summaryrefslogtreecommitdiff
path: root/trunk/dimbola
diff options
context:
space:
mode:
Diffstat (limited to 'trunk/dimbola')
-rw-r--r--trunk/dimbola/__init__.py44
-rw-r--r--trunk/dimbola/bgjobs.py231
-rw-r--r--trunk/dimbola/copier.py236
-rw-r--r--trunk/dimbola/copier_tests.py138
-rw-r--r--trunk/dimbola/db.py367
-rw-r--r--trunk/dimbola/grid.py464
-rw-r--r--trunk/dimbola/grid_tests.py202
-rw-r--r--trunk/dimbola/gtkapp.py98
-rw-r--r--trunk/dimbola/gtkapp.py.~1~99
-rw-r--r--trunk/dimbola/pluginmgr.py245
-rw-r--r--trunk/dimbola/pluginmgr_tests.py148
-rw-r--r--trunk/dimbola/plugins/checksum_plugin.py78
-rw-r--r--trunk/dimbola/plugins/export.ui105
-rw-r--r--trunk/dimbola/plugins/export_plugin.py101
-rw-r--r--trunk/dimbola/plugins/folderlist.ui31
-rw-r--r--trunk/dimbola/plugins/folderlist_plugin.py129
-rw-r--r--trunk/dimbola/plugins/gimp_plugin.py76
-rw-r--r--trunk/dimbola/plugins/import.ui66
-rw-r--r--trunk/dimbola/plugins/import_plugin.py213
-rw-r--r--trunk/dimbola/plugins/news_plugin.py47
-rw-r--r--trunk/dimbola/plugins/photoinfo.ui286
-rw-r--r--trunk/dimbola/plugins/photoinfo_plugin.py126
-rw-r--r--trunk/dimbola/plugins/phototags.ui45
-rw-r--r--trunk/dimbola/plugins/phototags_plugin.py107
-rw-r--r--trunk/dimbola/plugins/photoviewer.ui112
-rw-r--r--trunk/dimbola/plugins/photoviewer_plugin.py214
-rw-r--r--trunk/dimbola/plugins/rate_plugin.py101
-rw-r--r--trunk/dimbola/plugins/remove_photos.ui158
-rw-r--r--trunk/dimbola/plugins/remove_photos_plugin.py71
-rw-r--r--trunk/dimbola/plugins/rotate_plugin.py81
-rw-r--r--trunk/dimbola/plugins/rotate_plugin_tests.py53
-rw-r--r--trunk/dimbola/plugins/search.ui214
-rw-r--r--trunk/dimbola/plugins/search_plugin.py181
-rw-r--r--trunk/dimbola/plugins/tagtree.ui166
-rw-r--r--trunk/dimbola/plugins/tagtree_plugin.py318
-rw-r--r--trunk/dimbola/prefs.py81
-rw-r--r--trunk/dimbola/taglist.py161
-rw-r--r--trunk/dimbola/taglist_tests.py115
-rw-r--r--trunk/dimbola/ui.py385
-rw-r--r--trunk/dimbola/ui.ui556
-rw-r--r--trunk/dimbola/utils.py472
-rw-r--r--trunk/dimbola/utils.py.~1~473
-rw-r--r--trunk/dimbola/utils_tests.py363
43 files changed, 7957 insertions, 0 deletions
diff --git a/trunk/dimbola/__init__.py b/trunk/dimbola/__init__.py
new file mode 100644
index 0000000..44070a3
--- /dev/null
+++ b/trunk/dimbola/__init__.py
@@ -0,0 +1,44 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+# This is the canonical location of the version number.
+version = '0.0.3'
+
+
+# The MIME type for drag-and-drop for a list of tagids.
+TAGIDS_TYPE = 'application/x-dimbola-tagids'
+
+
+from utils import (abswalk, filterabswalk, safe_copy, filter_cmd,
+ image_data_to_pixbuf, pixbuf_to_image_data,
+ image_data_to_image_data,
+ rotate_pixbuf, scale_pixbuf,
+ encode_dnd_tagids, decode_dnd_tagids, TreeBuilder,
+ DcrawTypeCache, draw_star, draw_stars, sha1)
+from pluginmgr import Plugin, PluginManager
+from copier import Copier, ImageDict
+from db import Database
+from bgjobs import BackgroundJob, BackgroundManager
+from prefs import Preferences
+from grid import Grid, GridModel
+from taglist import Taglist
+from ui import UI, BackgroundStatus, MIN_WEIGHT, MAX_WEIGHT
+
diff --git a/trunk/dimbola/bgjobs.py b/trunk/dimbola/bgjobs.py
new file mode 100644
index 0000000..2b37143
--- /dev/null
+++ b/trunk/dimbola/bgjobs.py
@@ -0,0 +1,231 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+'''Manage heavy background processing tasks.
+
+bgjobs is a package that wraps around the multiprocessing module in
+the standard Python library. It gives a higher level abstraction for
+running CPU intensive background jobs in other processes. Each job is
+abstracted into a class with a specific interface, and bgjobs
+takes care of running jobs as CPUs become available, and returning
+the results.
+
+'''
+
+
+import multiprocessing
+import Queue
+
+
+class BackgroundJob(object):
+
+ '''A job to be run in the background, via multiprocessing.
+
+ This is a base class. Subclasses MUST implement the run method, which
+ does the actual job of the task. The run method should return the value
+ that is to be passed to the main process.
+
+ '''
+
+ def send_status(self, status):
+ '''Send status info to the foreground process.
+
+ The job may use this to, for example, inform a foreground UI
+ about progress with the job.
+
+ Note that the background runner sets the status_queue attribute
+ and this is necessary for this to work. This is the same queue
+ where results go; the caller is responsible for making sure
+ that the values can be differentiated from the return values
+ of the run method.
+
+ '''
+ self.status_queue.put(status)
+
+ def run(self):
+ raise Exception('Unimplemented run method.')
+
+
+def worker_process(jobs, results, status):
+ '''Run jobs and return results.'''
+
+ while True:
+ job = jobs.get()
+
+ job.status_queue = results
+ try:
+ result = job.run()
+ except BaseException, e:
+ result = e
+
+ results.put(result)
+ status.put(None)
+
+ jobs.close()
+ results.close()
+
+
+class BackgroundManager(object):
+
+ '''A manager for background jobs.
+
+ This starts and stops background processes, and gives jobs to them
+ as they become available.
+
+ Call add_job() to add a new job (subclass of BackgroundJob) to the
+ job queue, and start_jobs() to actually start executing jobs. After
+ start_jobs() has been called, background processes will continue to
+ wait for new jobs, and to execute them, just add them with add_job().
+
+ The caller MUST query the running property occasionally, and call
+ stop_jobs() when shutting down. It is not necessary to call stop_jobs()
+ until the caller application is shutting down, but it can be called
+ at any time, even when jobs are still being executed (all queued
+ and running jobs are terminated and forgotten, as are all existing
+ results).
+
+ Typical use:
+
+ manager = BackgroundManager()
+ manager.start_jobs()
+
+ while main_loop_needs_to_run:
+ if there is a new job:
+ manager.add_job()
+ do something else
+ if manager.running:
+ try:
+ result = manager.results.get(block=False)
+ except Queue.Empty:
+ pass
+ else:
+ do something with result
+
+ If you don't wish to poll results non-blockingly, just do this:
+
+ manager = BackgroundManager()
+ manager.start_jobs()
+ for job in jobs:
+ manager.add_job(job)
+
+ while manager.running:
+ result = manager.results.get(block=False)
+ do something with result
+
+ '''
+
+ # We start some child processes to run the jobs. We have a queue for
+ # unprocessed jobs (attribute jobs), and another for results. Each
+ # child process gets a job from the job queue, runs it, puts the result
+ # in the result queue.
+ #
+ # An additional queue is used for status reports, specifically the
+ # child processes send a token to the manager when they've finished
+ # running the job. This is used by the manager to keep track of when
+ # jobs have been finished. The caller needs to know this to be able
+ # to do things like report background processes in the user interface.
+
+ def __init__(self):
+ self.init()
+
+ def init(self):
+ '''Initialize things.
+
+ The user must not call this method.
+
+ '''
+ self.jobs = multiprocessing.Queue()
+ self.jobs_counter = 0
+ self.status = multiprocessing.Queue()
+ self.results = multiprocessing.Queue()
+ self.processes = []
+
+ @property
+ def running(self):
+ '''Are any child processes running jobs now?'''
+
+ if not self.processes:
+ return self.results.qsize() > 0
+
+ while True:
+ try:
+ item = self.status.get(block=False)
+ except Queue.Empty:
+ break
+ self.jobs_counter -= 1
+
+ return self.jobs_counter > 0 or self.results.qsize() > 0
+
+ def add_job(self, job):
+ '''Add a job to the queue.
+
+ The job will be executed when there is a free CPU to do it.
+
+ '''
+
+ self.jobs_counter += 1
+ self.jobs.put(job)
+
+ def start_jobs(self, maxproc=None):
+ '''Start executing jobs.
+
+ This starts the background processes. It must not be called if
+ there are any already running. A background process is started
+ for each job, unless maxproc is set, in which case that many
+ background processes are started.
+
+ '''
+
+ assert self.processes == []
+ if maxproc is None:
+ maxproc = multiprocessing.cpu_count()
+ for i in range(maxproc):
+ p = multiprocessing.Process(target=worker_process,
+ args=(self.jobs, self.results,
+ self.status))
+ self.processes.append(p)
+ p.start()
+
+ def stop_jobs(self):
+ '''Stop processing jobs.
+
+ The queue of jobs will be emptied. Currently running jobs will
+ be killed mercielssly, and will not produce results. This call
+ will block until all background processes have terminated.
+
+ '''
+
+ # Close pipes. This will shut down background threads so that
+ # things go away nicely. Not doing this will occasionally cause
+ # the background threads to throw exceptions.
+ self.jobs.close()
+ self.results.close()
+ self.status.close()
+ self.jobs.join_thread()
+ self.results.join_thread()
+ self.status.join_thread()
+
+ # Kill all processes.
+ for p in self.processes:
+ p.terminate()
+
+ # Wait for them to die.
+ for p in self.processes:
+ p.join()
+
+ # Start over.
+ self.init()
+
diff --git a/trunk/dimbola/copier.py b/trunk/dimbola/copier.py
new file mode 100644
index 0000000..fd9fc95
--- /dev/null
+++ b/trunk/dimbola/copier.py
@@ -0,0 +1,236 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import optparse
+import os
+import pwd
+import re
+
+import gnomevfs
+import pyexiv2
+
+import dimbola
+
+
+# The default template for renaming files.
+TEMPLATE = "%(date)s/%(username)s-%(date)s-%(counter)s%(suffix)s"
+
+
+class ImageDict:
+
+ """Hold data for templating output filenames.
+
+ The output filename template is just a string suitable for Python
+ string substitution, with the % operator. The data for the substition
+ needs to come from a dictionary. Since we want to get data both from
+ the image meta data (EXIF headers), and elsewhere, we use this class
+ to collect them all.
+
+ During initialization, we copy all the EXIF headers of the image into
+ our own dictionary, and then add some more data.
+
+ To do this, the caller must supply the constructor a pathname to the
+ image, a pyexiv2.Image instance, and a counter. The counter should be
+ incremented by the caller for each image; this allows the user to
+ specify a template like "%(year)s-%(counter)s".
+
+ We require the caller to supply the pyexiv2.Image instance so that
+ we don't need to do any I/O. This means there should be no reason for
+ this class to fail.
+
+ """
+
+ def __init__(self, pathname, image, counter):
+ date = image["Exif.Image.DateTime"]
+
+ self.dict = {
+ "username": self.get_username(),
+ "suffix": self.get_input_suffix(pathname),
+ "cameracounter": self.get_camera_counter(pathname),
+ "counter": counter,
+ "date": "%04d-%02d-%02d" % (date.year, date.month, date.day),
+ "year": date.year,
+ "month": date.month,
+ "day": date.day,
+ "hour": date.hour,
+ "min": date.minute,
+ "sec": date.second,
+ }
+
+ optional_key_prefix = "Exif.Image."
+ for key in image.exifKeys():
+ self.dict[key] = image[key]
+ if key.startswith(optional_key_prefix):
+ key2 = key[len(optional_key_prefix):]
+ self.dict[key2] = image[key]
+
+ def __getitem__(self, key):
+ return self.dict[key]
+
+ def __contains__(self, key):
+ return key in self.dict
+
+ def get_input_suffix(self, pathname):
+ """Return the suffix of the input filename, or empty."""
+ dummy, suffix = os.path.splitext(pathname)
+ return suffix
+
+ def get_camera_counter(self, pathname):
+ """Return the image counter in the input filename, or empty."""
+ basename = os.path.basename(pathname)
+ basename, ext = os.path.splitext(basename)
+ m = re.search(r"\d+", basename)
+ if m:
+ return m.group()
+ else:
+ return ""
+
+ def get_username(self): # pragma: no cover
+ """Return username of the user."""
+ return pwd.getpwuid(os.getuid()).pw_name
+
+
+
+class Copier:
+
+ """Copy digital photograps from memory card into desired location.
+
+ We scan the desired location recursively for files that have one
+ of the desired MIME types. Each image that we find, we copy to the
+ desired output location. Optionally, we delete the original.
+ The output filenames may be identical to the basenames of the
+ originals, or they may be constructed based on a template that
+ gets filled in with data from the input pathname, EXIF headers,
+ and other places.
+
+ """
+
+ known_image_types = set([
+ "image/x-canon-cr2",
+ "image/x-nikon-nef",
+ "image/jpeg",
+ ])
+
+ def __init__(self):
+ self.counter = 0
+
+ def is_image_file(self, pathname): # pragma: no cover
+ """Determine whether a given file is a (supported) image file."""
+ uri = gnomevfs.get_uri_from_local_path(os.path.abspath(pathname))
+ mime_type = gnomevfs.get_mime_type(uri)
+ return mime_type in self.known_image_types
+
+ def find_input_files(self, root): # pragma: no cover
+ """Recursively generate list of input files in a directory tree."""
+ all_names = []
+ for x, y, names in dimbola.filterabswalk(self.is_image_file, root):
+ all_names += names
+ all_names.sort()
+ return all_names
+
+ def read_exif(self, pathname): # pragma: no cover
+ """Read the EXIF data from a given file."""
+ image = pyexiv2.Image(pathname)
+ image.readMetadata()
+ return image
+
+ def output_name(self, input_name, options):
+ """Return the output name for a given input file."""
+
+ if options.rename:
+ image = self.read_exif(input_name)
+ image_dict = ImageDict(input_name, image, self.counter)
+ basename = options.template % image_dict
+ else:
+ basename = os.path.basename(input_name)
+
+ return os.path.join(options.output, basename)
+
+ def create_option_parser(self): # pragma: no cover
+ """Create an OptionParser instance for this app."""
+ parser = optparse.OptionParser()
+ parser.add_option("-i", "--input", metavar="DIR", default=".",
+ help="Scan DIR for files to import. "
+ "(Default: %default)")
+ parser.add_option("-o", "--output", metavar="DIR", default=".",
+ help="Write output to DIR. (Default: %default)")
+ parser.add_option("-t", "--template", metavar="TEMPLATE",
+ default=TEMPLATE,
+ help="Use TEMPLATE when renaming files. "
+ "(Default: %default)")
+ parser.add_option("-r", "--rename", action="store_true",
+ help="Rename files when copying.")
+ parser.add_option("--move", action="store_true",
+ help="Move files: delete originals after they "
+ "have been copied.")
+ parser.add_option("--verbose", action="store_true",
+ help="provide some progress output")
+ return parser
+
+ def parse_command_line(self): # pragma: no cover
+ """Parse the command line for this app."""
+ parser = self.create_option_parser()
+ options, args = parser.parse_args()
+ if args:
+ raise Exception("No non-option command line arguments allows.")
+ return options
+
+ def copy_file(self, input_name, options): # pragma: no cover
+ """Copy an input file according to options."""
+ while True:
+ self.counter += 1
+ try:
+ output_name = self.output_name(input_name, options)
+ except KeyError:
+ print 'ERROR: exif problem with %s' % input_name
+ return
+ except AttributeError:
+ print 'ERROR: exif problem with %s' % input_name
+ return
+ except IOError:
+ print 'ERROR: exif problem with %s' % input_name
+ return
+ if not os.path.exists(output_name):
+ break
+ output_dir = os.path.dirname(output_name) or "."
+ if os.path.exists(options.output) and not os.path.exists(output_dir):
+ os.makedirs(output_dir)
+ if options.verbose:
+ i = self.copied_files + 1
+ n = len(self.input_files)
+ print "%d/%d: %s -> %s" % (i, n, input_name, output_name)
+ if options.move:
+ os.rename(input_name, output_name)
+ else:
+ dimbola.safe_copy(input_name, output_name, None)
+
+ def find_total_bytes(self, pathnames): # pragma: no cover
+ """Find the total number of bytes in the given files."""
+ return sum([os.stat(x).st_size for x in pathnames])
+
+ def run(self): # pragma: no cover
+ """Main program of the application."""
+ options = self.parse_command_line()
+ self.input_files = self.find_input_files(options.input)
+ self.total_bytes = self.find_total_bytes(self.input_files)
+ self.copied_files = 0
+ self.copied_bytes = 0
+ for input_name in self.input_files:
+ self.this_file_bytes = 0
+ self.copy_file(input_name, options)
+ self.copied_files += 1
+ self.copied_bytes += self.this_file_bytes
+
diff --git a/trunk/dimbola/copier_tests.py b/trunk/dimbola/copier_tests.py
new file mode 100644
index 0000000..73c8070
--- /dev/null
+++ b/trunk/dimbola/copier_tests.py
@@ -0,0 +1,138 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import datetime
+import os
+import tempfile
+import unittest
+
+import dimbola
+
+
+class MockExiv2Image(dict):
+
+ def __init__(self):
+ dict.__init__(self)
+ self["Exif.Image.DateTime"] = datetime.datetime(2009, 4, 11,
+ 8, 6, 38)
+
+ def exifKeys(self):
+ return self.keys()
+
+
+class MockOptions:
+
+ def __init__(self):
+ self.rename = False
+ self.template = ""
+ self.input = "/foo"
+ self.output = "/bar"
+ self.verbose = False
+
+
+class ImageDictTests(unittest.TestCase):
+
+ def setUp(self):
+ mock_image = MockExiv2Image()
+ self.dict = dimbola.ImageDict("/foo/img_1234.cr2", mock_image, 42)
+
+ def test_gets_input_suffix_when_one_exists(self):
+ suffix = self.dict.get_input_suffix("/foo.bar/img_1234.cr2")
+ self.assertEqual(suffix, ".cr2")
+
+ def test_returns_empty_input_suffix_when_none_exists(self):
+ suffix = self.dict.get_input_suffix("/foo/bar.foobar/yeehaa")
+ self.assertEqual(suffix, "")
+
+ def test_returns_camera_counter_from_filename(self):
+ counter = self.dict.get_camera_counter("/foo/bar_1234.suffix5678")
+ self.assertEqual(counter, "1234")
+
+ def test_returns_empty_string_when_no_camera_counter_in_filename(self):
+ counter = self.dict.get_camera_counter("/foo/bar.suffix5678")
+ self.assertEqual(counter, "")
+
+ def test_sets_username_to_nonempty_string(self):
+ self.assertNotEqual(self.dict["username"], "")
+
+ def test_sets_suffix_to_input_filename_suffix(self):
+ self.assertEqual(self.dict["suffix"], ".cr2")
+
+ def test_sets_camera_counter_from_input_filename(self):
+ self.assertEqual(self.dict["cameracounter"], "1234")
+
+ def test_sets_counter_correctly(self):
+ self.assertEqual(self.dict["counter"], 42)
+
+ def test_sets_date_correctly(self):
+ self.assertEqual(self.dict["date"], "2009-04-11")
+
+ def test_sets_year_correctly(self):
+ self.assertEqual(self.dict["year"], 2009)
+
+ def test_sets_month_correctly(self):
+ self.assertEqual(self.dict["month"], 04)
+
+ def test_sets_day_correctly(self):
+ self.assertEqual(self.dict["day"], 11)
+
+ def test_sets_hour_correctly(self):
+ self.assertEqual(self.dict["hour"], 8)
+
+ def test_sets_min_correctly(self):
+ self.assertEqual(self.dict["min"], 6)
+
+ def test_sets_sec_correctly(self):
+ self.assertEqual(self.dict["sec"], 38)
+
+ def test_sets_exif_with_prefix(self):
+ self.assert_("Exif.Image.DateTime" in self.dict)
+
+ def test_sets_exif_without_prefix(self):
+ self.assert_("DateTime" in self.dict)
+
+
+class CopierTests(unittest.TestCase):
+
+ def setUp(self):
+ self.options = MockOptions()
+ self.importer = dimbola.Copier()
+
+ def test_initializes_counter_to_zero(self):
+ self.assertEqual(self.importer.counter, 0)
+
+ def test_increments_counter_when_copying_a_file(self):
+ fd, tempname = tempfile.mkstemp()
+ os.close(fd)
+ os.remove(tempname)
+ self.importer.output_name = lambda *args: tempname
+ self.importer.copy_file('/dev/null', self.options)
+
+ def test_has_nonempty_list_of_known_image_types(self):
+ self.assert_(self.importer.known_image_types)
+
+ def test_uses_basename_of_input_for_output_when_no_renaming(self):
+ self.assertEqual(self.importer.output_name("/foo/img_1234.cr2",
+ self.options),
+ "/bar/img_1234.cr2")
+
+ def test_uses_template_when_renaming(self):
+ self.options.template = "%(date)s-%(cameracounter)s%(suffix)s"
+ self.options.rename = True
+ self.importer.read_exif = lambda s: MockExiv2Image()
+ self.assertEqual(self.importer.output_name("/foo/img_1234.cr2",
+ self.options),
+ "/bar/2009-04-11-1234.cr2")
diff --git a/trunk/dimbola/db.py b/trunk/dimbola/db.py
new file mode 100644
index 0000000..13c7e27
--- /dev/null
+++ b/trunk/dimbola/db.py
@@ -0,0 +1,367 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import logging
+import os
+import sqlite3
+
+import dimbola
+
+
+class Database(object):
+
+ '''Interface to the database.
+
+ This provides a thin layer of abstraction to the Sqlite3 database
+ file we use to store data. It makes testing those parts that access
+ the database easier: all actual SQL queries are in this class, so
+ it is easy to mock up when testing upper level.
+
+ '''
+
+ SUPPORTED = 2
+
+ def __init__(self, filename):
+ self.conn = sqlite3.connect(filename)
+ self.transaction = None
+
+ def init_db(self):
+ '''Create tables in the database.'''
+
+ logging.debug('Creating database tables.')
+
+ self.begin_transaction()
+
+ self.execute('''create table if not exists dbmeta (version integer)''')
+ version = self.query_first('select version from dbmeta')
+ if version is None:
+ version = 1
+ if version > self.SUPPORTED:
+ raise Exception('Database is version %s, we support up to %s' %
+ (version, self.SUPPORTED))
+ if version < self.SUPPORTED:
+ self.execute('delete from dbmeta')
+ self.execute('insert into dbmeta (version) values (?)',
+ (self.SUPPORTED,))
+
+ self.execute('''create table if not exists photos
+ (photoid integer primary key autoincrement,
+ folderid integer,
+ basename string,
+ rating integer,
+ rotate integer)''')
+ if version < 2:
+ self.execute('alter table photos add column sha1 string')
+
+ self.execute('''create table if not exists folders
+ (folderid integer primary key autoincrement,
+ foldername string unique)''')
+
+ self.execute('''create table if not exists thumbnails
+ (photoid integer unique,
+ thumbnail blob)''')
+
+ self.execute('''create table if not exists previews
+ (photoid integer unique,
+ preview blob)''')
+
+ self.execute('''create table if not exists tagnames
+ (tagid integer primary key,
+ tagname string,
+ tagparent integer)''')
+
+ self.execute('''create table if not exists tags
+ (tagid integer,
+ photoid integer)''')
+
+ self.execute('''create table if not exists exif
+ (exifid integer,
+ photoid integer,
+ exifvalue string)''')
+
+ self.execute('''create table if not exists exifnames
+ (exifid integer primary key,
+ exifname string unique)''')
+
+ self.end_transaction()
+
+ def begin_transaction(self):
+ '''Start a transaction.
+
+ Any multiple-query thing should happen within a transaction.
+
+ '''
+
+ assert self.transaction is None
+ self.transaction = self.conn.cursor()
+
+ def end_transaction(self):
+ '''End the current transaction.'''
+
+ assert self.transaction is not None
+ self.transaction = None
+ self.conn.commit()
+
+ def __enter__(self):
+ self.begin_transaction()
+
+ def __exit__(self, exc_type, exc_value, exc_traceback):
+ if exc_type is None:
+ self.end_transaction()
+ else:
+ self.conn.rollback()
+ self.transaction = None
+
+ def execute(self, sql, args=None):
+ '''Execute some SQL within the current transaction.'''
+ logging.debug('Executing SQL: "%s" with arguments %s' % (sql, args))
+ if args is None:
+ self.transaction.execute(sql)
+ else:
+ self.transaction.execute(sql, args)
+
+ def query(self, sql, args=None):
+ '''Query the database within the current transaction.
+
+ Generate result rows, one by one.
+
+ '''
+
+ self.execute(sql, args)
+ for row in self.transaction:
+ yield row
+
+ def query_first(self, sql, args=None):
+ '''Like query, but return first column of first match, or None.'''
+ for row in self.query(sql, args):
+ return row[0]
+ return None
+
+ def find_folder(self, foldername):
+ '''Return folderid corresponding to a foldername, or None.'''
+ sql = 'select folderid from folders where foldername = ?'
+ return self.query_first(sql, (foldername,))
+
+ def get_folder_name(self, folderid):
+ '''Return foldername that corresponds to folder id.'''
+ sql = 'select foldername from folders where folderid = ?'
+ return self.query_first(sql, (folderid,))
+
+ def add_folder(self, foldername):
+ '''Add a new folder to the database, return its id.'''
+ self.execute('insert into folders (foldername) values (?)',
+ (foldername,))
+ folderid = self.transaction.lastrowid
+ logging.debug('Added folder %s as %s' % (foldername, folderid))
+ return folderid
+
+ def find_photoids(self):
+ '''Return list of ids of all photos in database.'''
+ return [row[0] for row in self.query('select photoid from photos')]
+
+ def find_photoids_in_folder(self, folderid):
+ '''Return ids of photos in a folder.'''
+ sql = 'select photoid from photos where folderid = ?'
+ return [row[0] for row in self.query(sql, (folderid,))]
+
+ def add_photo(self, folderid, basename, rating, rotate):
+ '''Add a new photo to the database, return its id.'''
+ self.execute('''insert into photos
+ (folderid, basename, rating, rotate)
+ values (:folderid, :basename, :rating, :rotate)''',
+ { 'folderid': folderid,
+ 'basename': basename,
+ 'rating': rating,
+ 'rotate': rotate })
+ photoid = self.transaction.lastrowid
+ logging.debug('Added photo %s in folder %s as %s' %
+ (basename, folderid, photoid))
+ return photoid
+
+ def remove_photo(self, photoid):
+ '''Remove photo from database.
+
+ This also removes all tags and other metadata related to the
+ photo.
+
+ '''
+
+ self.execute('delete from photos where photoid = ?', (photoid,))
+ self.execute('delete from thumbnails where photoid = ?', (photoid,))
+ self.execute('delete from previews where photoid = ?', (photoid,))
+ self.execute('delete from tags where photoid = ?', (photoid,))
+ self.execute('delete from exif where photoid = ?', (photoid,))
+
+ def set_sha1(self, photoid, sha1):
+ '''Set the sha1 checksum for a photo.'''
+ self.execute('update photos set sha1 = ? where photoid = ?',
+ (sha1, photoid))
+
+ def get_sha1(self, photoid):
+ '''Return the sha1 checksum for a photo.'''
+ return self.query_first('select sha1 from photos where photoid = ?',
+ (photoid,))
+
+ def find_photos_without_checksum(self):
+ '''Return list of photos without checksums.'''
+ sql = 'select photoid from photos where sha1 isnull'
+ return [row[0] for row in self.query(sql)]
+
+ def get_basic_photo_metadata(self, photoid):
+ '''Return the basic metadata about a photo.
+
+ Return folderid, basename, rating, rotate.
+
+ '''
+
+ sql = ('select folderid, basename, rating, rotate from photos '
+ 'where photoid = ?')
+ for row in self.query(sql, (photoid,)):
+ return row
+ return None, None, None, None
+
+ def get_photo_pathname(self, photoid):
+ '''Return the full pathname of a photo.'''
+ folderid, basename, c, d = self.get_basic_photo_metadata(photoid)
+ foldername = self.get_folder_name(folderid)
+ return os.path.join(foldername, basename)
+
+ def find_exifname(self, exifname):
+ '''Return exifid corresponding to exifname, or None.'''
+ sql = 'select exifid from exifnames where exifname = ?'
+ return self.query_first(sql, (exifname,))
+
+ def add_exifname(self, exifname):
+ '''Add a new exifname to the database, return its id.'''
+ self.execute('insert into exifnames (exifname) values (?)',
+ (exifname,))
+ exifid = self.transaction.lastrowid
+ logging.debug('Added exifname %s as %s' % (exifname, exifid))
+ return exifid
+
+ def add_exif(self, photoid, exifid, exifvalue):
+ '''Add a new exif header for a photo.'''
+ self.execute('''insert into exif (photoid, exifid, exifvalue)
+ values (:photoid, :exifid, :exifvalue)''',
+ { 'photoid': photoid,
+ 'exifid': exifid,
+ 'exifvalue': exifvalue })
+
+ def get_exif(self, photoid, exifname):
+ '''Return a given exif header for a given photo.'''
+ exifid = self.find_exifname(exifname)
+ sql = 'select exifvalue from exif where photoid = ? and exifid = ?'
+ value = self.query_first(sql, (photoid, exifid))
+ if value is not None:
+ value = str(value)
+ return value
+
+ def add_thumbnail(self, photoid, thumbnail):
+ '''Add a new thumbnail for a photo.'''
+ self.execute('''insert into thumbnails (photoid, thumbnail)
+ values (:photoid, :thumbnail)''',
+ { 'photoid': photoid,
+ 'thumbnail': buffer(thumbnail) })
+
+ def get_thumbnail(self, photoid):
+ '''Return the thumbnail for a given photo, or None.'''
+ sql = 'select thumbnail from thumbnails where photoid = ?'
+ return self.query_first(sql, (photoid,))
+
+ def add_preview(self, photoid, preview):
+ '''Add a new preview for a photo.'''
+ self.execute('''insert into previews (photoid, preview)
+ values (:photoid, :preview)''',
+ { 'photoid': photoid,
+ 'preview': buffer(preview) })
+
+ def get_preview(self, photoid):
+ '''Return the preview for a given photo, or None.'''
+ sql = 'select preview from previews where photoid = ?'
+ return self.query_first(sql, (photoid,))
+
+ def get_tagnames(self):
+ '''Return generator to go through all tagid, name, parentid tuples.'''
+ sql = 'select tagid, tagname, tagparent from tagnames'
+ for tagid, tagname, tagparent in self.query(sql):
+ yield tagid, tagname, tagparent
+
+ def get_tagname(self, tagid):
+ '''Return name corresponding to a tagid.'''
+ sql = 'select tagname from tagnames where tagid = ?'
+ return self.query_first(sql, (tagid,))
+
+ def add_tagname(self, tagname):
+ '''Add a new tagname to the database, return its id.'''
+ self.execute('insert into tagnames (tagname) values (?)',
+ (tagname,))
+ tagid = self.transaction.lastrowid
+ logging.debug('Added tagname %s as %s' % (tagname, tagid))
+ return tagid
+
+ def remove_tagname(self, tagid):
+ '''Remove a tag name from the database.
+
+ This also removes the tag from the photos that have it.
+
+ '''
+ self.execute('delete from tags where tagid = ?', (tagid,))
+ self.execute('delete from tagnames where tagid = ?', (tagid,))
+
+ def change_tagname(self, tagid, tagname):
+ '''Change the name of a tag.'''
+ self.execute('update tagnames set tagname = ? where tagid = ?',
+ (tagname, tagid))
+
+ def set_tagparent(self, tagid, parentid):
+ '''Set parent of a tag.'''
+ self.execute('update tagnames set tagparent = ? where tagid = ?',
+ (parentid, tagid))
+
+ def get_tagchildren(self, tagid):
+ '''Return ids of all child tags of a given tag.'''
+ sql = 'select tagid from tagnames where tagparent = ?'
+ for childid in self.query(sql, (tagid,)):
+ yield childid[0]
+
+ def get_tagids(self, photoid):
+ '''Return list of tagids that apply to a photo.'''
+ sql = 'select tagid from tags where photoid = ?'
+ tagids = []
+ for row in self.query(sql, (photoid,)):
+ tagids.append(row[0])
+ return tagids
+
+ def add_tagid(self, photoid, tagid):
+ '''Add a tagid for a photo.'''
+ sql = 'insert into tags (photoid, tagid) values (?, ?)'
+ self.execute(sql, (photoid, tagid))
+
+ def remove_tagid(self, photoid, tagid):
+ '''Remove a tagid for a photo.'''
+ sql = 'delete from tags where photoid = ? and tagid = ?'
+ self.execute(sql, (photoid, tagid))
+
+ def set_rating(self, photoid, rating):
+ '''Set rating for a photo.'''
+ sql = 'update photos set rating = ? where photoid = ?'
+ self.execute(sql, (rating, photoid))
+
+ def set_rotate(self, photoid, rotate):
+ '''Set rotation angle for a photo.'''
+ sql = 'update photos set rotate = ? where photoid = ?'
+ self.execute(sql, (rotate, photoid))
+
diff --git a/trunk/dimbola/grid.py b/trunk/dimbola/grid.py
new file mode 100644
index 0000000..49916af
--- /dev/null
+++ b/trunk/dimbola/grid.py
@@ -0,0 +1,464 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import gobject
+import gtk
+
+import dimbola
+
+
+class GridModel(gobject.GObject):
+
+ '''This is the MVC model of the thumbnail grid.
+
+ This class takes care of maintaining data about the grid: the current
+ list of photoids to be shown in the grid (photoids property), and the
+ list of photoids that are currently selected (selected property).
+ It also takes care of computing the vertical size in pixels of the
+ thumbnail grid (the whole grid, not just the visible part that gets
+ painted onto a gtk.DrawingArea).
+
+ The .selected property is a bit special. Its first element, if any,
+ is the focus for moving selection around with the keyboard.
+
+ '''
+
+ __gsignals__ = {
+ 'photoids-changed': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, []),
+ 'selection-changed': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, []),
+ }
+
+ def __init__(self):
+ gobject.GObject.__init__(self)
+ self._photoids = []
+ self._selected = []
+ self.thumbnails = dict()
+ self.angles = dict()
+ self.padding = 20
+ self.scale_value = None
+ self.widget_width = None
+ self.widget_height = None
+
+ def get_photoids(self):
+ return self._photoids
+ def set_photoids(self, photoids):
+ self._photoids = photoids
+ del self.selected[:]
+ self.thumbnails.clear()
+ self.angles.clear()
+ self.photoids_changed()
+ photoids = property(get_photoids, set_photoids)
+
+ def photoids_changed(self):
+ '''Emit the photoids-changed signal.'''
+ self.emit('photoids-changed')
+
+ def get_selected(self):
+ return self._selected
+ def set_selected(self, selected):
+ assert set(self._photoids).issuperset(set(selected))
+ self._selected = selected
+ self.selection_changed()
+ selected = property(get_selected, set_selected)
+
+ def selection_changed(self):
+ '''Emit the selection-changed signal.'''
+ self.emit('selection-changed')
+
+ def set_thumbnail(self, photoid, thumbnail):
+ '''Set the thumbnail for a photo.'''
+ assert photoid in self.photoids
+ self.thumbnails[photoid] = thumbnail
+ self.photoids_changed()
+
+ def set_angle(self, photoid, angle):
+ '''Set the angle for a photo.'''
+ assert photoid in self.photoids
+ self.angles[photoid] = angle
+ self.photoids_changed()
+
+ @property
+ def maxdim(self):
+ '''Maximum dimension of a thumbnail on the grid.'''
+ return min(int(self.scale_value), self.widget_width - self.padding)
+
+ @property
+ def distance(self):
+ '''Compute the distance between thumbnails in grid.
+
+ This includes padding between them.
+
+ '''
+ return self.maxdim + self.padding
+
+ @property
+ def maxcols(self):
+ '''Maximum number of columns.'''
+ return self.widget_width / self.distance
+
+ @property
+ def vertical_pixels(self):
+ '''Height of thumbnail grid (not just visible part) in pixels.
+
+ We get the dimension of the visible grid (the gtk.DrawingArea
+ widget) in pixels, and the size of the thumbnails also in pixels
+ from the slider. We also get the number of photos. We need to
+ compute the height of grid (not just visible part), in pixels.
+
+ '''
+
+ total_rows = (len(self.photoids) + self.maxcols - 1) / self.maxcols
+ return total_rows * self.distance
+
+ def thumbnail_pos(self, i):
+ '''Compute x and y for thumbnail of ith photo in grid.'''
+
+ maxcols = self.widget_width / self.distance
+
+ colno = i % self.maxcols
+ x = colno * self.distance
+
+ rowno = i / self.maxcols
+ y = rowno * self.distance
+
+ return x, y
+
+ def select_next(self):
+ '''Select next photo.'''
+ if self.selected:
+ i = self.photoids.index(self.selected[0])
+ self.selected = [self.photoids[min(i+1, len(self.photoids) - 1)]]
+ else:
+ self.selected = [self.photoids[0]]
+
+ def select_previous(self):
+ '''Select previous photo.'''
+ if self.selected:
+ i = self.photoids.index(self.selected[0])
+ self.selected = [self.photoids[max(i-1, 0)]]
+ else:
+ self.selected = [self.photoids[0]]
+
+
+class GridView(object): # pragma: no cover
+
+ '''This is the MVC view of the thumbnail grid.
+
+ This class takes care of drawing things on the grid.
+
+ '''
+
+ def __init__(self, model, widget, scrollbar):
+ self.model = model
+ self.widget = widget
+ self.scrollbar = scrollbar
+
+ # Set up the widget as a drag target.
+ self.widget.drag_dest_set(gtk.DEST_DEFAULT_ALL,
+ [(dimbola.TAGIDS_TYPE,
+ gtk.TARGET_SAME_APP, 0)],
+ gtk.gdk.ACTION_COPY)
+
+ # Which photoids are currently drawn as selected?
+ self.drawn_selected = list()
+
+ # Connect to the model's change signals so we automatically
+ # redraw the grid.
+ self.model.connect('photoids-changed', self.refresh_thumbnails)
+ self.model.connect('selection-changed', self.refresh_selection)
+
+ def resize_scrollbar(self):
+ '''Change the scrollbar's adjustment so it matches the model.'''
+ adj = self.scrollbar.get_adjustment()
+
+ lower = 0
+ upper = max(0, self.model.vertical_pixels - self.model.widget_height)
+ step = self.model.distance
+ page = self.model.widget_height
+
+ adj.set_all(lower=lower,
+ upper=upper,
+ step_increment=step,
+ page_increment=page,
+ page_size=page)
+
+ def coords_to_photoid(self, x, y):
+ '''Convert from widget's x,y to photoid.'''
+ y += int(self.scrollbar.get_value())
+ for i, photoid in enumerate(self.model.photoids):
+ x1, y1 = self.model.thumbnail_pos(i)
+ if (x >= x1 and x < x1 + self.model.distance and
+ y >= y1 and y < y1 + self.model.distance):
+ return photoid
+ return None
+
+ def refresh_selection(self, *args):
+ '''Update the selection on screen.'''
+ for photoid in set(self.drawn_selected + self.model.selected):
+ self.draw_thumbnail(photoid)
+ self.drawn_selected = self.model.selected[:]
+
+ def refresh_thumbnails(self, *args):
+ '''Update all thumbnails on screen.'''
+ if self.widget.window:
+ # We only do this if we're mapped and can draw.
+ self.widget.window.clear()
+ for photoid in self.model.photoids:
+ self.draw_thumbnail(photoid)
+ self.draw_focus_indicator()
+
+ def draw_focus_indicator(self):
+ '''Draw a visual focus indicator, if we have focus.'''
+
+ if self.widget.flags() & gtk.HAS_FOCUS:
+ width, height = self.widget.window.get_size()
+ self.widget.get_style().paint_focus(self.widget.window,
+ self.widget.state,
+ None,
+ None,
+ None,
+ 0, 0,
+ width, height)
+
+ def highlight_thumbnail(self, photoid):
+ '''Drag thumbnail with a drag destination highlight.'''
+ self.draw_thumbnail(photoid, highlight=True)
+
+ def draw_thumbnail(self, photoid, highlight=False):
+ '''Draw thumbnail onto grid view.'''
+
+ thumb = self.model.thumbnails.get(photoid)
+ if not thumb:
+ # We don't have the thumbnail yet. Can't draw it.
+ return
+
+ w = self.widget.window
+
+ style = self.widget.get_style()
+ if highlight:
+ bg = style.bg_gc[gtk.STATE_PRELIGHT]
+ fg = style.fg_gc[gtk.STATE_PRELIGHT]
+ else:
+ bg = style.bg_gc[gtk.STATE_NORMAL]
+ fg = style.fg_gc[gtk.STATE_NORMAL]
+
+ thumb = dimbola.scale_pixbuf(thumb, self.model.maxdim,
+ self.model.maxdim)
+ thumb = dimbola.rotate_pixbuf(thumb, self.model.angles.get(photoid, 0))
+ i = self.model.photoids.index(photoid)
+ x, y = self.model.thumbnail_pos(i)
+
+ y0 = int(self.scrollbar.get_value())
+ if y + self.model.distance < y0:
+ return
+ if y >= y0 + self.model.widget_height:
+ return
+
+ if photoid in self.model.selected:
+ gc = style.bg_gc[gtk.STATE_SELECTED]
+ else:
+ gc = bg
+ w.draw_rectangle(gc, True, x, y - y0, self.model.distance,
+ self.model.distance)
+
+ xdelta = (self.model.distance - thumb.get_width()) / 2
+ ydelta = (self.model.distance - thumb.get_height()) / 2
+ w.draw_pixbuf(fg, thumb, 0, 0, x + xdelta, y + ydelta - y0)
+ if highlight:
+ w.draw_rectangle(fg, False, x, y - y0, self.model.distance - 1,
+ self.model.distance - 1)
+
+
+class Grid(object): # pragma: no cover
+
+ '''This is the MVC controller of the thumbnail grid.
+
+ This class takes care of responding to events and signals related
+ to the grid. The rest of the world will interface with the grid
+ via this class.
+
+ '''
+
+ def __init__(self, mwc):
+ mwc.connect('setup-widgets', self.init)
+ mwc.connect('photo-meta-changed', self.on_photo_meta_changed)
+
+ def init(self, mwc):
+ '''Initialize this object after mwc's setup-widgets signal emitted.'''
+
+ self.mwc = mwc
+
+ self.model = GridModel()
+
+ self.box = mwc.widgets['grid_vbox']
+ drawingarea = mwc.widgets['thumbnail_drawingarea']
+ scrollbar = mwc.widgets['thumbnail_vscrollbar']
+ self.view = GridView(self.model, drawingarea, scrollbar)
+
+ self.scale = mwc.widgets['thumbnail_scale']
+ self.scale.set_range(50, 300)
+ self.scale.set_increments(10, 25)
+ self.scale.set_value(200)
+ self.model.scale_value = self.scale.get_value()
+
+ self.drag_dest = None
+
+ def on_photo_meta_changed(self, mwc, photoid):
+ with mwc.db:
+ a, b, c, rotate = mwc.db.get_basic_photo_metadata(photoid)
+ thumbnail = mwc.db.get_thumbnail(photoid)
+ self.model.set_angle(photoid, rotate)
+
+ def on_thumbnail_drawingarea_configure_event(self, widget, event):
+ self.model.widget_width = event.width
+ self.model.widget_height = event.height
+
+ def on_thumbnail_drawingarea_expose_event(self, *args):
+ if self.model.widget_height is not None:
+ self.view.refresh_thumbnails()
+ self.view.resize_scrollbar()
+
+ def on_thumbnail_scale_value_changed(self, *args):
+ self.model.scale_value = self.scale.get_value()
+ if self.model.widget_height is not None:
+ self.view.refresh_thumbnails()
+ self.view.resize_scrollbar()
+
+ def on_thumbnail_vscrollbar_value_changed(self, vscrollbar):
+ self.view.refresh_thumbnails()
+
+ def on_thumbnail_drawingarea_scroll_event(self, widget, event):
+ adj = self.view.scrollbar.get_adjustment()
+ value = adj.get_value()
+ step = adj.get_step_increment()
+ if event.direction == gtk.gdk.SCROLL_UP:
+ value = max(0, value - step)
+ else:
+ value = min(adj.get_upper(), value + step)
+ adj.set_value(value)
+
+ def on_thumbnail_drawingarea_button_press_event(self, widget, event):
+ '''Let user change thumbnail selection with mouse.'''
+
+ widget.grab_focus()
+
+ if event.button != 1:
+ return False
+
+ shift = (event.state & gtk.gdk.SHIFT_MASK) == gtk.gdk.SHIFT_MASK
+ ctrl = (event.state & gtk.gdk.CONTROL_MASK) == gtk.gdk.CONTROL_MASK
+ photoid = self.view.coords_to_photoid(event.x, event.y)
+ photoids = self.model.photoids
+ selected = self.model.selected
+
+ if event.type == gtk.gdk._2BUTTON_PRESS:
+ self.mwc.widgets['view_photo_menuitem'].set_active(True)
+ return False
+
+ if shift and photoid is not None:
+ # Extend current selection by selecting everything from oldest
+ # selection to the current one, inclusive, and only those.
+ if selected:
+ del selected[1:]
+ index0 = photoids.index(selected[0])
+ index = photoids.index(photoid)
+ if index < index0:
+ for i in range(index, index0):
+ selected.append(photoids[i])
+ else:
+ for i in range(index0 + 1, index + 1):
+ selected.append(photoids[i])
+ else:
+ # No current selection, just select current.
+ selected.append(photoid)
+ elif ctrl and photoid is not None:
+ # Add or remove current photo to selection.
+ if photoid in selected:
+ selected.remove(photoid)
+ else:
+ selected.append(photoid)
+ elif not shift and not ctrl:
+ # Just select current one.
+ del selected[:]
+ if photoid is not None:
+ selected.append(photoid)
+
+ self.model.selected = selected
+
+ def on_thumbnail_drawingarea_drag_leave(self, w, dc, timestamp):
+ if self.drag_dest is not None:
+ self.view.draw_thumbnail(self.drag_dest)
+ self.drag_dest = None
+
+ def on_thumbnail_drawingarea_drag_motion(self, w, dc, x, y, timestamp):
+ if self.drag_dest is not None:
+ self.view.draw_thumbnail(self.drag_dest)
+ self.drag_dest = None
+ self.drag_dest = self.view.coords_to_photoid(x, y)
+ if self.drag_dest is None:
+ return False
+ else:
+ dc.drag_status(gtk.gdk.ACTION_COPY, timestamp)
+ self.view.highlight_thumbnail(self.drag_dest)
+ return True
+
+ def on_thumbnail_drawingarea_drag_data_received(self, *args):
+ w, dc, x, y, data, info, timestamp = args
+ photoid = self.view.coords_to_photoid(x, y)
+ if photoid is not None:
+ tagids = dimbola.decode_dnd_tagids(data.data)
+ with self.mwc.db:
+ old_tagids = set(self.mwc.db.get_tagids(photoid))
+ for tagid in tagids:
+ if tagid not in old_tagids:
+ self.mwc.db.add_tagid(photoid, tagid)
+ dc.finish(True, False, timestamp)
+ self.model.selection_changed()
+
+ def request_rating(self, stars):
+ self.mwc.emit('photo-rating-requested', stars)
+
+ def on_thumbnail_drawingarea_key_press_event(self, widget, event):
+ if event.type == gtk.gdk.KEY_PRESS:
+ bindings = {
+ gtk.keysyms.Left: self.model.select_previous,
+ gtk.keysyms.Right: self.model.select_next,
+ '0': lambda *args: self.request_rating(0),
+ '1': lambda *args: self.request_rating(1),
+ '2': lambda *args: self.request_rating(2),
+ '3': lambda *args: self.request_rating(3),
+ '4': lambda *args: self.request_rating(4),
+ '5': lambda *args: self.request_rating(5),
+ }
+ if event.keyval in bindings:
+ bindings[event.keyval]()
+ return True
+ elif event.string in bindings:
+ bindings[event.string]()
+ return True
+ return False
+
+ def on_view_grid_menuitem_activate(self, radio):
+ if radio.get_active():
+ self.box.show()
+ else:
+ self.box.hide()
+
diff --git a/trunk/dimbola/grid_tests.py b/trunk/dimbola/grid_tests.py
new file mode 100644
index 0000000..8af2f29
--- /dev/null
+++ b/trunk/dimbola/grid_tests.py
@@ -0,0 +1,202 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import unittest
+
+import dimbola
+
+
+class GridDimensionsTests(unittest.TestCase):
+
+ def setUp(self):
+ self.model = dimbola.GridModel()
+ self.model.scale_value = 10
+ self.model.widget_width = 100
+ self.model.widget_height = 100
+ self.cell_dim = self.model.scale_value + self.model.padding
+ self.per_row = self.model.widget_width / self.cell_dim
+
+ def test_sets_padding_by_default(self):
+ self.assert_(self.model.padding > 0)
+
+ def test_computes_vertical_pixels_correctly_for_no_photos(self):
+ self.assertEqual(self.model.vertical_pixels, 0)
+
+ def test_computes_vertical_pixels_correctly_for_one_photo(self):
+ self.model.photoids = [1]
+ self.assertEqual(self.model.vertical_pixels, self.cell_dim)
+
+ def test_computes_vertical_pixels_correctly_for_one_row(self):
+ self.model.photoids = range(self.per_row)
+ self.assertEqual(self.model.vertical_pixels, self.cell_dim)
+
+ def test_computes_vertical_pixels_correctly_for_two_rows(self):
+ self.model.photoids = range(1 + self.per_row)
+ self.assertEqual(self.model.vertical_pixels, self.cell_dim * 2)
+
+ def test_computes_vertical_pixels_correctly_for_lots_of_rows(self):
+ self.model.photoids = range(self.per_row * 100**2)
+ self.assertEqual(self.model.vertical_pixels, self.cell_dim * 100**2)
+
+
+class GridModelTests(unittest.TestCase):
+
+ def setUp(self):
+ self.model = dimbola.GridModel()
+
+ def fake_emit(self, *args):
+ self.emit_args = args
+
+ def test_has_empty_list_of_photoids_initially(self):
+ self.assertEqual(self.model.photoids, [])
+
+ def test_sets_photoids_correctly(self):
+ self.model.photoids = [1, 2, 3]
+ self.assertEqual(self.model.photoids, [1, 2, 3])
+
+ def test_photoids_changed_emits_signals(self):
+ self.model.emit = self.fake_emit
+ self.model.photoids_changed()
+ self.assertEqual(self.emit_args, ('photoids-changed',))
+
+ def test_setting_photoids_emits_signal(self):
+ self.model.emit = self.fake_emit
+ self.model.photoids = [1]
+ self.assertEqual(self.emit_args, ('photoids-changed',))
+
+ def test_has_empty_list_of_selected_initially(self):
+ self.assertEqual(self.model.selected, [])
+
+ def test_selecting_unknown_photoids_raises_exception(self):
+ self.assertRaises(AssertionError, self.model.set_selected, [1, 2, 3])
+
+ def test_sets_selected_correctly(self):
+ self.model.photoids = [1, 2, 3]
+ self.model.selected = [1, 2, 3]
+ self.assertEqual(self.model.selected, [1, 2, 3])
+
+ def test_selection_changed_emits_signals(self):
+ self.model.emit = self.fake_emit
+ self.model.selection_changed()
+ self.assertEqual(self.emit_args, ('selection-changed',))
+
+ def test_setting_selection_emits_signal(self):
+ self.model.emit = self.fake_emit
+ self.model.photoids = [1, 2, 3]
+ self.model.selected = [1]
+ self.assertEqual(self.emit_args, ('selection-changed',))
+
+ def test_setting_photoids_removes_selection(self):
+ self.model.photoids = [1]
+ self.model.selected = [1]
+ self.model.photoids = [2]
+ self.assertEqual(self.model.selected, [])
+
+ def test_setting_photoids_clears_angles(self):
+ self.model.photoids = [1]
+ self.model.angles[1] = 180
+ self.model.photoids = [1]
+ self.assert_(1 not in self.model.angles)
+
+ def test_setting_photoids_clears_thumbnails(self):
+ self.model.photoids = [1]
+ self.model.thumbnails[1] = 'mock thumbnail'
+ self.model.photoids = [1]
+ self.assert_(1 not in self.model.thumbnails)
+
+ def test_sets_thumbnail(self):
+ self.model.photoids = [1]
+ self.model.set_thumbnail(1, 'mock thumbnail')
+ self.assertEqual(self.model.thumbnails[1], 'mock thumbnail')
+
+ def test_setting_thumbnail_for_nonexistent_photo_raises_exception(self):
+ self.assertRaises(AssertionError, self.model.set_thumbnail, 1, '')
+
+ def test_setting_thumbnail_emits_signal(self):
+ self.model.emit = self.fake_emit
+ self.model.photoids = [1]
+ self.model.set_thumbnail(1, 'mock thumbnail')
+ self.assertEqual(self.emit_args, ('photoids-changed',))
+
+ def test_sets_angle(self):
+ self.model.photoids = [1]
+ self.model.set_angle(1, 90)
+ self.assertEqual(self.model.angles[1], 90)
+
+ def test_setting_angle_for_nonexistent_photo_raises_exception(self):
+ self.assertRaises(AssertionError, self.model.set_angle, 1, 90)
+
+ def test_setting_angle_emits_signal(self):
+ self.model.emit = self.fake_emit
+ self.model.photoids = [1]
+ self.model.set_angle(1, 90)
+ self.assertEqual(self.emit_args, ('photoids-changed',))
+
+
+class ThumbnailPosTests(unittest.TestCase):
+
+ def setUp(self):
+ self.model = dimbola.GridModel()
+ self.model.photoids = range(10)
+ self.model.widget_width = 100
+ self.model.padding = 10
+ self.model.scale_value = 20
+ self.cell_dim = self.model.padding + self.model.scale_value
+ self.per_row = self.model.widget_width / self.cell_dim
+
+ def test_computes_xy_of_zeroth_photo_correctly(self):
+ self.assertEqual(self.model.thumbnail_pos(0), (0, 0))
+
+ def test_computes_xy_of_last_photo_on_first_row_correctly(self):
+ self.assertEqual(self.model.thumbnail_pos(self.per_row - 1),
+ ((self.per_row - 1) * self.cell_dim, 0))
+
+ def test_computes_xy_of_first_photo_on_second_row_correctly(self):
+ self.assertEqual(self.model.thumbnail_pos(self.per_row),
+ (0, self.cell_dim))
+
+ def test_computes_xy_of_last_photo_row_correctly(self):
+ i = len(self.model.photoids) - 1
+ row = i / self.per_row
+ col = i % self.per_row
+ self.assertEqual(self.model.thumbnail_pos(i),
+ (col * self.cell_dim, row * self.cell_dim))
+
+
+class GridModelSelectionTests(unittest.TestCase):
+
+ def setUp(self):
+ self.model = dimbola.GridModel()
+ self.model.photoids = [1, 2, 3]
+
+ def test_select_next_selects_first_photo_if_nothing_selected(self):
+ self.model.select_next()
+ self.assertEqual(self.model.selected, [1])
+
+ def test_select_next_selects_next_photo_if_something_is_selected(self):
+ self.model.selected = [1, 2]
+ self.model.select_next()
+ self.assertEqual(self.model.selected, [2])
+
+ def test_select_previous_selects_first_photo_if_nothing_selected(self):
+ self.model.select_previous()
+ self.assertEqual(self.model.selected, [1])
+
+ def test_select_previous_selects_previous_if_something_is_selected(self):
+ self.model.selected = [2, 3]
+ self.model.select_previous()
+ self.assertEqual(self.model.selected, [1])
+
diff --git a/trunk/dimbola/gtkapp.py b/trunk/dimbola/gtkapp.py
new file mode 100644
index 0000000..81a7d80
--- /dev/null
+++ b/trunk/dimbola/gtkapp.py
@@ -0,0 +1,98 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+'''Helper class for writing PyGTK applications.'''
+
+
+import gobject
+import gtk
+
+
+class GtkApplication(object):
+
+ '''Base class for GTK+ applications.
+
+ This class provides some convenience functions for GTK+ applications.
+ It makes some assumptions to simplify things:
+
+ * The UI will be made with Glade and described with a .ui file,
+ using GtkBuilder.
+ * A dictionary of all widgets is created as the widgets attribute.
+ It is indexed by the name of the widget, as defined in the .ui file.
+ * Callbacks will be named on_widgetname_signalname. They will be connected
+ automatically if this naming convention is followed.
+ * Methods named widgetname_is_sensitive control the sensitivity of
+ widgets that need to become sensitive or not based on the state of
+ the program.
+ * signal callbacks and *_is_sensitive are methods of "controllers",
+ which are provided to setup_widgets as an argument, and which it
+ stores into the controllers attribute.
+
+ '''
+
+ def setup_widgets(self, glade_filename, controllers):
+ '''Find all widgets and connect them to signal handlers.
+
+ The list of controllers will be stored in the controllers
+ attribute.
+
+ '''
+
+ self.controllers = controllers
+
+ if not hasattr(self, 'widgets'):
+ self.widgets = {}
+ builder = gtk.Builder()
+ if builder.add_from_file(glade_filename) == 0:
+ raise Exception('GtkBuilder.add_from_file failed for %s' %
+ glade_filename)
+ for widget in builder.get_objects():
+ if isinstance(widget, gtk.Widget):
+ self.setup_a_widget(widget)
+
+ def setup_a_widget(self, widget):
+ name = widget.get_property('name')
+ self.widgets[name] = widget
+ for controller in self.controllers:
+ for attr in dir(controller):
+ prefix = 'on_%s_' % name
+ if attr.startswith(prefix):
+ signal_name = attr[len(prefix):]
+ method = getattr(controller, attr)
+ widget.connect(signal_name, method)
+
+ def set_sensitive(self):
+ '''Set all widgets to be sensitive or not.
+
+ The sensitivity of each widget is tested with a method called
+ widgetname_is_sensitive. The method must be in one of the controllers
+ given to setup_widgets.
+
+ You should call this whenever one of the conditions for sensitivity
+ changes.
+
+ '''
+
+ suffix = '_is_sensitive'
+ for controller in self.controllers:
+ for attrname in dir(controller):
+ if attrname.endswith(suffix):
+ widgetname = attrname[:-len(suffix)]
+ if widgetname in self.widgets:
+ widget = self.widgets[widgetname]
+ method = getattr(controller, attrname)
+ widget.set_sensitive(bool(method()))
+
diff --git a/trunk/dimbola/gtkapp.py.~1~ b/trunk/dimbola/gtkapp.py.~1~
new file mode 100644
index 0000000..83772e9
--- /dev/null
+++ b/trunk/dimbola/gtkapp.py.~1~
@@ -0,0 +1,99 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+'''Helper class for writing PyGTK applications.'''
+
+
+import gobject
+import gtk
+
+
+class GtkApplication(object):
+
+ '''Base class for GTK+ applications.
+
+ This class provides some convenience functions for GTK+ applications.
+ It makes some assumptions to simplify things:
+
+ * The UI will be made with Glade and described with a .ui file,
+ using GtkBuilder.
+ * A dictionary of all widgets is created as the widgets attribute.
+ It is indexed by the name of the widget, as defined in the .ui file.
+ * Callbacks will be named on_widgetname_signalname. They will be connected
+ automatically if this naming convention is followed.
+ * Methods named widgetname_is_sensitive control the sensitivity of
+ widgets that need to become sensitive or not based on the state of
+ the program.
+ * signal callbacks and *_is_sensitive are methods of "controllers",
+ which are provided to setup_widgets as an argument, and which it
+ stores into the controllers attribute.
+
+ '''
+
+ def setup_widgets(self, glade_filename, controllers):
+ '''Find all widgets and connect them to signal handlers.
+
+ The list of controllers will be stored in the controllers
+ attribute.
+
+ '''
+
+ self.controllers = controllers
+
+ if not hasattr(self, 'widgets'):
+ self.widgets = {}
+ builder = gtk.Builder()
+ if builder.add_from_file(glade_filename) == 0:
+ raise Exception('GtkBuilder.add_from_file failed for %s' %
+ glade_filename)
+ for widget in builder.get_objects():
+ if isinstance(widget, gtk.Widget):
+ self.setup_a_widget(widget)
+
+ def setup_a_widget(self, widget):
+ name = widget.get_property('name')
+ print 'setup a widget', repr(name), widget
+ self.widgets[name] = widget
+ for controller in self.controllers:
+ for attr in dir(controller):
+ prefix = 'on_%s_' % name
+ if attr.startswith(prefix):
+ signal_name = attr[len(prefix):]
+ method = getattr(controller, attr)
+ widget.connect(signal_name, method)
+
+ def set_sensitive(self):
+ '''Set all widgets to be sensitive or not.
+
+ The sensitivity of each widget is tested with a method called
+ widgetname_is_sensitive. The method must be in one of the controllers
+ given to setup_widgets.
+
+ You should call this whenever one of the conditions for sensitivity
+ changes.
+
+ '''
+
+ suffix = '_is_sensitive'
+ for controller in self.controllers:
+ for attrname in dir(controller):
+ if attrname.endswith(suffix):
+ widgetname = attrname[:-len(suffix)]
+ if widgetname in self.widgets:
+ widget = self.widgets[widgetname]
+ method = getattr(controller, attrname)
+ widget.set_sensitive(bool(method()))
+
diff --git a/trunk/dimbola/pluginmgr.py b/trunk/dimbola/pluginmgr.py
new file mode 100644
index 0000000..e10be85
--- /dev/null
+++ b/trunk/dimbola/pluginmgr.py
@@ -0,0 +1,245 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+'''A generic plugin manager.
+
+The plugin manager finds files with plugins and loads them. It looks
+for plugins in a number of locations specified by the caller. To add
+a plugin to be loaded, it is enough to put it in one of the locations,
+and name it *_plugin.py. (The naming convention is to allow having
+other modules as well, such as unit tests, in the same locations.)
+
+'''
+
+
+import imp
+import inspect
+import os
+
+
+class Plugin(object):
+
+ '''Base class for plugins.
+
+ A plugin MUST NOT have any side effects when it is instantiated.
+ This is necessary so that it can be safely loaded by unit tests,
+ and so that a user interface can allow the user to disable it,
+ even if it is installed, with no ill effects. Any side effects
+ that would normally happen should occur in the enable() method,
+ and be undone by the disable() method. These methods must be
+ callable any number of times.
+
+ The subclass MAY define the following attributes:
+
+ * name
+ * description
+ * version
+ * required_application_version
+
+ name is the user-visible identifier for the plugin. It defaults
+ to the plugin's classname.
+
+ description is the user-visible description of the plugin. It may
+ be arbitrarily long, and can use pango markup language. Defaults
+ to the empty string.
+
+ version is the plugin version. Defaults to '0.0.0'. It MUST be a
+ sequence of integers separated by periods. If several plugins with
+ the same name are found, the newest version is used. Versions are
+ compared integer by integer, starting with the first one, and a
+ missing integer treated as a zero. If two plugins have the same
+ version, either might be used.
+
+ required_application_version gives the version of the minimal
+ application version the plugin is written for. The first integer
+ must match exactly: if the application is version 2.3.4, the
+ plugin's required_application_version must be at least 2 and
+ at most 2.3.4 to be loaded. Defaults to 0.
+
+ '''
+
+ @property
+ def name(self):
+ return self.__class__.__name__
+
+ @property
+ def description(self):
+ return ''
+
+ @property
+ def version(self):
+ return '0.0.0'
+
+ @property
+ def required_application_version(self):
+ return '0.0.0'
+
+ def enable(self):
+ '''Enable the plugin.'''
+ raise Exception('Unimplemented')
+
+ def disable(self):
+ '''Disable the plugin.'''
+ raise Exception('Unimplemented')
+
+ def enable_signal(self, obj, signal_name, callback):
+ '''Connect to a GObject signal.
+
+ This will remember the id so that disable_signals may do its stuff.
+
+ '''
+
+ if not hasattr(self, 'gobject_connect_ids'):
+ self.gobject_connect_ids = list()
+ conn_id = obj.connect(signal_name, callback)
+ self.gobject_connect_ids.append((obj, conn_id))
+
+ def disable_signals(self):
+ '''Disable all signals enabled with enable_signal.'''
+ if hasattr(self, 'gobject_connect_ids'):
+ for obj, conn_id in self.gobject_connect_ids:
+ obj.disconnect(conn_id)
+ del self.gobject_connect_ids[:]
+
+
+
+class PluginManager(object):
+
+ '''Manage plugins.
+
+ This class finds and loads plugins, and keeps a list of them that
+ can be accessed in various ways.
+
+ The locations are set via the locations attribute, which is a list.
+
+ When a plugin is loaded, an instance of its class is created. This
+ instance is initialized using normal and keyword arguments specified
+ in the plugin manager attributes plugin_arguments and
+ plugin_keyword_arguments.
+
+ The version of the application using the plugin manager is set via
+ the application_version attribute. This defaults to '0.0.0'.
+
+ '''
+
+ suffix = '_plugin.py'
+
+ def __init__(self):
+ self.locations = []
+ self._plugins = None
+ self._plugin_files = None
+ self.plugin_arguments = []
+ self.plugin_keyword_arguments = {}
+ self.application_version = '0.0.0'
+
+ @property
+ def plugin_files(self):
+ if self._plugin_files is None:
+ self._plugin_files = self.find_plugin_files()
+ return self._plugin_files
+
+ @property
+ def plugins(self):
+ if self._plugins is None:
+ self._plugins = self.load_plugins()
+ return self._plugins
+
+ def __getitem__(self, name):
+ for plugin in self.plugins:
+ if plugin.name == name:
+ return plugin
+ raise KeyError('Plugin %s is not known' % name)
+
+ def find_plugin_files(self):
+ '''Find files that may contain plugins.
+
+ This finds all files named *_plugin.py in all locations.
+ The returned list is sorted.
+
+ '''
+
+ pathnames = []
+
+ for location in self.locations:
+ try:
+ basenames = os.listdir(location)
+ except os.error:
+ continue
+ for basename in basenames:
+ s = os.path.join(location, basename)
+ if s.endswith(self.suffix) and os.path.exists(s):
+ pathnames.append(s)
+
+ return sorted(pathnames)
+
+ def load_plugins(self):
+ '''Load plugins from all plugin files.'''
+
+ plugins = dict()
+
+ for pathname in self.plugin_files:
+ for plugin in self.load_plugin_file(pathname):
+ if plugin.name in plugins:
+ p = plugins[plugin.name]
+ if self.is_older(p.version, plugin.version):
+ plugins[plugin.name] = plugin
+ else:
+ plugins[plugin.name] = plugin
+
+ return plugins.values()
+
+ def is_older(self, version1, version2):
+ '''Is version1 older than version2?'''
+ return self.parse_version(version1) < self.parse_version(version2)
+
+ def load_plugin_file(self, pathname):
+ '''Return plugin classes in a plugin file.'''
+
+ name, ext = os.path.splitext(os.path.basename(pathname))
+ f = file(pathname, 'r')
+ module = imp.load_module(name, f, pathname,
+ ('.py', 'r', imp.PY_SOURCE))
+ f.close()
+
+ plugins = []
+ for dummy, member in inspect.getmembers(module, inspect.isclass):
+ if issubclass(member, Plugin):
+ p = member(*self.plugin_arguments,
+ **self.plugin_keyword_arguments)
+ if self.compatible_version(p.required_application_version):
+ plugins.append(p)
+
+ return plugins
+
+ def compatible_version(self, required_application_version):
+ '''Check that the plugin is version-compatible with the application.
+
+ This checks the plugin's required_application_version against
+ the declared application version and returns True if they are
+ compatible, and False if not.
+
+ '''
+
+ req = self.parse_version(required_application_version)
+ app = self.parse_version(self.application_version)
+
+ return app[0] == req[0] and app >= req
+
+ def parse_version(self, version):
+ '''Parse a string represenation of a version into list of ints.'''
+
+ return [int(s) for s in version.split('.')]
+
diff --git a/trunk/dimbola/pluginmgr_tests.py b/trunk/dimbola/pluginmgr_tests.py
new file mode 100644
index 0000000..283a839
--- /dev/null
+++ b/trunk/dimbola/pluginmgr_tests.py
@@ -0,0 +1,148 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import unittest
+
+from pluginmgr import Plugin, PluginManager
+
+
+class MockGObject(object):
+
+ def connect(self, *args):
+ self.connect_args = args
+ return 42
+
+ def disconnect(self, *args):
+ self.disconnect_args = args
+
+
+class PluginTests(unittest.TestCase):
+
+ def setUp(self):
+ self.plugin = Plugin()
+
+ def test_name_is_class_name(self):
+ self.assertEqual(self.plugin.name, 'Plugin')
+
+ def test_description_is_empty_string(self):
+ self.assertEqual(self.plugin.description, '')
+
+ def test_version_is_zeroes(self):
+ self.assertEqual(self.plugin.version, '0.0.0')
+
+ def test_required_application_version_is_zeroes(self):
+ self.assertEqual(self.plugin.required_application_version, '0.0.0')
+
+ def test_enable_raises_exception(self):
+ self.assertRaises(Exception, self.plugin.enable)
+
+ def test_disable_raises_exception(self):
+ self.assertRaises(Exception, self.plugin.disable)
+
+ def test_enables_signal(self):
+ obj = MockGObject()
+ self.plugin.enable_signal(obj, 'signal_name', 'callback')
+ self.assertEqual(obj.connect_args, ('signal_name', 'callback'))
+
+ def test_disables_signals(self):
+ obj = MockGObject()
+ self.plugin.enable_signal(obj, 'signal_name', 'callback')
+ self.plugin.disable_signals()
+ self.assertEqual(obj.disconnect_args, (42,))
+
+
+class PluginManagerInitialStateTests(unittest.TestCase):
+
+ def setUp(self):
+ self.pm = PluginManager()
+
+ def test_locations_is_empty_list(self):
+ self.assertEqual(self.pm.locations, [])
+
+ def test_plugins_is_empty_list(self):
+ self.assertEqual(self.pm.plugins, [])
+
+ def test_application_version_is_zeroes(self):
+ self.assertEqual(self.pm.application_version, '0.0.0')
+
+ def test_plugin_files_is_empty(self):
+ self.assertEqual(self.pm.plugin_files, [])
+
+ def test_plugin_arguments_is_empty(self):
+ self.assertEqual(self.pm.plugin_arguments, [])
+
+ def test_plugin_keyword_arguments_is_empty(self):
+ self.assertEqual(self.pm.plugin_keyword_arguments, {})
+
+
+class PluginManagerTests(unittest.TestCase):
+
+ def setUp(self):
+ self.pm = PluginManager()
+ self.pm.locations = ['test-plugins', 'not-exist']
+ self.pm.plugin_arguments = ('fooarg',)
+ self.pm.plugin_keyword_arguments = { 'bar': 'bararg' }
+
+ self.files = sorted(['test-plugins/hello_plugin.py',
+ 'test-plugins/aaa_hello_plugin.py',
+ 'test-plugins/oldhello_plugin.py',
+ 'test-plugins/wrongversion_plugin.py'])
+
+ def test_finds_the_right_plugin_files(self):
+ self.assertEqual(self.pm.find_plugin_files(), self.files)
+
+ def test_plugin_files_attribute_implicitly_searches(self):
+ self.assertEqual(self.pm.plugin_files, self.files)
+
+ def test_loads_hello_plugin(self):
+ plugins = self.pm.load_plugins()
+ self.assertEqual(len(plugins), 1)
+ self.assertEqual(plugins[0].name, 'Hello')
+
+ def test_plugins_attribute_implicitly_searches(self):
+ self.assertEqual(len(self.pm.plugins), 1)
+ self.assertEqual(self.pm.plugins[0].name, 'Hello')
+
+ def test_initializes_hello_with_correct_args(self):
+ plugin = self.pm['Hello']
+ self.assertEqual(plugin.foo, 'fooarg')
+ self.assertEqual(plugin.bar, 'bararg')
+
+ def test_raises_keyerror_for_unknown_plugin(self):
+ self.assertRaises(KeyError, self.pm.__getitem__, 'Hithere')
+
+
+class PluginManagerCompatibleApplicationVersionTests(unittest.TestCase):
+
+ def setUp(self):
+ self.pm = PluginManager()
+ self.pm.application_version = '1.2.3'
+
+ def test_rejects_zero(self):
+ self.assertFalse(self.pm.compatible_version('0'))
+
+ def test_rejects_two(self):
+ self.assertFalse(self.pm.compatible_version('2'))
+
+ def test_rejects_one_two_four(self):
+ self.assertFalse(self.pm.compatible_version('1.2.4'))
+
+ def test_accepts_one(self):
+ self.assert_(self.pm.compatible_version('1'))
+
+ def test_accepts_one_two_three(self):
+ self.assert_(self.pm.compatible_version('1.2.3'))
+
diff --git a/trunk/dimbola/plugins/checksum_plugin.py b/trunk/dimbola/plugins/checksum_plugin.py
new file mode 100644
index 0000000..e58abf1
--- /dev/null
+++ b/trunk/dimbola/plugins/checksum_plugin.py
@@ -0,0 +1,78 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import datetime
+import hashlib
+import os
+import subprocess
+
+import gtk
+import pyexiv2
+
+import dimbola
+
+
+class ChecksumResult(dimbola.BackgroundStatus):
+
+ def __init__(self, action, description, photoid, sha1):
+ dimbola.BackgroundStatus.__init__(self, action, description)
+ self.photoid = photoid
+ self.sha1 = sha1
+
+ def process_result(self, mwc):
+ with mwc.db:
+ mwc.db.set_sha1(self.photoid, self.sha1)
+
+
+class ComputeChecksumJob(dimbola.BackgroundJob):
+
+ '''Compute checksum for a specific photo.'''
+
+ def __init__(self, photoid, pathname):
+ self.photoid = photoid
+ self.pathname = pathname
+
+ def run(self):
+ return ChecksumResult('stop', 'Checksum for %s' % self.pathname,
+ self.photoid, dimbola.sha1(self.pathname))
+
+
+class ComputeChecksums(dimbola.Plugin):
+
+ '''Compute checksums for original photo files.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+
+ def enable(self):
+ self.mwc.add_to_menu('file_menu', 'missing_checksums_menuitem',
+ 'Compute missing checksums')
+
+ def disable(self):
+ self.mwc.remove_from_menu('file_menu', 'missing_checksums_menuitem')
+
+ def on_missing_checksums_menuitem_activate(self, *args):
+ with self.mwc.db:
+ photoids = self.mwc.db.find_photos_without_checksum()
+ for photoid in photoids:
+ pathname = self.mwc.db.get_photo_pathname(photoid)
+ self.mwc.add_bgjob(ComputeChecksumJob(photoid, pathname))
+
diff --git a/trunk/dimbola/plugins/export.ui b/trunk/dimbola/plugins/export.ui
new file mode 100644
index 0000000..4d0e1df
--- /dev/null
+++ b/trunk/dimbola/plugins/export.ui
@@ -0,0 +1,105 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkFileChooserDialog" id="export_filechooser">
+ <property name="border_width">5</property>
+ <property name="title" translatable="yes">Export photos</property>
+ <property name="destroy_with_parent">True</property>
+ <property name="type_hint">normal</property>
+ <property name="has_separator">False</property>
+ <property name="action">select-folder</property>
+ <child internal-child="vbox">
+ <object class="GtkVBox" id="dialog-vbox4">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ <child>
+ <object class="GtkHButtonBox" id="hbuttonbox3">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkRadioButton" id="export_original_radiobutton">
+ <property name="label" translatable="yes">Original file</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="active">True</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkRadioButton" id="export_jpeg_radiobutton">
+ <property name="label" translatable="yes">Convert JPEG</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <property name="group">export_original_radiobutton</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child internal-child="action_area">
+ <object class="GtkHButtonBox" id="dialog-action_area4">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="button6">
+ <property name="label">gtk-cancel</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button5">
+ <property name="label" translatable="yes">Export</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="can_default">True</property>
+ <property name="has_default">True</property>
+ <property name="receives_default">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <action-widgets>
+ <action-widget response="-6">button6</action-widget>
+ <action-widget response="-5">button5</action-widget>
+ </action-widgets>
+ </object>
+</interface>
diff --git a/trunk/dimbola/plugins/export_plugin.py b/trunk/dimbola/plugins/export_plugin.py
new file mode 100644
index 0000000..d76187c
--- /dev/null
+++ b/trunk/dimbola/plugins/export_plugin.py
@@ -0,0 +1,101 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import os
+import shutil
+
+import gtk
+
+import dimbola
+
+
+class ExportOriginal(dimbola.BackgroundJob):
+
+ def __init__(self, mwc, photoid, dirname):
+ self.dirname = dirname
+ with mwc.db:
+ folderid, basename, c, d = mwc.db.get_basic_photo_metadata(photoid)
+ self.foldername = mwc.db.get_folder_name(folderid)
+ self.basename = basename
+
+ def run(self):
+ status = dimbola.BackgroundStatus('start',
+ 'Exporting %s' % self.basename)
+ self.send_status(status)
+ shutil.copy(os.path.join(self.foldername, self.basename),
+ os.path.join(self.dirname, self.basename))
+ return dimbola.BackgroundStatus('stop', 'Exported %s' % self.basename)
+
+
+class ExportJpeg(dimbola.BackgroundJob):
+
+ def __init__(self, mwc, photoid, dirname):
+ self.dirname = dirname
+ self.basename = 'foo'
+ with mwc.db:
+ self.preview = str(mwc.db.get_preview(photoid))
+ a, basename, c, rotate = mwc.db.get_basic_photo_metadata(photoid)
+ self.basename = basename
+ self.rotate = rotate
+
+ def run(self):
+ pixbuf = dimbola.image_data_to_pixbuf(self.preview)
+ pixbuf = dimbola.rotate_pixbuf(pixbuf, self.rotate)
+ basename, ext = os.path.splitext(self.basename)
+ new_basename = basename + '.jpg'
+ pixbuf.save(os.path.join(self.dirname, new_basename), "jpeg")
+ return dimbola.BackgroundStatus('stop', 'Exported %s' % new_basename)
+
+
+class ExportFiles(dimbola.Plugin):
+
+ '''Export selected files using background jobs.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+
+ def enable(self):
+ self.mwc.add_to_menu('file_menu', 'export_menuitem',
+ 'Export selected photos')
+
+ def disable(self):
+ self.mwc.remove_from_menu('file_menu', 'export_menuitem')
+
+ def on_export_menuitem_activate(self, menuitem):
+ chooser = self.mwc.widgets['export_filechooser']
+ chooser.set_transient_for(self.mwc.widgets['window'])
+ chooser.show()
+ response = chooser.run()
+ chooser.hide()
+ if response == gtk.RESPONSE_OK:
+ dirname = chooser.get_filenames()[0]
+ origs = self.mwc.widgets['export_original_radiobutton'].get_active()
+ selected = self.mwc.grid.model.selected
+ for photoid in selected:
+ if origs:
+ job = ExportOriginal(self.mwc, photoid, dirname)
+ else:
+ job = ExportJpeg(self.mwc, photoid, dirname)
+ self.mwc.add_bgjob(job)
+
+ def export_menuitem_is_sensitive(self):
+ return self.mwc.grid.model.selected
+
diff --git a/trunk/dimbola/plugins/folderlist.ui b/trunk/dimbola/plugins/folderlist.ui
new file mode 100644
index 0000000..ddea255
--- /dev/null
+++ b/trunk/dimbola/plugins/folderlist.ui
@@ -0,0 +1,31 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkExpander" id="folderlist_expander">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">automatic</property>
+ <property name="vscrollbar_policy">automatic</property>
+ <child>
+ <object class="GtkTreeView" id="folders_treeview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="headers_visible">False</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label19">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Folders with photos</property>
+ </object>
+ </child>
+ </object>
+</interface>
diff --git a/trunk/dimbola/plugins/folderlist_plugin.py b/trunk/dimbola/plugins/folderlist_plugin.py
new file mode 100644
index 0000000..1f08de3
--- /dev/null
+++ b/trunk/dimbola/plugins/folderlist_plugin.py
@@ -0,0 +1,129 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import os
+
+import gobject
+import gtk
+
+import dimbola
+
+
+class FolderList(dimbola.Plugin):
+
+ '''Show list of folders with imported photos in left sidebar.'''
+
+ ID_COL = 0
+ NAME_COL = 1
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+ mwc.connect('setup-widgets', self.setup_widgets)
+ self.tagids = set()
+ self.store = gtk.TreeStore(gobject.TYPE_INT, gobject.TYPE_STRING)
+
+ mwc.new_hook('folder-selection-changed', gobject.TYPE_NONE,
+ (gobject.TYPE_PYOBJECT, gobject.TYPE_PYOBJECT))
+
+ def enable(self):
+ self.mwc.add_to_sidebar('left_sidebar', 'folderlist_expander',
+ weight=dimbola.MIN_WEIGHT,
+ expand=True, fill=True)
+ self.enable_signal(self.mwc, 'db-changed', self.db_changed)
+
+ def disable(self):
+ self.mwc.remove_from_sidebar('left_sidebar', 'folderlist_expander')
+ self.disable_signals()
+
+ def setup_widgets(self, mwc):
+ cr = gtk.CellRendererText()
+ col = gtk.TreeViewColumn()
+ col.pack_start(cr)
+ col.add_attribute(cr, 'text', self.NAME_COL)
+ self.treeview = self.mwc.widgets['folders_treeview']
+ self.treeview.append_column(col)
+ self.treeview.set_model(self.store)
+
+ sel = self.treeview.get_selection()
+ sel.set_mode(gtk.SELECTION_MULTIPLE)
+ sel.connect('changed', self.selection_changed)
+
+ def db_changed(self, mwc):
+ self.refresh_store()
+
+ def refresh_store(self):
+ tb = dimbola.TreeBuilder()
+ fakes = dict()
+ with self.mwc.db:
+ photoids = self.mwc.db.find_photoids()
+ for photoid in photoids:
+ folderid, b, c, d = \
+ self.mwc.db.get_basic_photo_metadata(photoid)
+ foldername = self.mwc.db.get_folder_name(folderid)
+ parentname = os.path.dirname(foldername)
+ parentid = self.mwc.db.find_folder(parentname)
+ if parentid is None:
+ parentid = self.make_fake_parent(fakes, parentname)
+ basename = os.path.basename(foldername)
+ tb.add(folderid, basename, basename, parentid)
+ for fakename in fakes:
+ parentname = os.path.dirname(fakename)
+ parentid = fakes[parentname]
+ fakeid = fakes[fakename]
+ if parentid == fakeid:
+ parentid = None
+ basename = os.path.basename(fakename) or os.sep
+ tb.add(fakeid, basename, basename, parentid)
+ tb.done()
+ self.store.clear()
+ self.populate_treemodel(tb.tree)
+ self.treeview.expand_all()
+
+ def make_fake_parent(self, fakes, parentname):
+ if parentname not in fakes:
+ fakes[parentname] = -len(fakes) - 1
+ self.make_fake_parent(fakes, os.path.dirname(parentname))
+ return fakes[parentname]
+
+ def populate_treemodel(self, nodes, parent_iter=None):
+ for node in nodes:
+ folderid, foldername, children = node
+ it = self.store.append(parent_iter, (folderid, foldername))
+ self.populate_treemodel(children, parent_iter=it)
+
+ def photos_in_selected_folders(self):
+ '''Return photoids for all currently selected folders.'''
+ sel = self.treeview.get_selection()
+ model, paths = sel.get_selected_rows()
+ folderids = list()
+ photoids = list()
+ with self.mwc.db:
+ for path in paths:
+ it = self.store.get_iter(path)
+ folderid = self.store.get_value(it, self.ID_COL)
+ photoids += self.mwc.db.find_photoids_in_folder(folderid)
+ folderids.append(folderid)
+ return folderids, photoids
+
+ def selection_changed(self, *args):
+ folderids, photoids = self.photos_in_selected_folders()
+ self.mwc.emit('folder-selection-changed', folderids, photoids)
+
diff --git a/trunk/dimbola/plugins/gimp_plugin.py b/trunk/dimbola/plugins/gimp_plugin.py
new file mode 100644
index 0000000..8653f98
--- /dev/null
+++ b/trunk/dimbola/plugins/gimp_plugin.py
@@ -0,0 +1,76 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import os
+import subprocess
+import tempfile
+
+import dimbola
+
+
+class ConvertToPNGAndGimpIt(dimbola.BackgroundJob):
+
+ def __init__(self, mwc, photoid):
+ with mwc.db:
+ folderid, basename, c, d = mwc.db.get_basic_photo_metadata(photoid)
+ foldername = mwc.db.get_folder_name(folderid)
+
+ self.pathname = os.path.join(foldername, basename)
+ self.pngname = self.pathname + '.png'
+
+ def run(self):
+ if not os.path.exists(self.pngname):
+ p = subprocess.Popen(['dcraw', '-c', self.pathname],
+ stdout=subprocess.PIPE)
+ ppm, stderr = p.communicate('')
+ if p.returncode:
+ raise Exception('dcraw failed: exit code %s:\n%s' %
+ (p.returncode, stderr))
+
+ pixbuf = dimbola.image_data_to_pixbuf(ppm)
+ pixbuf.save(self.pngname, 'png')
+
+ os.spawnlp(os.P_NOWAIT, "gimp", "gimp", self.pngname)
+
+
+class Gimp(dimbola.Plugin):
+
+ '''Edit selected photos using the GIMP.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+
+ def enable(self):
+ self.mwc.add_to_menu('photo_menu', 'gimp_menuitem',
+ 'Edit with the GIMP')
+
+ def disable(self):
+ self.mwc.remove_from_menu('photo_menu', 'gimp_menuitem')
+
+ def on_gimp_menuitem_activate(self, *args):
+ selected = self.mwc.grid.model.selected
+ for photoid in selected:
+ job = ConvertToPNGAndGimpIt(self.mwc, photoid)
+ self.mwc.add_bgjob(job)
+
+ def gimp_menuitem_is_sensitive(self):
+ return self.mwc.grid.model.selected
+
diff --git a/trunk/dimbola/plugins/import.ui b/trunk/dimbola/plugins/import.ui
new file mode 100644
index 0000000..55c8aaa
--- /dev/null
+++ b/trunk/dimbola/plugins/import.ui
@@ -0,0 +1,66 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkFileChooserDialog" id="import_filechooserdialog">
+ <property name="border_width">5</property>
+ <property name="title" translatable="yes">Import photos</property>
+ <property name="type_hint">normal</property>
+ <property name="has_separator">False</property>
+ <property name="select_multiple">True</property>
+ <child internal-child="vbox">
+ <object class="GtkVBox" id="dialog-vbox6">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ <child>
+ <placeholder/>
+ </child>
+ <child internal-child="action_area">
+ <object class="GtkHButtonBox" id="dialog-action_area6">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="button7">
+ <property name="label">gtk-cancel</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button8">
+ <property name="label" translatable="yes">Import</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="can_default">True</property>
+ <property name="has_default">True</property>
+ <property name="receives_default">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <action-widgets>
+ <action-widget response="-6">button7</action-widget>
+ <action-widget response="-5">button8</action-widget>
+ </action-widgets>
+ </object>
+</interface>
diff --git a/trunk/dimbola/plugins/import_plugin.py b/trunk/dimbola/plugins/import_plugin.py
new file mode 100644
index 0000000..e66da88
--- /dev/null
+++ b/trunk/dimbola/plugins/import_plugin.py
@@ -0,0 +1,213 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import datetime
+import hashlib
+import os
+import subprocess
+
+import gtk
+import pyexiv2
+
+import dimbola
+
+
+class ImportResult(dimbola.BackgroundStatus):
+
+ def __init__(self, action, description, foldername, basename,
+ thumbnail, preview, exifs, sha1):
+ dimbola.BackgroundStatus.__init__(self, action, description)
+ self.foldername = foldername
+ self.basename = basename
+ self.thumbnail = thumbnail
+ self.preview = preview
+ self.exifs = exifs
+ self.sha1 = sha1
+
+ def process_result(self, mwc):
+ db = mwc.db
+ with db:
+ folderid = db.find_folder(self.foldername)
+ if not folderid:
+ folderid = db.add_folder(self.foldername)
+
+ photoid = db.add_photo(folderid, self.basename, 0, 0)
+ db.set_sha1(photoid, self.sha1)
+ for exifname, exifvalue in self.exifs.iteritems():
+ assert type(exifvalue) == str
+ exifid = db.find_exifname(exifname)
+ if not exifid:
+ exifid = db.add_exifname(exifname)
+ db.add_exif(photoid, exifid, exifvalue)
+
+ db.add_thumbnail(photoid, self.thumbnail)
+ db.add_preview(photoid, self.preview)
+
+ mwc.emit('db-changed')
+
+
+class PreviewMaker(object):
+
+ '''Create a JPEG preview of an image file.'''
+
+ def __init__(self):
+ self.dtc = dimbola.DcrawTypeCache()
+
+ def make_preview(self, filename):
+ if self.dtc.supported(filename):
+ return self.from_raw(filename)
+ else:
+ return self.from_other(filename)
+
+ def from_raw(self, filename):
+ p = subprocess.Popen(['dcraw', '-c', filename], stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ ppm, stderr = p.communicate('')
+ if p.returncode:
+ raise Exception('dcraw failed: exit code %s:\n%s' %
+ (p.returncode, stderr))
+ return dimbola.image_data_to_image_data(ppm, 'jpeg',
+ { 'quality': '50' })
+
+ def from_other(self, filename):
+ pixbuf = gtk.gdk.pixbuf_new_from_file(filename)
+ return dimbola.pixbuf_to_image_data(pixbuf, 'jpeg',
+ { 'quality': '50' })
+
+
+class ThumbnailMaker(object):
+
+ '''Create a thumbnail from a preview.'''
+
+ def make_thumbnail(self, preview_jpeg):
+ pixbuf = dimbola.image_data_to_pixbuf(preview_jpeg)
+ pixbuf = dimbola.scale_pixbuf(pixbuf, 200, 200)
+ return dimbola.pixbuf_to_image_data(pixbuf, 'jpeg', {'quality':'50'})
+
+
+class ImportPhoto(dimbola.BackgroundJob):
+
+ exifs = ['Exif.Image.Make',
+ 'Exif.Image.Model',
+ 'Exif.Image.Orientation',
+ 'Exif.Image.DateTime',
+ 'Exif.Photo.ExposureTime',
+ 'Exif.Photo.FNumber',
+ 'Exif.Photo.ISOSpeedRatings',
+ 'Exif.Photo.ShutterSpeedValue',
+ 'Exif.Photo.ApertureValue',
+ 'Exif.Photo.ExposureBiasValue',
+ 'Exif.Photo.FocalLength',
+ 'Exif.Photo.WhiteBalance']
+
+ def __init__(self, pathname):
+ self.pathname = pathname
+ self.description = 'Importing %s' % pathname
+
+ def run(self):
+ self.send_status(dimbola.BackgroundStatus('start',
+ 'Importing %s' % os.path.basename(self.pathname)))
+
+ preview_maker = PreviewMaker()
+ preview = preview_maker.make_preview(self.pathname)
+
+ thumbnail_maker = ThumbnailMaker()
+ thumbnail = thumbnail_maker.make_thumbnail(preview)
+
+ image = pyexiv2.Image(self.pathname)
+ image.readMetadata()
+
+ foldername = os.path.dirname(os.path.abspath(self.pathname))
+ basename = os.path.basename(self.pathname)
+
+ exifs = dict()
+ for exifname in image.exifKeys():
+ if exifname not in self.exifs:
+ continue
+ exifs[exifname] = self.get_encoded(image, exifname)
+ assert type(exifs[exifname]) == str
+
+ return ImportResult('stop', 'Imported %s' % basename,
+ foldername, basename, thumbnail, preview, exifs,
+ dimbola.sha1(self.pathname))
+
+ def get_encoded(self, image, key):
+ s = image.interpretedExifValue(key)
+ assert type(s) == str
+ return s
+
+
+class ImportFiles(dimbola.Plugin):
+
+ '''Import into database using background jobs.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+ self.mwc.connect('setup-widgets', self.setup_widgets)
+
+ def setup_widgets(self, mwc):
+ self.chooser = mwc.widgets['import_filechooserdialog']
+ self.chooser.set_transient_for(mwc.widgets['window'])
+ self.dtc = dimbola.DcrawTypeCache()
+
+ photo_filter = gtk.FileFilter()
+ photo_filter.set_name('All images')
+ photo_filter.add_pixbuf_formats()
+ photo_filter.add_custom(gtk.FILE_FILTER_FILENAME |
+ gtk.FILE_FILTER_MIME_TYPE,
+ self.filter_dcraw_known,
+ None)
+ self.chooser.add_filter(photo_filter)
+
+ raw_filter = gtk.FileFilter()
+ raw_filter.set_name('RAW photos')
+ raw_filter.add_custom(gtk.FILE_FILTER_FILENAME |
+ gtk.FILE_FILTER_MIME_TYPE,
+ self.filter_dcraw_known,
+ None)
+ self.chooser.add_filter(raw_filter)
+
+ all_filter = gtk.FileFilter()
+ all_filter.set_name('All files')
+ all_filter.add_pattern('*')
+ self.chooser.add_filter(all_filter)
+
+
+ def enable(self):
+ self.mwc.add_to_menu('file_menu', 'import_menuitem',
+ 'Import photos')
+
+ def disable(self):
+ self.mwc.remove_from_menu('file_menu', 'import_menuitem')
+
+ def on_import_menuitem_activate(self, *args):
+ self.chooser.show()
+ response = self.chooser.run()
+ self.chooser.hide()
+ if response == gtk.RESPONSE_OK:
+ for pathname in self.chooser.get_filenames():
+ job = ImportPhoto(pathname)
+ self.mwc.add_bgjob(job)
+
+ def filter_dcraw_known(self, filter_info, data):
+ pathname, uri, display_name, mime_type = filter_info
+ return self.dtc.supported(pathname)
+
diff --git a/trunk/dimbola/plugins/news_plugin.py b/trunk/dimbola/plugins/news_plugin.py
new file mode 100644
index 0000000..c76aa3e
--- /dev/null
+++ b/trunk/dimbola/plugins/news_plugin.py
@@ -0,0 +1,47 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import os
+import time
+
+import gtk
+
+import dimbola
+
+
+class News(dimbola.Plugin):
+
+ '''Show the user the NEWS file.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+ path = '/%s/NEWS.html' % os.path.dirname(dimbola.__file__)
+ self.uri = 'file://%s' % path
+ self.exists = os.path.exists(path)
+
+ def enable(self):
+ self.mwc.add_to_menu('help_menu', 'news_menuitem',
+ 'News')
+
+ def disable(self):
+ self.mwc.remove_from_menu('help_menu', 'news_menuitem')
+
+ def on_news_menuitem_activate(self, *args):
+ gtk.show_uri(screen=None, uri=self.uri, timestamp=int(time.time()))
+
+ def news_menuitem_is_sensitive(self):
+ return self.exists
+
diff --git a/trunk/dimbola/plugins/photoinfo.ui b/trunk/dimbola/plugins/photoinfo.ui
new file mode 100644
index 0000000..3be939a
--- /dev/null
+++ b/trunk/dimbola/plugins/photoinfo.ui
@@ -0,0 +1,286 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkExpander" id="basic_info_expander">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <property name="spacing">6</property>
+ <child>
+ <object class="GtkTable" id="table1">
+ <property name="visible">True</property>
+ <property name="n_rows">10</property>
+ <property name="n_columns">2</property>
+ <property name="column_spacing">6</property>
+ <property name="row_spacing">6</property>
+ <child>
+ <object class="GtkLabel" id="label8">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Rating</property>
+ </object>
+ <packing>
+ <property name="top_attach">3</property>
+ <property name="bottom_attach">4</property>
+ <property name="x_options">GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label7">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Filename</property>
+ </object>
+ <packing>
+ <property name="top_attach">2</property>
+ <property name="bottom_attach">3</property>
+ <property name="x_options">GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label6">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Folder</property>
+ </object>
+ <packing>
+ <property name="top_attach">1</property>
+ <property name="bottom_attach">2</property>
+ <property name="x_options">GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label9">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Photo</property>
+ </object>
+ <packing>
+ <property name="x_options">GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="photo_info_photoid">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="x_options">GTK_EXPAND | GTK_SHRINK | GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="photo_info_folder">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="max_width_chars">20</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">1</property>
+ <property name="bottom_attach">2</property>
+ <property name="x_options">GTK_EXPAND | GTK_SHRINK | GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="photo_info_filename">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="max_width_chars">20</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">2</property>
+ <property name="bottom_attach">3</property>
+ <property name="x_options">GTK_EXPAND | GTK_SHRINK | GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label11">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Camera</property>
+ </object>
+ <packing>
+ <property name="top_attach">4</property>
+ <property name="bottom_attach">5</property>
+ <property name="x_options">GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="photo_info_camera">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">4</property>
+ <property name="bottom_attach">5</property>
+ <property name="x_options">GTK_EXPAND | GTK_SHRINK | GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label12">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Date/time</property>
+ </object>
+ <packing>
+ <property name="top_attach">5</property>
+ <property name="bottom_attach">6</property>
+ <property name="x_options">GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="photo_info_datetime">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">5</property>
+ <property name="bottom_attach">6</property>
+ <property name="x_options">GTK_EXPAND | GTK_SHRINK | GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="labelxx">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Shutter</property>
+ </object>
+ <packing>
+ <property name="top_attach">6</property>
+ <property name="bottom_attach">7</property>
+ <property name="x_options">GTK_FILL</property>
+ <property name="y_options"></property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label13">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Aperture</property>
+ </object>
+ <packing>
+ <property name="top_attach">7</property>
+ <property name="bottom_attach">8</property>
+ <property name="x_options">GTK_FILL</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label14">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">ISO</property>
+ </object>
+ <packing>
+ <property name="top_attach">8</property>
+ <property name="bottom_attach">9</property>
+ <property name="x_options">GTK_FILL</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label15">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Focal length</property>
+ </object>
+ <packing>
+ <property name="top_attach">9</property>
+ <property name="bottom_attach">10</property>
+ <property name="x_options">GTK_FILL</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="photo_info_shutter">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">6</property>
+ <property name="bottom_attach">7</property>
+ <property name="x_options">GTK_EXPAND | GTK_SHRINK | GTK_FILL</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="photo_info_aperture">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">7</property>
+ <property name="bottom_attach">8</property>
+ <property name="x_options">GTK_EXPAND | GTK_SHRINK | GTK_FILL</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="photo_info_iso">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">8</property>
+ <property name="bottom_attach">9</property>
+ <property name="x_options">GTK_EXPAND | GTK_SHRINK | GTK_FILL</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="photo_info_focal_length">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">9</property>
+ <property name="bottom_attach">10</property>
+ <property name="x_options">GTK_EXPAND | GTK_SHRINK | GTK_FILL</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkDrawingArea" id="photo_info_rating">
+ <property name="visible">True</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">3</property>
+ <property name="bottom_attach">4</property>
+ <property name="x_options">GTK_FILL</property>
+ <property name="y_options">GTK_FILL</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label5">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Basic info</property>
+ </object>
+ </child>
+ </object>
+</interface>
diff --git a/trunk/dimbola/plugins/photoinfo_plugin.py b/trunk/dimbola/plugins/photoinfo_plugin.py
new file mode 100644
index 0000000..e5bb4fb
--- /dev/null
+++ b/trunk/dimbola/plugins/photoinfo_plugin.py
@@ -0,0 +1,126 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import os
+
+import gtk
+import pyexiv2
+
+import dimbola
+
+
+class PhotoInfoViewer(dimbola.Plugin):
+
+ '''Show information about the selected photo in the right sidebar.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+ self.photoid = None
+
+ def enable(self):
+ self.enable_signal(self.mwc.grid.model, 'selection-changed',
+ self.remember_photo)
+ self.enable_signal(self.mwc, 'photo-meta-changed',
+ self.remember_photo)
+ self.mwc.add_to_sidebar('right_sidebar', 'basic_info_expander')
+
+ def disable(self):
+ self.disable_signals()
+ self.mwc.remove_from_sidebar('right_sidebar', 'basic_info_expander')
+
+ def exifvalue(self, exifname):
+ if self.photoid is None:
+ return ''
+ else:
+ return self.mwc.db.get_exif(self.photoid, exifname) or ''
+
+ @property
+ def foldername(self):
+ if self.photoid is None:
+ return ''
+ else:
+ folderid, basename, rating, rotate = \
+ self.mwc.db.get_basic_photo_metadata(self.photoid)
+ foldername = self.mwc.db.get_folder_name(folderid)
+ if foldername:
+ foldername = os.path.basename(foldername)
+ return foldername or ''
+
+ @property
+ def filename(self):
+ if self.photoid is None:
+ return ''
+ else:
+ folderid, basename, rating, rotate = \
+ self.mwc.db.get_basic_photo_metadata(self.photoid)
+ return basename
+
+ @property
+ def rating(self):
+ if self.photoid is None:
+ return ''
+ else:
+ folderid, basename, rating, rotate = \
+ self.mwc.db.get_basic_photo_metadata(self.photoid)
+ return '*' * rating
+
+ def draw_rating_stars(self):
+ w = self.mwc.widgets['photo_info_rating']
+ gc = w.get_style().fg_gc[gtk.STATE_NORMAL]
+ x = 0
+ y = 0
+ width, dim = w.window.get_size()
+ with self.mwc.db:
+ dimbola.draw_stars(len(self.rating), w.window, gc, x, y, dim)
+
+ def on_photo_info_rating_expose_event(self, w, event):
+ self.draw_rating_stars()
+
+ def remember_photo(self, *args):
+ if self.mwc.db is None:
+ return
+
+ if len(self.mwc.grid.model.selected) == 1:
+ self.photoid = self.mwc.grid.model.selected[0]
+ else:
+ self.photoid = None
+ with self.mwc.db:
+ exiftable = {
+ 'photo_info_camera': 'Exif.Image.Model',
+ 'photo_info_datetime': 'Exif.Image.DateTime',
+ 'photo_info_shutter': 'Exif.Photo.ExposureTime',
+ 'photo_info_aperture': 'Exif.Photo.ApertureValue',
+ 'photo_info_iso': 'Exif.Photo.ISOSpeedRatings',
+ 'photo_info_focal_length': 'Exif.Photo.FocalLength',
+ }
+ for widget_name, exifname in exiftable.iteritems():
+ value = self.exifvalue(exifname)
+ self.mwc.widgets[widget_name].set_text(value)
+
+ misctable = {
+ 'photo_info_photoid': '%s' % (self.photoid or ''),
+ 'photo_info_folder': self.foldername,
+ 'photo_info_filename': self.filename,
+ }
+ for widget_name, value in misctable.iteritems():
+ self.mwc.widgets[widget_name].set_text(value)
+ self.draw_rating_stars()
+
diff --git a/trunk/dimbola/plugins/phototags.ui b/trunk/dimbola/plugins/phototags.ui
new file mode 100644
index 0000000..794e23a
--- /dev/null
+++ b/trunk/dimbola/plugins/phototags.ui
@@ -0,0 +1,45 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkExpander" id="photo_tags_expander">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">automatic</property>
+ <property name="vscrollbar_policy">automatic</property>
+ <child>
+ <object class="GtkTextView" id="tags_textview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="border_width">1</property>
+ <property name="editable">False</property>
+ <property name="wrap_mode">word</property>
+ <property name="cursor_visible">False</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label18">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Photo's tags</property>
+ </object>
+ </child>
+ </object>
+ <object class="GtkMenu" id="tags_textview_popup">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkImageMenuItem" id="remove_photo_tag_menuitem">
+ <property name="label">gtk-remove</property>
+ <property name="visible">True</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ </child>
+ </object>
+</interface>
diff --git a/trunk/dimbola/plugins/phototags_plugin.py b/trunk/dimbola/plugins/phototags_plugin.py
new file mode 100644
index 0000000..e2fc1d2
--- /dev/null
+++ b/trunk/dimbola/plugins/phototags_plugin.py
@@ -0,0 +1,107 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import gtk
+
+import dimbola
+
+
+class PhotoTags(dimbola.Plugin):
+
+ '''Show/edit selected photo's tags.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+ mwc.connect('setup-widgets', self.setup_widgets)
+
+ def setup_widgets(self, mwc):
+ self.textview = mwc.widgets['tags_textview']
+ self.popup = mwc.widgets['tags_textview_popup']
+ self.remove_menuitem = mwc.widgets['remove_photo_tag_menuitem']
+ self.taglist = dimbola.Taglist(self.textview, self.popup,
+ self.prepare_popup)
+ self.photoid = None
+
+ def enable(self):
+ self.enable_signal(self.mwc.grid.model, 'selection-changed',
+ self.selection_changed)
+ self.enable_signal(self.mwc, 'tagtree-changed',
+ lambda *args: self.refresh_tags())
+ self.mwc.add_to_sidebar('right_sidebar', 'photo_tags_expander')
+
+ def disable(self):
+ self.disable_signals()
+ self.mwc.remove_from_sidebar('right_sidebar', 'photo_tags_expander')
+
+ def refresh_tags(self):
+ with self.mwc.db:
+ tags = []
+ for tagid in self.mwc.db.get_tagids(self.photoid):
+ tagname = self.mwc.db.get_tagname(tagid)
+ if tagname is not None:
+ tags.append((tagid, tagname))
+ tags.sort()
+ self.taglist.set_tags(self.photoid, tags)
+
+ def selection_changed(self, model):
+ if self.mwc.db:
+ if len(model.selected) == 1:
+ self.photoid = model.selected[0]
+ self.refresh_tags()
+ else:
+ self.taglist.clear()
+ self.photoid = None
+ else:
+ self.photoid = None
+
+ def tags_textview_is_sensitive(self):
+ return len(self.mwc.grid.model.selected) == 1
+
+ def prepare_popup(self):
+ self.remove_menuitem.set_sensitive(
+ self.taglist.buf.get_has_selection())
+
+ def on_tags_textview_button_press_event(self, widget, event):
+ return self.taglist.button_press_event(widget, event)
+
+ def on_remove_photo_tag_menuitem_activate(self, *args):
+ photoid, tagid = self.taglist.selected_tag()
+ if photoid is not None:
+ with self.mwc.db:
+ self.mwc.db.remove_tagid(photoid, tagid)
+ self.refresh_tags()
+
+ def on_tags_textview_drag_motion(self, *args):
+ return self.taglist.drag_motion(*args)
+
+ def on_tags_textview_drag_leave(self, *args):
+ return self.taglist.drag_leave(*args)
+
+ def on_tags_textview_drag_data_received(self, *args):
+ if self.photoid is None:
+ return
+
+ tagids = self.taglist.drag_data_received(*args)
+ with self.mwc.db:
+ for tagid in tagids:
+ self.mwc.db.add_tagid(self.photoid, tagid)
+ self.refresh_tags()
+
diff --git a/trunk/dimbola/plugins/photoviewer.ui b/trunk/dimbola/plugins/photoviewer.ui
new file mode 100644
index 0000000..ed36f28
--- /dev/null
+++ b/trunk/dimbola/plugins/photoviewer.ui
@@ -0,0 +1,112 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkWindow" id="photo_window">
+ <property name="title" translatable="yes">Dimbola Photo Viewer</property>
+ <property name="default_width">700</property>
+ <property name="default_height">500</property>
+ <property name="destroy_with_parent">True</property>
+ <child>
+ <object class="GtkVBox" id="vbox6">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkDrawingArea" id="photowin_drawingarea">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="has_focus">True</property>
+ <property name="events">GDK_BUTTON_PRESS_MASK | GDK_KEY_PRESS_MASK | GDK_STRUCTURE_MASK</property>
+ </object>
+ <packing>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHBox" id="hbox1">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkHBox" id="hbox4">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkButton" id="fullscreen_button">
+ <property name="label">gtk-fullscreen</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <property name="focus_on_click">False</property>
+ </object>
+ <packing>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="unfullscreen_button">
+ <property name="label">gtk-leave-fullscreen</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <property name="focus_on_click">False</property>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHButtonBox" id="hbuttonbox2">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="photowin_previous_button">
+ <property name="label">gtk-media-previous</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <property name="focus_on_click">False</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="photowin_next_button">
+ <property name="label">gtk-media-next</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ <property name="focus_on_click">False</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+</interface>
diff --git a/trunk/dimbola/plugins/photoviewer_plugin.py b/trunk/dimbola/plugins/photoviewer_plugin.py
new file mode 100644
index 0000000..45ce81c
--- /dev/null
+++ b/trunk/dimbola/plugins/photoviewer_plugin.py
@@ -0,0 +1,214 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import os
+
+import gtk
+
+import dimbola
+
+
+class PhotoViewerBase(object):
+
+ '''Display the currently selected photo in full size.
+
+ This is a base class, from which will be derived two other classes,
+ one for showing photos in a tab, the other in a separate toplevel
+ window.
+
+ '''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+
+ self.photoid = None
+
+ mwc.connect('setup-widgets', self.setup_widgets)
+ mwc.connect('photo-meta-changed', lambda *args: self.draw_photo())
+
+ def remember_photo(self, model):
+ if len(model.selected) == 1:
+ self.photoid = model.selected[0]
+ else:
+ self.photoid = None
+ self.draw_photo()
+
+ def draw_photo(self):
+ w = self.widget.window
+ if w is None:
+ return
+ w.clear()
+ width, height = w.get_size()
+ style = self.widget.get_style()
+ if self.widget.flags() & gtk.HAS_FOCUS:
+ self.widget.get_style().paint_focus(w,
+ self.widget.state,
+ None,
+ None,
+ None,
+ 0, 0,
+ width, height)
+ fg = style.fg_gc[gtk.STATE_NORMAL]
+ with self.mwc.db:
+ preview = self.mwc.db.get_preview(self.photoid)
+ a, b, c, rotate = \
+ self.mwc.db.get_basic_photo_metadata(self.photoid)
+ if preview is not None:
+ pixbuf = dimbola.image_data_to_pixbuf(preview)
+ pixbuf = dimbola.rotate_pixbuf(pixbuf, rotate)
+ pixbuf = dimbola.scale_pixbuf(pixbuf, width, height)
+ x = (width - pixbuf.get_width()) / 2
+ y = (height - pixbuf.get_height()) / 2
+ w.draw_pixbuf(fg, pixbuf, 0, 0, x, y)
+
+ def request_rating(self, stars):
+ self.mwc.emit('photo-rating-requested', stars)
+
+ def key_press_event(self, event):
+ if event.type == gtk.gdk.KEY_PRESS:
+ bindings = {
+ '0': lambda *args: self.request_rating(0),
+ '1': lambda *args: self.request_rating(1),
+ '2': lambda *args: self.request_rating(2),
+ '3': lambda *args: self.request_rating(3),
+ '4': lambda *args: self.request_rating(4),
+ '5': lambda *args: self.request_rating(5),
+ }
+ if event.keyval in bindings:
+ bindings[event.keyval]()
+ return True
+ elif event.string in bindings:
+ bindings[event.string]()
+ return True
+ return False
+
+
+class PhotoViewerNotebook(PhotoViewerBase, dimbola.Plugin):
+
+ '''Display the currently selected photo in full size, in notebook.'''
+
+ def enable(self):
+ pass
+
+ def disable(self):
+ pass
+
+ def setup_widgets(self, mwc):
+ self.widget = mwc.widgets['photo_drawingarea']
+ self.box = mwc.widgets['photo_vbox']
+ mwc.grid.model.connect('selection-changed', self.remember_photo)
+
+ def on_photo_drawingarea_expose_event(self, *args):
+ self.draw_photo()
+
+ def on_photo_previous_button_clicked(self, *args):
+ self.mwc.grid.model.select_previous()
+
+ def on_photo_next_button_clicked(self, *args):
+ self.mwc.grid.model.select_next()
+
+ def on_view_photo_menuitem_activate(self, radio):
+ if radio.get_active():
+ self.box.show()
+ self.widget.grab_focus()
+ else:
+ self.box.hide()
+
+ def on_photo_drawingarea_key_press_event(self, widget, event):
+ return self.key_press_event(event)
+
+ def on_photo_drawingarea_button_press_event(self, widget, event):
+ widget.grab_focus()
+ return False
+
+
+class PhotoViewerWindow(PhotoViewerBase, dimbola.Plugin):
+
+ '''Like PhotoViewerNotebook, but use a separate window.'''
+
+ def enable(self):
+ self.mwc.add_to_menu('photo_menu', 'photowin_menuitem',
+ 'View photo in separate window',
+ check=True)
+ self.menuitem = self.mwc.widgets['photowin_menuitem']
+
+ def disable(self):
+ self.mwc.remove_from_menu('photo_menu', 'photowin_menuitem')
+ self.menuitem = None
+
+ def setup_widgets(self, mwc):
+ self.widget = mwc.widgets['photowin_drawingarea']
+ self.window = mwc.widgets['photo_window']
+ self.fullscreen_button = mwc.widgets['fullscreen_button']
+ self.unfullscreen_button = mwc.widgets['unfullscreen_button']
+ self.widget = mwc.widgets['photowin_drawingarea']
+ mwc.grid.model.connect('selection-changed', self.remember_photo)
+
+ def on_photowin_menuitem_activate(self, menuitem):
+ if menuitem.get_active():
+ self.window.show()
+ else:
+ self.window.hide()
+
+ def on_photowin_drawingarea_expose_event(self, *args):
+ self.draw_photo()
+
+ def on_photo_window_delete_event(self, *args):
+ self.menuitem.set_active(False)
+ self.window.hide()
+ return True
+
+ def on_fullscreen_button_clicked(self, *args):
+ self.window.fullscreen()
+ self.fullscreen_button.hide()
+ self.unfullscreen_button.show()
+
+ def on_unfullscreen_button_clicked(self, *args):
+ self.window.unfullscreen()
+ self.unfullscreen_button.hide()
+ self.fullscreen_button.show()
+
+ def on_photowin_previous_button_clicked(self, *args):
+ self.mwc.grid.model.select_previous()
+
+ def on_photowin_next_button_clicked(self, *args):
+ self.mwc.grid.model.select_next()
+
+ def on_photowin_drawingarea_key_press_event(self, widget, event):
+
+ bindings = {
+ gtk.keysyms.Escape: self.on_unfullscreen_button_clicked,
+ }
+
+ if event.type == gtk.gdk.KEY_PRESS:
+ if event.keyval == gtk.keysyms.Escape:
+ self.on_unfullscreen_button_clicked()
+ return True
+ elif (event.keyval == gtk.keysyms.w and
+ event.state & gtk.gdk.CONTROL_MASK):
+ self.on_photo_window_delete_event()
+ return True
+ return self.key_press_event(event)
+
+ def on_photowin_drawingarea_button_press_event(self, widget, event):
+ widget.grab_focus()
+ return False
+
diff --git a/trunk/dimbola/plugins/rate_plugin.py b/trunk/dimbola/plugins/rate_plugin.py
new file mode 100644
index 0000000..435d01f
--- /dev/null
+++ b/trunk/dimbola/plugins/rate_plugin.py
@@ -0,0 +1,101 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import os
+import shutil
+
+import gobject
+import gtk
+
+import dimbola
+
+
+class RatePhotosPlugin(dimbola.Plugin):
+
+ '''Allow user to use a 0-5 star rating for photos.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+ mwc.new_hook('photo-rating-requested', gobject.TYPE_NONE,
+ (gobject.TYPE_INT,))
+
+ def enable(self):
+ self.mwc.add_to_menu('photo_menu', 'rate_as_0_stars',
+ 'Rate as 0 stars')
+ self.mwc.add_to_menu('photo_menu', 'rate_as_1_star',
+ 'Rate as 1 stars')
+ self.mwc.add_to_menu('photo_menu', 'rate_as_2_stars',
+ 'Rate as 2 stars')
+ self.mwc.add_to_menu('photo_menu', 'rate_as_3_stars',
+ 'Rate as 3 stars')
+ self.mwc.add_to_menu('photo_menu', 'rate_as_4_stars',
+ 'Rate as 4 stars')
+ self.mwc.add_to_menu('photo_menu', 'rate_as_5_stars',
+ 'Rate as 5 stars')
+ self.enable_signal(self.mwc, 'photo-rating-requested', self.rate_cb)
+
+ def disable(self):
+ self.mwc.remove_from_menu('photo_menu', 'rate_as_0_stars')
+ self.mwc.remove_from_menu('photo_menu', 'rate_as_1_star')
+ self.mwc.remove_from_menu('photo_menu', 'rate_as_2_stars')
+ self.mwc.remove_from_menu('photo_menu', 'rate_as_3_stars')
+ self.mwc.remove_from_menu('photo_menu', 'rate_as_4_stars')
+ self.mwc.remove_from_menu('photo_menu', 'rate_as_5_stars')
+
+ def rate(self, stars):
+ '''Rate selected photos with stars.'''
+ with self.mwc.db:
+ for photoid in self.mwc.grid.model.selected:
+ self.mwc.db.set_rating(photoid, stars)
+ for photoid in self.mwc.grid.model.selected:
+ self.mwc.photo_meta_changed(photoid)
+
+ def rate_cb(self, mwc, stars):
+ self.rate(stars)
+
+ def on_rate_as_0_stars_activate(self, menuitem):
+ self.rate(0)
+
+ def on_rate_as_1_star_activate(self, menuitem):
+ self.rate(1)
+
+ def on_rate_as_2_stars_activate(self, menuitem):
+ self.rate(2)
+
+ def on_rate_as_3_stars_activate(self, menuitem):
+ self.rate(3)
+
+ def on_rate_as_4_stars_activate(self, menuitem):
+ self.rate(4)
+
+ def on_rate_as_5_stars_activate(self, menuitem):
+ self.rate(5)
+
+ def menuitem_is_sensitive(self):
+ return self.mwc.grid.model.selected
+
+ rate_as_0_stars_is_sensitive = menuitem_is_sensitive
+ rate_as_1_star_is_sensitive = menuitem_is_sensitive
+ rate_as_2_stars_is_sensitive = menuitem_is_sensitive
+ rate_as_3_stars_is_sensitive = menuitem_is_sensitive
+ rate_as_4_stars_is_sensitive = menuitem_is_sensitive
+ rate_as_5_stars_is_sensitive = menuitem_is_sensitive
+
diff --git a/trunk/dimbola/plugins/remove_photos.ui b/trunk/dimbola/plugins/remove_photos.ui
new file mode 100644
index 0000000..edb9f8a
--- /dev/null
+++ b/trunk/dimbola/plugins/remove_photos.ui
@@ -0,0 +1,158 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkDialog" id="remove_photos_dialog">
+ <property name="border_width">5</property>
+ <property name="resizable">False</property>
+ <property name="type_hint">normal</property>
+ <property name="has_separator">False</property>
+ <child internal-child="vbox">
+ <object class="GtkVBox" id="dialog-vbox1">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ <child>
+ <object class="GtkHBox" id="hbox1">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkVBox" id="vbox2">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkImage" id="image1">
+ <property name="visible">True</property>
+ <property name="xpad">12</property>
+ <property name="ypad">12</property>
+ <property name="stock">gtk-dialog-warning</property>
+ <property name="icon-size">6</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="padding">6</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkVBox" id="vbox3">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">12</property>
+ <child>
+ <object class="GtkLabel" id="label1">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="yalign">0</property>
+ <property name="label" translatable="yes">&lt;b&gt;Remove photos?&lt;/b&gt;</property>
+ <property name="use_markup">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="padding">6</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label2">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="yalign">0</property>
+ <property name="label" translatable="yes">The photos will be removed from the database. This operation cannot be undone.
+
+If you choose to also remove files from disk, the photos will be entirely, totally, completely lost, and only black magic and large amounts of bribes to sysadmins will bring them back, and even then it might be impossible. Please don't claim you were not warned.</property>
+ <property name="use_markup">True</property>
+ <property name="wrap">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="remove_photos_from_disk_checkbutton">
+ <property name="label" translatable="yes">Remove files from disk also? (They will be completely lost.)</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="padding">12</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child internal-child="action_area">
+ <object class="GtkHButtonBox" id="dialog-action_area1">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="button2">
+ <property name="label">gtk-remove</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button1">
+ <property name="label">gtk-cancel</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="has_focus">True</property>
+ <property name="can_default">True</property>
+ <property name="has_default">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <action-widgets>
+ <action-widget response="-5">button2</action-widget>
+ <action-widget response="-6">button1</action-widget>
+ </action-widgets>
+ </object>
+</interface>
diff --git a/trunk/dimbola/plugins/remove_photos_plugin.py b/trunk/dimbola/plugins/remove_photos_plugin.py
new file mode 100644
index 0000000..36f46ee
--- /dev/null
+++ b/trunk/dimbola/plugins/remove_photos_plugin.py
@@ -0,0 +1,71 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import os
+
+import gtk
+
+import dimbola
+
+
+class RemovePhotos(dimbola.Plugin):
+
+ '''Remove selected files from database, optional also disk.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+
+ def enable(self):
+ self.mwc.add_to_menu('photo_menu', 'remove_photo_menuitem',
+ 'Remove selected photos')
+
+ def disable(self):
+ self.mwc.remove_from_menu('photo_menu', 'remove_photo_menuitem')
+
+ def remove_photo_menuitem_is_sensitive(self):
+ return self.mwc.grid.model.selected
+
+ def on_remove_photo_menuitem_activate(self, menuitem):
+ photoids = self.mwc.grid.model.selected
+ if not photoids:
+ return
+ dialog = self.mwc.widgets['remove_photos_dialog']
+ dialog.set_transient_for(self.mwc.widgets['window'])
+ button = self.mwc.widgets['remove_photos_from_disk_checkbutton']
+ button.set_active(False)
+ dialog.show()
+ response = dialog.run()
+ dialog.hide()
+ if response == gtk.RESPONSE_OK:
+ from_disk = button.get_active()
+ with self.mwc.db:
+ for photoid in photoids:
+ if from_disk:
+ (folderid, basename,
+ c, d) = self.mwc.db.get_basic_photo_metadata(photoid)
+ foldername = self.mwc.db.get_folder_name(folderid)
+ pathname = os.path.join(foldername, basename)
+ os.remove(pathname)
+ self.mwc.db.remove_photo(photoid)
+ old = self.mwc.grid.model.photoids
+ self.mwc.grid.model.photoids = [x for x in old if x not in photoids]
+ self.mwc.load_thumbnails_from_database()
+
diff --git a/trunk/dimbola/plugins/rotate_plugin.py b/trunk/dimbola/plugins/rotate_plugin.py
new file mode 100644
index 0000000..5c63656
--- /dev/null
+++ b/trunk/dimbola/plugins/rotate_plugin.py
@@ -0,0 +1,81 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import os
+import shutil
+
+import gtk
+
+import dimbola
+
+
+class RotatePhotos(dimbola.Plugin):
+
+ '''Rotate selected photos in 90 degree steps.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+
+ def enable(self): # pragma: no cover
+ self.mwc.add_to_menu('photo_menu', 'rotate_90_left_menuitem',
+ 'Rotate photos left')
+ self.mwc.add_to_menu('photo_menu', 'rotate_90_right_menuitem',
+ 'Rotate photos right')
+
+ def disable(self): # pragma: no cover
+ self.mwc.remove_from_menu('file_menu', 'rotate_90_left_menuitem')
+ self.mwc.remove_from_menu('file_menu', 'rotate_90_right_menuitem')
+
+ def new_angle(self, old_angle, step):
+ '''Compute angle given old angle and number of steps.
+
+ Positive steps are clockwise, negative steps are counter-clockwise.
+
+ '''
+ angles = [0, 90, 180, 270]
+ try:
+ i = angles.index(old_angle)
+ except ValueError:
+ return 0
+ else:
+ return (angles + angles)[i - step]
+
+ def rotate(self, step): # pragma: no cover
+ with self.mwc.db:
+ for photoid in self.mwc.grid.model.selected:
+ a, b, c, angle = self.mwc.db.get_basic_photo_metadata(photoid)
+ self.mwc.db.set_rotate(photoid, self.new_angle(angle, step))
+ new_value = self.new_angle(angle, step)
+ for photoid in self.mwc.grid.model.selected:
+ self.mwc.photo_meta_changed(photoid)
+
+ def on_rotate_90_left_menuitem_activate(self, menuitem): #pragma: no cover
+ self.rotate(-1)
+
+ def on_rotate_90_right_menuitem_activate(self, menuitem): #pragma: no cover
+ self.rotate(1)
+
+ def menuitem_is_sensitive(self): # pragma: no cover
+ return self.mwc.grid.model.selected
+
+ rotate_90_left_menuitem_is_sensitive = menuitem_is_sensitive
+ rotate_90_right_menuitem_is_sensitive = menuitem_is_sensitive
+
diff --git a/trunk/dimbola/plugins/rotate_plugin_tests.py b/trunk/dimbola/plugins/rotate_plugin_tests.py
new file mode 100644
index 0000000..f5d790e
--- /dev/null
+++ b/trunk/dimbola/plugins/rotate_plugin_tests.py
@@ -0,0 +1,53 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import unittest
+
+import rotate_plugin
+
+
+class RotatePluginNewAngleTests(unittest.TestCase):
+
+ def setUp(self):
+ self.plugin = rotate_plugin.RotatePhotos(None)
+
+ def test_rotates_bad_angle_to_zero(self):
+ self.assertEqual(self.plugin.new_angle(123, 1), 0)
+
+ def test_rotates_zero_left(self):
+ self.assertEqual(self.plugin.new_angle(0, -1), 90)
+
+ def test_rotates_zero_right(self):
+ self.assertEqual(self.plugin.new_angle(0, 1), 270)
+
+ def test_rotates_ninety_left(self):
+ self.assertEqual(self.plugin.new_angle(90, -1), 180)
+
+ def test_rotates_ninety_right(self):
+ self.assertEqual(self.plugin.new_angle(90, 1), 0)
+
+ def test_rotates_oneeighty_left(self):
+ self.assertEqual(self.plugin.new_angle(180, -1), 270)
+
+ def test_rotates_oneeighty_right(self):
+ self.assertEqual(self.plugin.new_angle(180, 1), 90)
+
+ def test_rotates_twoseventy_left(self):
+ self.assertEqual(self.plugin.new_angle(270, -1), 0)
+
+ def test_rotates_twoseventy_right(self):
+ self.assertEqual(self.plugin.new_angle(270, 1), 180)
+
diff --git a/trunk/dimbola/plugins/search.ui b/trunk/dimbola/plugins/search.ui
new file mode 100644
index 0000000..9adee53
--- /dev/null
+++ b/trunk/dimbola/plugins/search.ui
@@ -0,0 +1,214 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkExpander" id="search_expander">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <object class="GtkVBox" id="vbox9">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkVBox" id="vbox11">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkHButtonBox" id="hbuttonbox4">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkCheckButton" id="no_stars_checkbutton">
+ <property name="label" translatable="yes">No stars</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="one_star_checkbutton">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <child>
+ <object class="GtkDrawingArea" id="search_one_star_drawingarea">
+ <property name="visible">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="two_stars_checkbutton">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <child>
+ <object class="GtkDrawingArea" id="search_two_stars_drawingarea">
+ <property name="visible">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHButtonBox" id="hbuttonbox5">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkCheckButton" id="three_stars_checkbutton">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <child>
+ <object class="GtkDrawingArea" id="search_three_stars_drawingarea">
+ <property name="visible">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="four_stars_checkbutton">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <child>
+ <object class="GtkDrawingArea" id="search_four_stars_drawingarea">
+ <property name="visible">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="five_stars_checkbutton">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ <child>
+ <object class="GtkDrawingArea" id="search_five_stars_drawingarea">
+ <property name="visible">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkVBox" id="vbox10">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkLabel" id="label21">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="yalign">1</property>
+ <property name="xpad">6</property>
+ <property name="label" translatable="yes">Required tags</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow3">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">automatic</property>
+ <property name="vscrollbar_policy">automatic</property>
+ <child>
+ <object class="GtkTextView" id="searchtags_textview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="border_width">1</property>
+ <property name="editable">False</property>
+ <property name="wrap_mode">word</property>
+ <property name="cursor_visible">False</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label20">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Search</property>
+ </object>
+ </child>
+ </object>
+ <object class="GtkMenu" id="searchtags_textview_popup">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkImageMenuItem" id="remove_search_tag_menuitem">
+ <property name="label">gtk-remove</property>
+ <property name="visible">True</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ </child>
+ </object>
+</interface>
diff --git a/trunk/dimbola/plugins/search_plugin.py b/trunk/dimbola/plugins/search_plugin.py
new file mode 100644
index 0000000..95abe8c
--- /dev/null
+++ b/trunk/dimbola/plugins/search_plugin.py
@@ -0,0 +1,181 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import gtk
+import pango
+
+import dimbola
+
+
+class Search(dimbola.Plugin):
+
+ '''Search for photos in the currently selected folders.'''
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+ mwc.connect('setup-widgets', self.setup_widgets)
+ self.tagids = set()
+ self.photoids = list()
+
+ def setup_widgets(self, mwc):
+ self.textview = mwc.widgets['searchtags_textview']
+ self.popup = mwc.widgets['searchtags_textview_popup']
+ self.remove_menuitem = mwc.widgets['remove_search_tag_menuitem']
+ self.taglist = dimbola.Taglist(self.textview, self.popup,
+ self.prepare_popup)
+
+ stars = ('one_star', 'two_stars', 'three_stars', 'four_stars',
+ 'five_stars')
+ for n, x in enumerate(stars):
+ w = mwc.widgets['search_%s_drawingarea' % x]
+ height = w.get_style().font_desc.get_size()
+ pixels = max(height / pango.SCALE, 5)
+ w.set_size_request(pixels * (n+1), pixels)
+
+ def enable(self):
+ self.mwc.add_to_sidebar('left_sidebar', 'search_expander',
+ weight=dimbola.MAX_WEIGHT)
+
+ self.enable_signal(self.mwc, 'folder-selection-changed',
+ self.folder_selection_changed)
+ self.enable_signal(self.mwc, 'tagtree-changed',
+ lambda *args: self.refresh_tags())
+
+ def disable(self):
+ self.mwc.remove_from_sidebar('left_sidebar', 'search_expander')
+ self.disable_signals()
+
+ def refresh_tags(self):
+ with self.mwc.db:
+ tags = []
+ for tagid in self.tagids:
+ tagname = self.mwc.db.get_tagname(tagid)
+ if tagname is not None:
+ tags.append((tagname, tagid))
+ tags.sort()
+ self.taglist.set_tags(0, [(y, x) for x, y in tags])
+
+ def prepare_popup(self):
+ self.remove_menuitem.set_sensitive(
+ self.taglist.buf.get_has_selection())
+
+ def draw_stars(self, n_stars, drawingarea):
+ gc = drawingarea.get_style().fg_gc[drawingarea.state]
+ dim = drawingarea.window.get_size()[1]
+ dimbola.draw_stars(n_stars, drawingarea.window, gc, 0, 0, dim)
+
+ def on_search_one_star_drawingarea_expose_event(self, widget, event):
+ self.draw_stars(1, widget)
+
+ def on_search_two_stars_drawingarea_expose_event(self, widget, event):
+ self.draw_stars(2, widget)
+
+ def on_search_three_stars_drawingarea_expose_event(self, widget, event):
+ self.draw_stars(3, widget)
+
+ def on_search_four_stars_drawingarea_expose_event(self, widget, event):
+ self.draw_stars(4, widget)
+
+ def on_search_five_stars_drawingarea_expose_event(self, widget, event):
+ self.draw_stars(5, widget)
+
+ def on_searchtags_textview_button_press_event(self, widget, event):
+ return self.taglist.button_press_event(widget, event)
+
+ def on_remove_search_tag_menuitem_activate(self, *args):
+ photoid, tagid = self.taglist.selected_tag()
+ if tagid in self.tagids:
+ self.tagids.remove(tagid)
+ self.refresh_tags()
+ self.search_photos()
+
+ def on_searchtags_textview_drag_motion(self, *args):
+ return self.taglist.drag_motion(*args)
+
+ def on_searchtags_textview_drag_leave(self, *args):
+ return self.taglist.drag_leave(*args)
+
+ def on_searchtags_textview_drag_data_received(self, *args):
+ tagids = self.taglist.drag_data_received(*args)
+ for tagid in tagids:
+ self.tagids.add(tagid)
+ self.refresh_tags()
+ self.search_photos()
+
+ def select_on_stars(self, photoids, stars_set):
+ with self.mwc.db:
+ for photoid in photoids[:]:
+ a, b, rating, d = self.mwc.db.get_basic_photo_metadata(photoid)
+ if rating not in stars_set:
+ photoids.remove(photoid)
+ return photoids
+
+ def select_on_tags(self, photoids, tagids):
+ with self.mwc.db:
+ for photoid in photoids[:]:
+ photo_tagids = set(self.mwc.db.get_tagids(photoid))
+ if not photo_tagids.issuperset(tagids):
+ photoids.remove(photoid)
+ return photoids
+
+ def on_no_stars_checkbutton_toggled(self, button):
+ self.search_photos()
+
+ on_one_star_checkbutton_toggled = on_no_stars_checkbutton_toggled
+ on_two_stars_checkbutton_toggled = on_no_stars_checkbutton_toggled
+ on_three_stars_checkbutton_toggled = on_no_stars_checkbutton_toggled
+ on_four_stars_checkbutton_toggled = on_no_stars_checkbutton_toggled
+ on_five_stars_checkbutton_toggled = on_no_stars_checkbutton_toggled
+
+ def search_photos(self):
+ '''Set grid's list of photoids to matches for current search.'''
+
+ if self.mwc.db is None:
+ return
+
+ photoids = self.photoids[:]
+
+ buttons = (
+ ('no_stars_checkbutton', 0),
+ ('one_star_checkbutton', 1),
+ ('two_stars_checkbutton', 2),
+ ('three_stars_checkbutton', 3),
+ ('four_stars_checkbutton', 4),
+ ('five_stars_checkbutton', 5),
+ )
+ stars_set = set()
+ for name, stars in buttons:
+ w = self.mwc.widgets[name]
+ if w.get_active():
+ stars_set.add(stars)
+ if not stars_set:
+ stars_set = set(range(6))
+ photoids = self.select_on_stars(photoids, stars_set)
+
+ photoids = self.select_on_tags(photoids, self.tagids)
+
+ self.mwc.grid.model.photoids = list(photoids)
+ self.mwc.load_thumbnails_from_database()
+
+ def folder_selection_changed(self, mwc, folderids, photoids):
+ self.photoids = photoids
+ self.search_photos()
+
diff --git a/trunk/dimbola/plugins/tagtree.ui b/trunk/dimbola/plugins/tagtree.ui
new file mode 100644
index 0000000..c4600d9
--- /dev/null
+++ b/trunk/dimbola/plugins/tagtree.ui
@@ -0,0 +1,166 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkExpander" id="tag_tree_expander">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="expanded">True</property>
+ <child>
+ <object class="GtkVBox" id="vbox5">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkHBox" id="hbox5">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkButton" id="add_tag_button">
+ <property name="label" translatable="yes">+</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="remove_tag_button">
+ <property name="label" translatable="yes">-</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">automatic</property>
+ <property name="vscrollbar_policy">automatic</property>
+ <child>
+ <object class="GtkTreeView" id="tagtree">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="events">GDK_BUTTON_PRESS_MASK | GDK_STRUCTURE_MASK</property>
+ <property name="headers_visible">False</property>
+ <property name="reorderable">True</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <child type="label">
+ <object class="GtkLabel" id="label16">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">All tags</property>
+ </object>
+ </child>
+ </object>
+ <object class="GtkDialog" id="remove_tag_dialog">
+ <property name="border_width">5</property>
+ <property name="type_hint">normal</property>
+ <property name="has_separator">False</property>
+ <child internal-child="vbox">
+ <object class="GtkVBox" id="dialog-vbox3">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ <child>
+ <object class="GtkLabel" id="remove_tag_label">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">Really remove tag?</property>
+ <property name="use_markup">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child internal-child="action_area">
+ <object class="GtkHButtonBox" id="dialog-action_area3">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="button3">
+ <property name="label">gtk-cancel</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="button4">
+ <property name="label">gtk-remove</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <action-widgets>
+ <action-widget response="-6">button3</action-widget>
+ <action-widget response="-5">button4</action-widget>
+ </action-widgets>
+ </object>
+ <object class="GtkMenu" id="tagtree_menu">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkMenuItem" id="tagtreemenu_add_menuitem">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Add new tag</property>
+ <property name="use_underline">True</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkMenuItem" id="tagtreemenu_rename_menuitem">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Rename tag</property>
+ <property name="use_underline">True</property>
+ </object>
+ </child>
+ </object>
+</interface>
diff --git a/trunk/dimbola/plugins/tagtree_plugin.py b/trunk/dimbola/plugins/tagtree_plugin.py
new file mode 100644
index 0000000..494e272
--- /dev/null
+++ b/trunk/dimbola/plugins/tagtree_plugin.py
@@ -0,0 +1,318 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import gobject
+import gtk
+
+import dimbola
+
+
+class TaglistEditor(dimbola.Plugin):
+
+ '''Show/edit selected list of all tags.'''
+
+ ID_COL = 0
+ NAME_COL = 1
+
+ def __init__(self, mwc):
+ self.mwc = mwc
+ mwc.connect('setup-widgets', self.setup_widgets)
+ self.db = None
+ self.model = gtk.TreeStore(gobject.TYPE_INT, gobject.TYPE_STRING)
+ self.dragged_tagids = None
+
+ mwc.new_hook('tagtree-changed', gobject.TYPE_NONE, [])
+
+ def tagtree_changed(self):
+ self.mwc.emit('tagtree-changed')
+
+ def enable(self):
+ self.enable_signal(self.mwc, 'db-changed', self.remember_db)
+ self.mwc.add_to_sidebar('right_sidebar', 'tag_tree_expander',
+ expand=True, fill=True, weight=0)
+
+ def disable(self):
+ self.disable_signals()
+ self.mwc.remove_from_sidebar('right_sidebar', 'tag_tree_expander')
+
+ def setup_widgets(self, mwc):
+ self.remove_dialog = mwc.widgets['remove_tag_dialog']
+ self.remove_dialog.set_transient_for(mwc.widgets['window'])
+ self.remove_label = mwc.widgets['remove_tag_label']
+ self.popup = mwc.widgets['tagtree_menu']
+
+ cr = gtk.CellRendererText()
+ self.tagname_cr = cr
+ cr.connect('edited', self.tag_name_was_edited)
+ col = gtk.TreeViewColumn()
+ col.pack_start(cr)
+ col.add_attribute(cr, 'text', self.NAME_COL)
+ self.tagname_col = col
+ self.treeview = mwc.widgets['tagtree']
+ self.treeview.append_column(col)
+ self.treeview.set_model(self.model)
+
+ src_targets = [(dimbola.TAGIDS_TYPE, gtk.TARGET_SAME_APP, 0)]
+ self.treeview.enable_model_drag_source(gtk.gdk.BUTTON1_MASK,
+ src_targets,
+ gtk.gdk.ACTION_COPY)
+
+ dest_targets = [(dimbola.TAGIDS_TYPE, gtk.TARGET_SAME_WIDGET, 0)]
+ self.treeview.enable_model_drag_dest(dest_targets, gtk.gdk.ACTION_COPY)
+
+ self.treeview.get_selection().connect('changed',
+ lambda *a: mwc.set_sensitive())
+
+ def columns(self, id_value, name_value):
+ values = [(self.ID_COL, id_value), (self.NAME_COL, name_value)]
+ values.sort()
+ return [value for colno, value in values]
+
+ def remember_db(self, mwc):
+ self.db = mwc.db
+ with self.db:
+ tb = dimbola.TreeBuilder()
+ for tagid, tagname, parentid in self.db.get_tagnames():
+ tb.add(tagid, tagname, tagname.lower(), parentid)
+ tb.done()
+ self.model.clear()
+ self.populate_treemodel(tb.tree)
+ self.sort_model(None)
+
+ def populate_treemodel(self, nodes, parent_iter=None):
+ for node in nodes:
+ tagid, tagname, children = node
+ print tagid, tagname
+ it = self.model.append(parent_iter, self.columns(tagid, tagname))
+ self.populate_treemodel(children, parent_iter=it)
+
+ def sort_model(self, parent_iter):
+ '''Sort self.model by tagname at node parent_iter.'''
+ items = []
+ it = self.model.iter_children(parent_iter)
+ i = 0
+ while it:
+ name = self.model.get_value(it, self.NAME_COL)
+ items.append((name.lower(), i))
+ self.sort_model(it)
+ it = self.model.iter_next(it)
+ i += 1
+ if items:
+ items.sort()
+ reordered = [i for name, i in items]
+ self.model.reorder(parent_iter, reordered)
+
+ def remove_from_model(self, tagids, parent_iter=None):
+ '''Remove the given tagids from the model.
+
+ If they have child tags, they are removed too.
+
+ '''
+
+ it = self.model.iter_children(parent_iter)
+ while it:
+ next = self.model.iter_next(it)
+ tagid = self.model.get_value(it, self.ID_COL)
+ if tagid in tagids:
+ self.model.remove(it)
+ tagids.remove(tagid)
+ else:
+ self.remove_from_model(tagids, it)
+ it = next
+
+ @property
+ def selected_tags(self):
+ '''Return list of iters, ids of currently selected tags.'''
+ selection = self.treeview.get_selection()
+ model, paths = selection.get_selected_rows()
+ iters = [model.get_iter(path) for path in paths]
+ return [(it, model.get_value(it, self.ID_COL)) for it in iters]
+
+ @property
+ def selected_tag_iter(self):
+ '''Return iter for currently selected tag, or None.
+
+ If there are more than one, return None.
+
+ '''
+
+ selection = self.treeview.get_selection()
+ model, paths = selection.get_selected_rows()
+ if len(paths) == 1:
+ return model.get_iter(paths[0])
+ else:
+ return None
+
+ @property
+ def selected_tagid(self):
+ '''Return id for currently selected tag, or None.
+
+ If there are more than one, return None.
+
+ '''
+
+ selection = self.treeview.get_selection()
+ model, paths = selection.get_selected_rows()
+ if len(paths) == 1:
+ it = model.get_iter(paths[0])
+ return model.get_value(it, self.ID_COL)
+ else:
+ return None
+
+ def on_add_tag_button_clicked(self, *args):
+ tagname = 'new tag'
+ with self.db:
+ tagid = self.db.add_tagname(unicode(tagname))
+ self.db.set_tagparent(tagid, self.selected_tagid)
+ it = self.model.append(self.selected_tag_iter,
+ self.columns(tagid, tagname))
+ self.sort_model(self.selected_tag_iter)
+ path = self.model.get_path(it)
+ self.treeview.expand_to_path(path)
+ self.tagname_cr.set_property('editable', True)
+ self.treeview.set_cursor(path, focus_column=self.tagname_col,
+ start_editing=True)
+
+ on_tagtreemenu_add_menuitem_activate = on_add_tag_button_clicked
+
+ def remove_tag_button_is_sensitive(self):
+ return self.selected_tags
+
+ def on_remove_tag_button_clicked(self, *args):
+ selected = self.selected_tags
+ tagnames = [self.model.get_value(it, self.NAME_COL)
+ for it, tagid in selected]
+
+ self.remove_label.set_markup('Really remove tag <b>%s</b>?' %
+ ', '.join(tagnames))
+ self.remove_dialog.show()
+ response = self.remove_dialog.run()
+ self.remove_dialog.hide()
+ if response == gtk.RESPONSE_OK:
+ tagids = [tagid for it, tagid in selected]
+ with self.db:
+ for tagid in tagids:
+ self.remove_tag_with_children(tagid)
+ self.remove_from_model(tagids)
+ self.tagtree_changed()
+
+ def remove_tag_with_children(self, tagid):
+ for childid in self.db.get_tagchildren(tagid):
+ self.remove_tag_with_children(childid)
+ self.db.remove_tagname(tagid)
+
+ def tag_name_was_edited(self, cr, path, new_tagname):
+ it = self.model.get_iter(path)
+ tagid = self.model.get_value(it, self.ID_COL)
+ with self.db:
+ self.db.change_tagname(tagid, new_tagname)
+ self.model.set_value(it, self.NAME_COL, new_tagname)
+ self.sort_model(self.model.iter_parent(it))
+ self.tagtree_changed()
+ self.tagname_cr.set_property('editable', False)
+
+ def on_tagtree_button_press_event(self, widget, event):
+ if event.button == 3:
+ self.popup.popup(None, None, None, event.button, event.time)
+ return True
+
+ def on_tagtreemenu_rename_menuitem_activate(self, *args):
+ it, tagid = self.selected_tags[0]
+ path = self.model.get_path(it)
+ self.tagname_cr.set_property('editable', True)
+ self.treeview.set_cursor(path, focus_column=self.tagname_col,
+ start_editing=True)
+
+ def tagtreemenu_rename_menuitem_is_sensitive(self):
+ return len(self.selected_tags) == 1
+
+ # Drag handlers for when we're the drag source.
+
+ def on_tagtree_drag_begin(self, w, dc):
+ '''Dragging from us begins.
+
+ We find the currently selected tags and remember their tagids
+ in the dragged_tagids attribute.
+
+ '''
+
+ self.dragged_tagids = [tagid for it, tagid in self.selected_tags]
+
+ def on_tagtree_drag_data_get(self, w, dc, seldata, info, ts):
+ '''Send the dragged data to the other end.'''
+ seldata.set(dimbola.TAGIDS_TYPE, 8,
+ dimbola.encode_dnd_tagids(self.dragged_tagids))
+
+ def on_tagtree_drag_end(self, w, dc):
+ '''Drag operation is finished.
+
+ We forget the tagids that were being dragged.
+
+ '''
+ self.dragged_tagids = None
+
+ def on_tagtree_drag_failed(self, w, dc, result):
+ '''Dragging from us failed: deal with it.'''
+ self.dragged_tagids = None
+
+ # Drag handler for when we're the drag target.
+
+ def on_tagtree_drag_data_received(self, w, dc, x, y, seldata, info, ts):
+ '''Receive data from other end.'''
+ assert seldata.type == dimbola.TAGIDS_TYPE
+ tagids = dimbola.decode_dnd_tagids(seldata.data)
+ t = self.treeview.get_dest_row_at_pos(x, y)
+ if t is None:
+ parent = None
+ parentid = None
+ else:
+ path, drop = t
+ parent = self.model.get_iter(path)
+ if drop in [gtk.TREE_VIEW_DROP_BEFORE, gtk.TREE_VIEW_DROP_AFTER]:
+ parent = self.model.iter_parent(parent)
+ if parent:
+ parentid = self.model.get_value(parent, self.ID_COL)
+ else:
+ parentid = None
+ with self.mwc.db:
+ for tagid in tagids:
+ tb = self.build_tree_for_tag(tagid)
+ self.populate_treemodel(tb, parent_iter=parent)
+ self.sort_model(parent)
+ self.mwc.db.set_tagparent(tagid, parentid)
+ dc.finish(True, True, ts)
+
+ def build_tree_for_tag(self, tagid):
+ '''Make a dimbola.TreeBuilder.tree for the tree rooted at tagid.'''
+
+ def helper(tb, tagid, parentid):
+ # We assume we're within a transaction!
+ tagname = self.mwc.db.get_tagname(tagid)
+ tb.add(tagid, tagname, tagname.lower(), parentid)
+ childids = self.mwc.db.get_tagchildren(tagid)
+ for childid in childids:
+ helper(tb, childid, tagid)
+
+ tb = dimbola.TreeBuilder()
+ helper(tb, tagid, None)
+ tb.done()
+ return tb.tree
+
diff --git a/trunk/dimbola/prefs.py b/trunk/dimbola/prefs.py
new file mode 100644
index 0000000..54daae9
--- /dev/null
+++ b/trunk/dimbola/prefs.py
@@ -0,0 +1,81 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import gobject
+import gtk
+
+import dimbola
+
+
+class Preferences(gobject.GObject):
+
+ def init(self, mwc):
+ self.dialog = mwc.widgets['preferences_dialog']
+
+ model = gtk.ListStore(gobject.TYPE_BOOLEAN, gobject.TYPE_STRING,
+ gobject.TYPE_PYOBJECT)
+ plugins = [(p.name, p) for p in mwc.pm.plugins]
+ plugins.sort()
+ for name, plugin in plugins:
+ model.append((True, name, plugin))
+
+ treeview = mwc.widgets['plugins_treeview']
+
+ toggle_cr = gtk.CellRendererToggle()
+ toggle_cr.connect('toggled', self.toggled, treeview)
+ toggle_col = gtk.TreeViewColumn()
+ toggle_col.pack_start(toggle_cr)
+ toggle_col.add_attribute(toggle_cr, 'active', 0)
+
+ text_cr = gtk.CellRendererText()
+ name_col = gtk.TreeViewColumn()
+ name_col.pack_start(text_cr)
+ name_col.add_attribute(text_cr, 'text', 1)
+
+ treeview.append_column(toggle_col)
+ treeview.append_column(name_col)
+ treeview.set_model(model)
+ self.model = model
+
+ def toggled(self, cr, path, treeview):
+ model = treeview.get_model()
+ it = model.get_iter(path)
+ enabled = model.get_value(it, 0)
+ plugin = model.get_value(it, 2)
+ model.set_value(it, 0, not enabled)
+ if enabled:
+ plugin.disable()
+ else:
+ plugin.enable()
+
+ def on_preferences_menuitem_activate(self, *args):
+ self.dialog.show()
+
+ def on_preferences_close_button_clicked(self, *args):
+ self.dialog.hide()
+
+ def on_preferences_dialog_delete_event(self, *args):
+ self.dialog.hide()
+ return True
+
+ def plugin_is_enabled(self, plugin):
+ it = self.model.get_iter_first()
+ while it:
+ if self.model.get_value(it, 2) == plugin:
+ return self.model.get_value(it, 0)
+ it = self.model.iter_next(it)
+ return False
+
diff --git a/trunk/dimbola/taglist.py b/trunk/dimbola/taglist.py
new file mode 100644
index 0000000..5d88921
--- /dev/null
+++ b/trunk/dimbola/taglist.py
@@ -0,0 +1,161 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import re
+
+import gtk
+
+import dimbola
+
+
+class Taglist(object):
+
+ '''User-editable list of tags.
+
+ This is used for the list of tags for a photo, or the tags that are
+ currently searched for. This is not the "tag tree" that is used to
+ hold the whole set of tags in the database.
+
+ '''
+
+ tagpat = re.compile(r'^taglist-photoid-(?P<photoid>\d+)-'
+ r'tagid-(?P<tagid>\d+)$')
+
+ def __init__(self, textview, popup, popup_prepare_cb):
+ self.textview = textview
+ self.popup = popup
+ self.popup_prepare = popup_prepare_cb
+ self.buf = self.textview.get_buffer()
+ self.textview.drag_dest_set(gtk.DEST_DEFAULT_ALL,
+ [(dimbola.TAGIDS_TYPE,
+ gtk.TARGET_SAME_APP, 0)],
+ gtk.gdk.ACTION_COPY)
+
+ def text_tag_name(self, photoid, tagid):
+ return 'taglist-photoid-%d-tagid-%d' % (photoid, tagid)
+
+ def parse_text_tag_name(self, name):
+ m = self.tagpat.match(name)
+ assert m
+ return int(m.group('photoid')), int(m.group('tagid'))
+
+ def is_our_text_tag_name(self, name):
+ return self.tagpat.match(name)
+
+ @property
+ def text_tag_names(self):
+ def save(text_tag, text_tag_names):
+ name = text_tag.get_property('name')
+ if self.is_our_text_tag_name(name):
+ text_tag_names.append(name)
+
+ names = []
+ tag_table = self.buf.get_tag_table()
+ tag_table.foreach(save, names)
+ return names
+
+ def clear(self):
+ '''Clear the buffer from all text and tags.'''
+ self.buf.set_text('')
+ tag_table = self.buf.get_tag_table()
+ for name in self.text_tag_names:
+ text_tag = tag_table.lookup(name)
+ tag_table.remove(text_tag)
+
+ def set_tags(self, photoid, tags):
+ '''Set tags in list.
+
+ tags is list of (tagid, tagname) pairs.
+
+ '''
+
+ self.clear()
+ taglist = [(tagname.lower(), tagid, tagname)
+ for tagid, tagname in tags]
+ taglist.sort()
+ tags = [(tagid, tagname) for sortkey, tagid, tagname in taglist]
+ for tagid, tagname in tags:
+ text_tag = self.buf.create_tag(self.text_tag_name(photoid, tagid))
+ it = self.buf.get_end_iter()
+ if self.buf.get_char_count() > 0:
+ self.buf.insert(it, '; ')
+ begin = self.buf.create_mark('begin-%s' % tagid, it, True)
+ self.buf.insert_with_tags(it, tagname, text_tag)
+ end = self.buf.create_mark('end-%d' % tagid, it, True)
+
+ def iter_to_ids(self, it):
+ '''Return the photoid, tagid that applies at an iterator.
+
+ If nothing is found, return None, None.
+
+ '''
+
+ for text_tag in it.get_tags():
+ text_tag_name = text_tag.get_property('name')
+ if self.is_our_text_tag_name(text_tag_name):
+ photoid, tagid = self.parse_text_tag_name(text_tag_name)
+ return photoid, tagid
+ return None, None
+
+ def select_tagname_at_iter(self, it):
+ '''Select the tagname at an iterator.'''
+
+ photoid, tagid = self.iter_to_ids(it)
+ if tagid is not None:
+ begin = self.buf.get_mark('begin-%d' % tagid)
+ end = self.buf.get_mark('end-%d' % tagid)
+ self.buf.select_range(self.buf.get_iter_at_mark(begin),
+ self.buf.get_iter_at_mark(end))
+
+ def button_press_event(self, widget, event): # pragma: no cover
+ shift = (event.state & gtk.gdk.SHIFT_MASK) == gtk.gdk.SHIFT_MASK
+ ctrl = (event.state & gtk.gdk.CONTROL_MASK) == gtk.gdk.CONTROL_MASK
+
+ if event.button == 1:
+ it = self.textview.get_iter_at_location(int(event.x), int(event.y))
+ self.select_tagname_at_iter(it)
+ return True
+ elif event.button == 3:
+ if not self.buf.get_has_selection():
+ it = self.textview.get_iter_at_location(int(event.x),
+ int(event.y))
+ if it:
+ self.select_tagname_at_iter(it)
+ if self.popup_prepare:
+ self.popup_prepare()
+ self.popup.popup(None, None, None, event.button, event.time)
+ return True
+
+ def selected_tag(self):
+ if self.buf.get_has_selection():
+ begin_mark = self.buf.get_insert()
+ it = self.buf.get_iter_at_mark(begin_mark)
+ return self.iter_to_ids(it)
+
+ def drag_motion(self, w, dc, x, y, timestamp): # pragma: no cover
+ self.textview.drag_highlight()
+ dc.drag_status(gtk.gdk.ACTION_COPY, timestamp)
+ return True
+
+ def drag_leave(self, w, dc, timestamp): # pragma: no cover
+ self.textview.drag_unhighlight()
+
+ def drag_data_received(self, *args): # pragma: no cover
+ w, dc, x, y, data, info, timestamp = args
+ tagids = dimbola.decode_dnd_tagids(data.data)
+ dc.finish(True, False, timestamp)
+ return tagids
+
diff --git a/trunk/dimbola/taglist_tests.py b/trunk/dimbola/taglist_tests.py
new file mode 100644
index 0000000..32ccfd7
--- /dev/null
+++ b/trunk/dimbola/taglist_tests.py
@@ -0,0 +1,115 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import unittest
+
+import gtk
+
+import dimbola
+
+
+class TaglistTests(unittest.TestCase):
+
+ '''Show/edit selected photo's tags.'''
+
+ def setUp(self):
+ self.textview = gtk.TextView()
+
+ self.taglist = dimbola.Taglist(self.textview, None, None)
+
+ self.photoid = 12
+
+ self.tagid = 34
+ self.tagname = 'yeehaa'
+ self.text_tag_name = 'taglist-photoid-12-tagid-34'
+
+ self.tagid2 = 56
+ self.tagname2 = 'blib'
+ self.text_tag_name2 = 'taglist-photoid-12-tagid-56'
+
+ self.tags = [(self.tagid, self.tagname), (self.tagid2, self.tagname2)]
+
+ def test_creates_text_tag_name_correctly(self):
+ self.assertEqual(self.taglist.text_tag_name(self.photoid, self.tagid),
+ self.text_tag_name)
+
+ def test_recognizes_its_text_tag_name(self):
+ self.assert_(self.taglist.is_our_text_tag_name(self.text_tag_name))
+
+ def test_parses_text_tag_name_correctly(self):
+ self.assertEqual(self.taglist.parse_text_tag_name(self.text_tag_name),
+ (self.photoid, self.tagid))
+
+ def test_has_no_text_tags_initially(self):
+ self.assertEqual(self.taglist.text_tag_names, [])
+
+ def test_set_tags_results_in_text_tags(self):
+ self.taglist.set_tags(self.photoid, self.tags)
+ self.assertEqual(self.taglist.text_tag_names,
+ [self.text_tag_name, self.text_tag_name2])
+
+ def test_set_tags_results_in_buffer_text(self):
+ self.taglist.set_tags(self.photoid, self.tags)
+ start, end = self.taglist.buf.get_bounds()
+ text = self.taglist.buf.get_text(start, end)
+ # Note that we assume tagname2 comes before tagname in sorted order.
+ self.assertEqual(text, '%s; %s' % (self.tagname2, self.tagname))
+
+ def test_clear_empties_text(self):
+ self.taglist.set_tags(self.photoid, self.tags)
+ self.taglist.clear()
+ start, end = self.taglist.buf.get_bounds()
+ text = self.taglist.buf.get_text(start, end)
+ self.assertEqual(text, '')
+
+ def test_clear_empties_text_tags(self):
+ self.taglist.set_tags(self.photoid, self.tags)
+ self.taglist.clear()
+ self.assertEqual(self.taglist.text_tag_names, [])
+
+ def test_finds_ids_at_iter(self):
+ self.taglist.set_tags(self.photoid, self.tags)
+ it = self.taglist.buf.get_iter_at_offset(0)
+ photoid, tagid = self.taglist.iter_to_ids(it)
+ self.assertEqual(photoid, self.photoid)
+ self.assertEqual(tagid, self.tagid2)
+
+ def test_finds_no_ids_outside_tags(self):
+ self.taglist.set_tags(self.photoid, self.tags)
+ it = self.taglist.buf.get_end_iter()
+ self.assertEqual(self.taglist.iter_to_ids(it), (None, None))
+
+ def test_selects_tagname_at_iterator(self):
+ self.taglist.set_tags(self.photoid, self.tags)
+ it = self.taglist.buf.get_iter_at_offset(0)
+ self.taglist.select_tagname_at_iter(it)
+
+ ins = self.taglist.buf.get_insert()
+ ins_it = self.taglist.buf.get_iter_at_mark(ins)
+ self.assertEqual(ins_it.get_offset(), 0)
+
+ sel = self.taglist.buf.get_selection_bound()
+ sel_it = self.taglist.buf.get_iter_at_mark(sel)
+ self.assertEqual(sel_it.get_offset(), len(self.tagname2))
+
+ def test_returns_correct_tagid_when_tagname_is_selected(self):
+ self.taglist.set_tags(self.photoid, self.tags)
+ it = self.taglist.buf.get_iter_at_offset(0)
+ self.taglist.select_tagname_at_iter(it)
+
+ self.assertEqual(self.taglist.selected_tag(),
+ (self.photoid, self.tagid2))
+
diff --git a/trunk/dimbola/ui.py b/trunk/dimbola/ui.py
new file mode 100644
index 0000000..6e655c5
--- /dev/null
+++ b/trunk/dimbola/ui.py
@@ -0,0 +1,385 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+# This is necessary for running under Python 2.5, which we need to
+# do on Debian, for now.
+from __future__ import with_statement
+
+
+import datetime
+import logging
+import optparse
+import os
+import Queue
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+
+import glib
+import gobject
+import gtk
+
+import dimbola
+import gtkapp
+import pluginmgr
+
+
+GLADE = os.path.join(os.path.dirname(__file__), 'ui.ui')
+
+
+
+MIN_WEIGHT = 0
+MAX_WEIGHT = 2**31
+
+
+class BackgroundStatus(object):
+
+ '''Status change indications from background jobs to UI.
+
+ action should be either 'start' or 'stop'.
+ description is a user-visible description of what action is going in.
+
+ '''
+
+ def __init__(self, action, description):
+ self.action = action
+ self.description = description
+
+ def process_result(self, mwc):
+ '''Do something with the result, in the MWC context.
+
+ This will only ever be called by MWC when action is 'stop'.
+
+ '''
+
+
+class MainWindowController(gobject.GObject, gtkapp.GtkApplication):
+
+ __gsignals__ = {
+ 'setup-widgets': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, []),
+ 'db-changed': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, []),
+ 'photo-meta-changed': (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE,
+ [gobject.TYPE_INT]),
+ }
+
+ def __init__(self, pm):
+ gobject.GObject.__init__(self)
+
+ # Set up the plugin manager. Every Dimbola plugin gets this
+ # class as their initializer argument.
+ self.pm = pm
+ self.pm.plugin_arguments = (self,)
+
+ # The currently open database. See the get_db/set_db methods
+ # later on.
+ self._db = None
+
+ self.grid = dimbola.Grid(self)
+ self.preferences = dimbola.Preferences()
+
+ # Load all .ui files and setup all widgets.
+ for ui_file in self.find_ui_files():
+ self.setup_widgets(ui_file,
+ [self, self.grid,
+ self.preferences] +
+ self.pm.plugins)
+ self.weigh_predefined_menu_items()
+ self.preferences.init(self)
+ self.emit('setup-widgets')
+
+ self.grid.model.connect('selection-changed', self.grid_selection_changed)
+
+ self.set_default_size()
+
+ self.bgmgr = dimbola.BackgroundManager()
+ self.bg_idle_id = None
+ self.bg_total = 0
+ self.bg_done = 0
+
+ self.enable_plugins()
+
+ def enable_plugins(self):
+ '''Enable all plugins that are intended to be enabled.'''
+ for plugin in self.pm.plugins:
+ if self.preferences.plugin_is_enabled(plugin):
+ plugin.enable()
+
+ def find_ui_files(self):
+ '''Find all .ui files: the main one, plus those for plugins.'''
+ ui_files = [GLADE]
+ for plugin in self.pm.plugins:
+ module = sys.modules[plugin.__module__]
+ pathname, ext = os.path.splitext(module.__file__)
+ assert pathname.endswith('_plugin')
+ pathname = pathname[:-len('_plugin')] + '.ui'
+ if os.path.exists(pathname):
+ ui_files.append(pathname)
+ return ui_files
+
+ def photo_meta_changed(self, photoid):
+ self.emit('photo-meta-changed', photoid)
+
+ def new_hook(self, name, return_type, param_types):
+ gobject.signal_new(name, self.__class__, gobject.SIGNAL_RUN_LAST,
+ return_type, param_types)
+
+ def get_db(self):
+ return self._db
+ def set_db(self, db):
+ self._db = db
+ self.emit('db-changed')
+ db = property(get_db, set_db)
+
+ def set_default_size(self):
+ self.widgets['window'].set_default_size(900, 700)
+ self.widgets['window'].maximize()
+ self.widgets['left_sidebar'].set_size_request(250, -1)
+ self.widgets['right_sidebar'].set_size_request(250, -1)
+
+ def run(self):
+ self.widgets['window'].show()
+ self.set_sensitive()
+ gtk.main()
+
+ def handle_background_status(self):
+ if self.bgmgr.running:
+ try:
+ result = self.bgmgr.results.get(block=False)
+ except Queue.Empty:
+ pass
+ else:
+ if isinstance(result, dimbola.BackgroundStatus):
+ text = result.description
+ if result.action == 'stop':
+ result.process_result(self)
+ self.bg_done += 1
+ elif result is None:
+ text = ''
+ self.bg_done += 1
+ else:
+ self.error_message('Oops', str(result))
+ text = 'Error'
+ self.bg_done += 1
+ f = float(self.bg_done) / float(self.bg_total)
+ p = self.widgets['bg_progressbar']
+ p.set_fraction(f)
+ p.set_text('%d / %d' % (self.bg_done, self.bg_total))
+ self.widgets['bg_label'].set_text(text)
+ self.set_sensitive()
+ return True
+ else:
+ self.bg_idle_id = None
+ self.widgets['bg_label'].set_text('')
+ p = self.widgets['bg_progressbar']
+ p.set_fraction(0.0)
+ p.set_text('')
+ self.bg_done = 0
+ self.bg_total = 0
+ self.set_sensitive()
+ return False
+
+ def add_bgjob(self, job):
+ self.bgmgr.add_job(job)
+ self.bg_total += 1
+ if not self.bgmgr.processes:
+ self.bgmgr.start_jobs()
+ if self.bg_idle_id is None:
+ self.bg_idle_id = glib.idle_add(self.handle_background_status)
+ p = self.widgets['bg_progressbar']
+ p.set_fraction(0.0)
+ p.set_text('%d / %d' % (self.bg_done, self.bg_total))
+ self.set_sensitive()
+
+ def bg_stop_button_is_sensitive(self):
+ return self.bgmgr.running
+
+ def error_message(self, msg1, msg2):
+ dialog = self.widgets['error_dialog']
+ dialog.set_markup(msg1)
+ dialog.format_secondary_text(msg2)
+ dialog.show()
+ dialog.run()
+ dialog.hide()
+
+ def add_to_menu(self, menu_name, menuitem_name, label, check=False,
+ weight=MAX_WEIGHT):
+ '''Add an item to a menu.
+
+ menu_name is the name of the menu in the .ui file.
+ menuitem_name is the name of the new menu item.
+ label is the text of the new menu item.
+ check is True if the new item is to be a check item.
+ weight gives the ordering inside the menu, relative to other items.
+
+ '''
+
+ assert weight >= MIN_WEIGHT
+ assert weight <= MAX_WEIGHT
+
+ menu = self.widgets[menu_name]
+
+ if menuitem_name in [i.get_name() for i in menu.get_children()]:
+ raise Exception('Attempting to re-add menu item %s to %s' %
+ (menuitem_name, menu_name))
+
+ if check:
+ menuitem = gtk.CheckMenuItem(label)
+ else:
+ menuitem = gtk.MenuItem(label)
+ menuitem.set_name(menuitem_name)
+ menuitem.show()
+ menuitem.set_data('weight', weight)
+ menu.append(menuitem)
+ self.reorder_within_parent(menu, menuitem)
+ self.setup_a_widget(menuitem)
+
+ def remove_from_menu(self, menu_name, menuitem_name):
+ menu = self.widgets[menu_name]
+ for menuitem in menu.get_children():
+ if menuitem.get_name() == menuitem_name:
+ menu.remove(menuitem)
+
+ def add_to_sidebar(self, sidebar_name, widget_name, expand=False,
+ fill=False, weight=MAX_WEIGHT):
+ '''Add an item to a sidebar.
+
+ sidebar_name is the name of the sidebar in the .ui file.
+ widget_name is the name of the widget, also from (some) .ui file.
+ expand and fill are given to the gtk.Box.pack_start method.
+ weight gives the ordering inside the menu, relative to other items.
+
+ '''
+
+ assert weight >= MIN_WEIGHT
+ assert weight <= MAX_WEIGHT
+
+ sidebar = self.widgets[sidebar_name]
+ widget = self.widgets[widget_name]
+ widget.set_data('weight', weight)
+
+ sidebar.pack_start(widget, expand=expand, fill=fill, padding=6)
+ self.reorder_within_parent(sidebar, widget)
+
+ def remove_from_sidebar(self, sidebar_name, widget_name):
+ sidebar = self.widgets[sidebar_name]
+ widget = self.widgets[widget_name]
+ sidebar.remove(widget)
+
+ def reorder_within_parent(self, parent, widget):
+ weight = widget.get_data('weight')
+ for i, child in enumerate(parent.get_children()):
+ if weight < child.get_data('weight'):
+ parent.reorder_child(widget, i)
+ break
+
+ def weigh_predefined_menu_items(self):
+ '''Set weights for all menu items for main_menu.
+
+ This is used so that the default items and items added by plugins
+ get ordered in a nice, deterministic manner. Each menu may contain
+ an invisible menu item named with a 'plugin_items' prefix. Any
+ menu items before such an item are given a MIN_WEIGHT-1 weight;
+ anything after (and the invisible one itself), a MAX_WEIGHT+1
+ weight.
+
+ If a menu does not have a 'plugin_items' item, all weights will
+ be MIN_WEIGHT-1.
+
+ '''
+
+ for menu in self.find_menus():
+ children = menu.get_children()
+ for pos, child in enumerate(children):
+ if child.get_name().startswith('plugin_items'):
+ break
+ for child in children[:pos]:
+ child.set_data('weight', MIN_WEIGHT - 1)
+ for child in children[pos:]:
+ child.set_data('weight', MAX_WEIGHT + 1)
+
+ def find_menus(self):
+ '''Generator for finding all menus under the main menu.'''
+
+ for item in self.widgets.values():
+ if isinstance(item, gtk.Menu):
+ yield item
+
+ def load_thumbnails_from_database(self):
+ with self.db:
+ for photoid in self.grid.model.photoids:
+ a, b, c, rotate = self.db.get_basic_photo_metadata(photoid)
+ thumbnail = self.db.get_thumbnail(photoid)
+ pixbuf = dimbola.image_data_to_pixbuf(thumbnail)
+ self.grid.model.thumbnails[photoid] = pixbuf
+ self.grid.model.angles[photoid] = rotate
+ self.grid.view.draw_thumbnail(photoid)
+
+ # The rest are GTK signal callbacks.
+
+ def on_window_delete_event(self, *args):
+ self.bgmgr.stop_jobs()
+ gtk.main_quit()
+
+ on_quit_menuitem_activate = on_window_delete_event
+
+ def on_about_menuitem_activate(self, *args):
+ w = self.widgets['aboutdialog']
+ w.set_version(dimbola.version)
+ w.show()
+ w.run()
+ w.hide()
+
+ def grid_selection_changed(self, *args):
+ self.set_sensitive()
+
+ def on_bg_stop_button_clicked(self, *args):
+ self.bgmgr.stop_jobs()
+ self.set_sensitive()
+
+
+class UI(object):
+
+ '''Graphical user interface.'''
+
+ def create_option_parser(self): # pragma: no cover
+ """Create an OptionParser instance for this app."""
+ parser = optparse.OptionParser(version=dimbola.version)
+ return parser
+
+ def parse_command_line(self): # pragma: no cover
+ """Parse the command line for this app."""
+ parser = self.create_option_parser()
+ options, args = parser.parse_args()
+ return options, args
+
+ def run(self): # pragma: no cover
+ logging.basicConfig(level=logging.INFO)
+ options, args = self.parse_command_line()
+ if not args:
+ db_name = 'default.dimbola'
+ else:
+ db_name = args[0]
+ db = dimbola.Database(db_name)
+ db.init_db()
+ pm = pluginmgr.PluginManager()
+ pm.locations = [os.path.join(os.path.dirname(dimbola.__file__),
+ 'plugins')]
+ mwc = MainWindowController(pm)
+ mwc.db = db
+ mwc.run()
+
diff --git a/trunk/dimbola/ui.ui b/trunk/dimbola/ui.ui
new file mode 100644
index 0000000..6a54c86
--- /dev/null
+++ b/trunk/dimbola/ui.ui
@@ -0,0 +1,556 @@
+<?xml version="1.0"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkWindow" id="window">
+ <property name="title" translatable="yes">Dimbola</property>
+ <child>
+ <object class="GtkVBox" id="vbox1">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkMenuBar" id="main_menu">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkMenuItem" id="file_menuitem">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">_File</property>
+ <property name="use_underline">True</property>
+ <child type="submenu">
+ <object class="GtkMenu" id="file_menu">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkMenuItem" id="plugin_items2">
+ <property name="label" translatable="yes">menuitem3</property>
+ <property name="use_underline">True</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkSeparatorMenuItem" id="prepend_separator1">
+ <property name="visible">True</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="quit_menuitem">
+ <property name="label">gtk-quit</property>
+ <property name="visible">True</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ <accelerator key="q" signal="activate" modifiers="GDK_CONTROL_MASK"/>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkMenuItem" id="photo_menuitem">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Photo</property>
+ <property name="use_underline">True</property>
+ <child type="submenu">
+ <object class="GtkMenu" id="photo_menu">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkMenuItem" id="plugin_items3">
+ <property name="label" translatable="yes">menuitem2</property>
+ <property name="use_underline">True</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkMenuItem" id="view_menuitem">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">View</property>
+ <property name="use_underline">True</property>
+ <child type="submenu">
+ <object class="GtkMenu" id="menu2">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkRadioMenuItem" id="view_grid_menuitem">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Thumbnail grid</property>
+ <property name="use_underline">True</property>
+ <property name="active">True</property>
+ <property name="draw_as_radio">True</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkRadioMenuItem" id="view_photo_menuitem">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Photo</property>
+ <property name="use_underline">True</property>
+ <property name="group">view_grid_menuitem</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkMenuItem" id="menuitem1">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">Tools</property>
+ <property name="use_underline">True</property>
+ <child type="submenu">
+ <object class="GtkMenu" id="menu1">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkMenuItem" id="plugin_items4">
+ <property name="label" translatable="yes">menuitem2</property>
+ <property name="use_underline">True</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="preferences_menuitem">
+ <property name="label">gtk-preferences</property>
+ <property name="visible">True</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="GtkMenuItem" id="menuitem4">
+ <property name="visible">True</property>
+ <property name="label" translatable="yes">_Help</property>
+ <property name="use_underline">True</property>
+ <child type="submenu">
+ <object class="GtkMenu" id="help_menu">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkMenuItem" id="plugin_items1">
+ <property name="sensitive">False</property>
+ <property name="label" translatable="yes">menuitem3</property>
+ <property name="use_underline">True</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkImageMenuItem" id="about_menuitem">
+ <property name="label">gtk-about</property>
+ <property name="visible">True</property>
+ <property name="use_underline">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHPaned" id="hpaned1">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkHPaned" id="hpaned2">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <child>
+ <object class="GtkVBox" id="left_sidebar">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ <packing>
+ <property name="resize">False</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHBox" id="hbox1">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkVBox" id="grid_vbox">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkHBox" id="hbox2">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkDrawingArea" id="thumbnail_drawingarea">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="events">GDK_BUTTON_PRESS_MASK | GDK_FOCUS_CHANGE_MASK | GDK_STRUCTURE_MASK</property>
+ </object>
+ <packing>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkVScrollbar" id="thumbnail_vscrollbar">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHBox" id="hbox3">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkLabel" id="label1">
+ <property name="visible">True</property>
+ <property name="xalign">1</property>
+ <property name="label" translatable="yes">small</property>
+ </object>
+ <packing>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHScale" id="thumbnail_scale">
+ <property name="width_request">300</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="adjustment">thumbnail_adjustment</property>
+ <property name="show_fill_level">True</property>
+ <property name="draw_value">False</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label2">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">large</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkVBox" id="photo_vbox">
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkDrawingArea" id="photo_drawingarea">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="events">GDK_BUTTON_PRESS_MASK | GDK_KEY_PRESS_MASK | GDK_STRUCTURE_MASK</property>
+ </object>
+ <packing>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHButtonBox" id="hbuttonbox1">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <object class="GtkButton" id="photo_previous_button">
+ <property name="label">gtk-media-previous</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="photo_next_button">
+ <property name="label">gtk-media-next</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="resize">True</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="resize">True</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkVBox" id="right_sidebar">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ <packing>
+ <property name="resize">False</property>
+ <property name="shrink">True</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHBox" id="bg_hbox">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkProgressBar" id="bg_progressbar">
+ <property name="visible">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="bg_stop_button">
+ <property name="label">gtk-stop</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="bg_label">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="xpad">6</property>
+ <property name="ellipsize">start</property>
+ </object>
+ <packing>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkAdjustment" id="thumbnail_adjustment">
+ <property name="upper">100</property>
+ <property name="step_increment">1</property>
+ <property name="page_increment">1</property>
+ <property name="page_size">1</property>
+ </object>
+ <object class="GtkMessageDialog" id="error_dialog">
+ <property name="border_width">5</property>
+ <property name="title" translatable="yes">Error</property>
+ <property name="destroy_with_parent">True</property>
+ <property name="type_hint">normal</property>
+ <property name="skip_taskbar_hint">True</property>
+ <property name="transient_for">window</property>
+ <property name="message_type">error</property>
+ <property name="buttons">close</property>
+ <child internal-child="vbox">
+ <object class="GtkVBox" id="dialog-vbox8">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ <child internal-child="action_area">
+ <object class="GtkHButtonBox" id="dialog-action_area8">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <placeholder/>
+ </child>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkAboutDialog" id="aboutdialog">
+ <property name="border_width">5</property>
+ <property name="type_hint">normal</property>
+ <property name="transient_for">window</property>
+ <property name="program_name">Dimbola</property>
+ <property name="copyright" translatable="yes">Copyright 2009 Lars Wirzenius.
+</property>
+ <property name="comments" translatable="yes">Manage collection of digital photographs. This is still very alpha quality, but might some day grow up to be a tool for people serious about photography.</property>
+ <property name="website">https://launchpad.net/dimbola</property>
+ <property name="license" translatable="yes">Dimbola is licensed under the GNU General Public License, version 3 or (at your option) a later version.</property>
+ <property name="wrap_license">True</property>
+ <child internal-child="vbox">
+ <object class="GtkVBox" id="dialog-vbox10">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ <child>
+ <placeholder/>
+ </child>
+ <child internal-child="action_area">
+ <object class="GtkHButtonBox" id="dialog-action_area10">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkDialog" id="preferences_dialog">
+ <property name="border_width">5</property>
+ <property name="title" translatable="yes">Dimbola preferences</property>
+ <property name="default_width">500</property>
+ <property name="default_height">500</property>
+ <property name="type_hint">normal</property>
+ <property name="transient_for">window</property>
+ <property name="has_separator">False</property>
+ <child internal-child="vbox">
+ <object class="GtkVBox" id="dialog-vbox11">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">2</property>
+ <child>
+ <object class="GtkVBox" id="vbox12">
+ <property name="visible">True</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkLabel" id="label22">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="xpad">6</property>
+ <property name="ypad">6</property>
+ <property name="label" translatable="yes">Plugins</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrolledWindow" id="scrolledwindow4">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hscrollbar_policy">automatic</property>
+ <property name="vscrollbar_policy">automatic</property>
+ <child>
+ <object class="GtkTreeView" id="plugins_treeview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="headers_visible">False</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child internal-child="action_area">
+ <object class="GtkHButtonBox" id="dialog-action_area11">
+ <property name="visible">True</property>
+ <property name="layout_style">end</property>
+ <child>
+ <placeholder/>
+ </child>
+ <child>
+ <object class="GtkButton" id="preferences_close_button">
+ <property name="label">gtk-close</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_stock">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="pack_type">end</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ <action-widgets>
+ <action-widget response="0">preferences_close_button</action-widget>
+ </action-widgets>
+ </object>
+</interface>
diff --git a/trunk/dimbola/utils.py b/trunk/dimbola/utils.py
new file mode 100644
index 0000000..e7815e3
--- /dev/null
+++ b/trunk/dimbola/utils.py
@@ -0,0 +1,472 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import hashlib
+import logging
+import math
+import os
+import StringIO
+import subprocess
+import tempfile
+
+import gio
+import gtk
+
+
+def abswalk(*args, **kwargs):
+ '''Like os.walk, but return absolute pathnames for dirs, filenames.
+
+ Arguments are as for os.walk.
+
+ For example, abswalk might return the tuple
+ ('/etc', ['/etc/default'], ['/etc/passwd', '/etc/group']) where
+ os.walk might return ('/etc', ['default'], ['passwd', 'group']).
+
+ abswalk os convenient when the caller wants to handle full pathnames
+ anyway, as it saves the caller from having to do os.path.join itself.
+
+ '''
+ def abs(dirname, list):
+ return [os.path.join(dirname, x) for x in list]
+ for dirname, dirnames, filenames in os.walk(*args, **kwargs):
+ yield dirname, abs(dirname, dirnames), abs(dirname, filenames)
+
+
+def filterabswalk(is_ok, *args, **kwargs):
+ '''Like abswalk, but filenames (not dirnames) can be filtered.
+
+ The is_ok argument is a function that gets the fully qualified name of
+ a file (not directory) and returns True/False to indicate whether it
+ should be included in the results.
+
+ All other arguments are as for os.walk.
+
+ '''
+ for dirname, dirnames, pathnames in abswalk(*args, **kwargs):
+ yield dirname, dirnames, [x for x in pathnames if is_ok(x)]
+
+
+def safe_copy(input_name, output_name, callback):
+ """Copy contents of input_name to new file called output_name.
+
+ If the output_name already exists, fail. If anything else goes
+ wrong, fail. Ensure the data is on disk using fsync on the output
+ name and on the directory containing the output.
+
+ The permissions and other stat information for the input are NOT
+ copied to the output.
+
+ """
+
+ infd = os.open(input_name, os.O_RDONLY)
+ outfd = os.open(output_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0644)
+
+ total_copied = 0
+ while True:
+ data = os.read(infd, 1024**2)
+ if not data:
+ break
+ os.write(outfd, data)
+ total_copied += len(data)
+ if callback:
+ callback(input_name, output_name, total_copied)
+
+ os.close(infd)
+ os.fsync(outfd)
+ os.close(outfd)
+
+ output_dir = os.path.dirname(output_name) or "."
+ dirfd = os.open(output_dir, os.O_RDONLY)
+ os.fsync(dirfd)
+ os.close(dirfd)
+
+
+def filter_cmd(argv, input_data):
+ '''Filter input data through an external command.'''
+
+ fd, name = tempfile.mkstemp()
+ os.write(fd, input_data)
+ os.lseek(fd, 0, 0)
+ os.remove(name)
+
+ p = subprocess.Popen(argv, stdin=fd, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ stdout, stderr = p.communicate()
+ if p.returncode:
+ raise Exception('command %s failed: exit code %s\n%s' %
+ (argv, p.returncode, stderr or ''))
+ return stdout
+
+
+def image_data_to_pixbuf(image_data):
+ '''Create a gdk.Pixbuf out of some image data.'''
+
+ loader = gtk.gdk.PixbufLoader()
+ loader.write(image_data)
+ loader.close()
+ return loader.get_pixbuf()
+
+
+def pixbuf_to_image_data(pixbuf, format, options=None): # pragma: no cover
+ f = StringIO.StringIO()
+ def save_func(buf):
+ f.write(buf)
+ return True
+ pixbuf.save_to_callback(save_func, format, options=options)
+ return f.getvalue()
+
+
+def image_data_to_image_data(data, format, options=None): # pragma: no cover
+ pixbuf = image_data_to_pixbuf(data)
+ return pixbuf_to_image_data(pixbuf, format, options=options)
+
+
+def scale_pixbuf(pixbuf, maxw, maxh):
+ '''Scale a pixbuf so it fits within maxw and maxh.
+
+ Keep aspect ratio.
+
+ '''
+
+ w = pixbuf.get_width()
+ h = pixbuf.get_height()
+
+ fw = float(maxw) / float(w)
+ fh = float(maxh) / float(h)
+ f = min(fw, fh)
+ w2 = int(f * w)
+ h2 = int(f * h)
+ assert w2 <= maxw
+ assert h2 <= maxh
+
+ return pixbuf.scale_simple(w2, h2, gtk.gdk.INTERP_BILINEAR)
+
+
+def rotate_pixbuf(pixbuf, angle):
+ '''Rotate pixbuf in 90 degree angles.'''
+ values = {
+ 90: gtk.gdk.PIXBUF_ROTATE_COUNTERCLOCKWISE,
+ 180: gtk.gdk.PIXBUF_ROTATE_UPSIDEDOWN,
+ 270: gtk.gdk.PIXBUF_ROTATE_CLOCKWISE,
+ }
+ return pixbuf.rotate_simple(values.get(angle, gtk.gdk.PIXBUF_ROTATE_NONE))
+
+
+def encode_dnd_tagids(tagids):
+ '''Encode a list of tagids for drag-and-drop.'''
+ return ' '. join(str(tagid) for tagid in tagids)
+
+
+def decode_dnd_tagids(encoded):
+ '''Reverse operation of encode_dnd_tagids.'''
+ return [int(s) for s in encoded.split()]
+
+
+class TreeBuilder(object):
+
+ '''Build a tree out of a sequence of nodes.
+
+ The caller provides zero or more node descriptions (see add method).
+ After the caller is done (see done method), they can access the
+ tree built out of the nodes as the tree property. The tree is
+ represented as a list of node tuples (nodeid, data, child_nodes).
+
+ If a node should have a parent, but the parent is not added, the
+ node becomes a root node.
+
+ '''
+
+ def __init__(self):
+ # We store nodes in a dictionary indexed by the nodeid.
+ # The value is a tuple of (data, parentid, childids).
+ # Childids is initially an empty set, it'll be used by
+ # the done method.
+ self.nodes = dict()
+
+ def add(self, nodeid, data, sortkey, parentid):
+ '''Add a node to tree.
+
+ nodeid is the identifier of the node itself.
+ sortkey is used when sorting children with the same parent.
+ parentid is the identifier of its parent, or None.
+
+ data is the data associated with the node.
+
+ '''
+
+ self.nodes[nodeid] = (data, sortkey, parentid, set())
+
+ def done(self):
+ '''Caller is done adding nodes, compute the tree.
+
+ Caller MUST call this; until this is called, self.tree does not
+ exist.
+
+ '''
+
+ # First we put each node into its parents' childids.
+ # If parent is missing, we pretend it was always None.
+ for nodeid in self.nodes:
+ data, sortkey, parentid, children = self.nodes[nodeid]
+ if parentid in self.nodes:
+ self.nodes[parentid][3].add(nodeid)
+ else:
+ self.nodes[nodeid] = (data, sortkey, None, children)
+
+ # Next we find all root nodes: all nodes whose parentid is None.
+ roots = [nodeid
+ for nodeid in self.nodes
+ if self.nodes[nodeid][2] is None]
+
+ # Next we build the tree for each root node, and add those
+ # to the tree.
+ rootlist = [(self.nodes[rootid][1], rootid) for rootid in roots]
+ rootlist.sort()
+ roots = [rootid for sortkey, rootid in rootlist]
+ self.tree = [self.build_one_tree(rootid) for rootid in roots]
+
+ def build_one_tree(self, rootid):
+ data, sortkey, parentid, childids = self.nodes[rootid]
+ childlist = [(self.nodes[kid][1], kid) for kid in childids]
+ childlist.sort()
+ childids = [kid for sortkey, kid in childlist]
+ return (rootid, data,
+ [self.build_one_tree(childid) for childid in childids])
+
+
+class DcrawTypeCache(object):
+
+ '''Cache 'dcraw -i' results.
+
+ dcraw does not export a list of MIME types it recognizes, but it does
+ have an option to test whether it supports the format of a particular
+ file. That's slow, so we cache the results using this class.
+
+ The results are stored in a format compatible with what
+ gtk.gdk.pixbuf_get_formats returns: a list of dictionaries with
+ keys 'name', 'mime_types', and 'extension'. (This is a subset of
+ the keys for pixbufs.)
+
+ '''
+
+ def __init__(self):
+ self.formats = []
+ self.fail_extensions = set()
+ self.fail_mime_types = set()
+
+ def update(self, format, mime_type, extension):
+ if mime_type not in format['mime_types']:
+ format['mime_types'].append(mime_type)
+ for ext in [extension, extension.lower(), extension.upper()]:
+ if ext not in format['extensions']:
+ format['extensions'].append(ext)
+
+ def add_format(self, name, mime_type, extension):
+ for format in self.formats:
+ if format['name'] == name:
+ self.update(format, mime_type, extension)
+ return
+ elif mime_type in format['mime_types']:
+ self.update(format, mime_type, extension)
+ return
+ elif extension in format['extensions']:
+ self.update(format, mime_type, extension)
+ return
+
+ format = {
+ 'name': name,
+ 'mime_types': [],
+ 'extensions': [],
+ }
+ self.update(format, mime_type, extension)
+ self.formats.append(format)
+
+ def extension_is_known(self, ext):
+ return [x for x in self.formats if ext in x['extensions']]
+
+ def mime_type_is_known(self, mimetype):
+ return [x for x in self.formats if mimetype in x['mime_types']]
+
+ def get_mime_type(self, filename):
+ f = gio.File(path=filename)
+ fi = f.query_info(gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE)
+ return gio.content_type_get_mime_type(fi.get_content_type())
+
+ def get_dcraw(self, filename):
+ try:
+ p = subprocess.Popen(['dcraw', '-i', filename],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ stdout, stderr = p.communicate('')
+ except OSError, e: # pragma: no cover
+ logging.debug('Cannot run dcraw: %s' % str(e))
+ return None
+ if p.returncode == 0:
+ prefix = '%s is a ' % filename
+ if stdout.startswith(prefix):
+ stdout = stdout[len(prefix):]
+ suffix = ' image.\n'
+ if stdout.endswith(suffix):
+ stdout = stdout[:-len(suffix)]
+ return stdout
+ else:
+ return None
+
+ def add_from_file(self, filename):
+ prefix, ext = os.path.splitext(filename)
+ if ext.startswith('.'):
+ ext = ext[1:]
+ if self.extension_is_known(ext):
+ return
+ if ext in self.fail_extensions:
+ return
+
+ mime_type = self.get_mime_type(filename)
+ if self.mime_type_is_known(mime_type):
+ return
+ if mime_type in self.fail_mime_types:
+ return
+
+ desc = self.get_dcraw(filename)
+ if desc is None:
+ self.fail_extensions.add(ext)
+ self.fail_mime_types.add(mime_type)
+ else:
+ self.add_format(ext, mime_type, ext)
+
+ def supported(self, filename):
+ self.add_from_file(filename)
+ name, ext = os.path.splitext(filename)
+ if ext.startswith('.'):
+ ext = ext[1:]
+ if self.extension_is_known(ext):
+ return True
+ mime_type = self.get_mime_type(filename)
+ return self.mime_type_is_known(mime_type)
+
+
+def draw_star(drawable, gc, x, y, dim): # pragma: no cover
+ '''Draw a five-pointed star.
+
+ The star will be drawn inside a square of dim pixels, whose top left
+ corner is at (x,y). The star will be filled. The graphics context gc
+ is used for drawing and filling.
+
+ '''
+
+ # To follow this code, imagine a circle inscribed in the square.
+ # The star is a pentagram drawn inside the circle, situated so that
+ # one of its points is pointing upwards. The five points are called
+ # A through E. Inside the pentagram is an upside down pentagon. It's
+ # lowest point is directly below A (same x co-ordinate), and is called
+ # F. We draw the pentagram by drawing three filled triangles: ACF,
+ # ADF, and BEF.
+ #
+ # The co-ordinates of the six points are a bit tricky, or I am stupid.
+ # First we find the co-ordinates with the assumption that the center of
+ # the square (and circle and pentagram) is at origin, then we displace
+ # them to the right place. Note also that screen and geometrical
+ # y-axis are in opposite direction.
+ #
+ # The radius of the circle is R = dim/2.
+ #
+ # The angle AOB is 2*pi/5.
+ #
+ # A is simple: (0, R).
+ #
+ # B: angle between FB and x-axis is (pi/2 - AOB) = (pi/2 - 2*pi/5) =
+ # (5*pi/10 - 4*pi/10) = pi/10 = alpha.
+ # Thus B = (R*cos alpha, R*sin alpha).
+ #
+ # C: angle between FC and x-axis is (BOC - alpha) = (2*pi/5 - alpha) =
+ # (2*pi/5 - pi/10) = (4*pi/10 - pi/10) = 3*pi/10 = beta.
+ # Thus C = (R*cos beta, R*sin beta).
+ #
+ # D = (-Cx, Cy).
+ #
+ # E = (-Bx, By).
+ #
+ # F: Let P = (Cx, By), Z = (0, By). The triangle EZF is shaped like
+ # EPC, but smaller. EZ/ZF = EP/PC <=> ZF = EZ*PC/EP. Also,
+ # ZF = ZO + OF so OF = EZ*PC/EP - ZO. We have the co-ordinates for
+ # everything except F, and F = (0, OF). Thus:
+ # Fy = -(Bx*(By-Cy)/(Bx+Cx) - By) = Bx*(Cy-By)/(Bx+Cx)+By.
+
+ R = float(dim) / 2.0
+ alpha = math.pi / 10.0
+ beta = -3.0 * math.pi / 10.0
+
+ # These calculations are done in normal math co-ordinate system.
+ # (Y grows upwards.)
+ A = (0, R)
+ B = (R * math.cos(alpha), R * math.sin(alpha))
+ C = (R * math.cos(beta), R * math.sin(beta))
+ D = (-C[0], C[1])
+ E = (-B[0], B[1])
+ F = (0, -(B[0] * (B[1] - C[1]) / (B[0] + C[0]) - B[1]))
+ F = (0, B[0] * (C[1] - B[1]) / (B[0] + C[0]) + B[1])
+
+ # Transform co-ordinates to screen: move origin to center of square,
+ # and change direction of Y axis.
+ def xform(coords):
+ return int(x + R + coords[0]), int(y + R - coords[1])
+
+ A = xform(A)
+ B = xform(B)
+ C = xform(C)
+ D = xform(D)
+ E = xform(E)
+ F = xform(F)
+
+ # Draw the three triangles.
+ drawable.draw_polygon(gc, True, (A, F, C, A))
+ drawable.draw_polygon(gc, True, (A, F, D, A))
+ drawable.draw_polygon(gc, True, (B, E, F, B))
+
+
+def draw_stars(n_stars, drawable, gc, x, y, dim): # pragma: no cover
+ '''Like draw_star, but draws n_stars stars.
+
+ The drawable MUST be wide enough to have space for five (5) stars.
+ That area will be cleared.
+
+ '''
+
+ drawable.clear_area(x, y, 5 * dim, dim)
+ for i in range(n_stars):
+ draw_star(drawable, gc, x + i*dim, y, dim)
+
+
+def sha1(filename): # pragma: no cover
+ '''Compute SHA1 checksum of a file.
+
+ Return None if there were errors.
+
+ '''
+ try:
+ f = file(filename)
+ except IOError:
+ return None
+
+ c = hashlib.new('sha1')
+ while True:
+ data = f.read(64*1024)
+ if not data:
+ break
+ c.update(data)
+ f.close()
+ return c.hexdigest()
+
diff --git a/trunk/dimbola/utils.py.~1~ b/trunk/dimbola/utils.py.~1~
new file mode 100644
index 0000000..705b7ff
--- /dev/null
+++ b/trunk/dimbola/utils.py.~1~
@@ -0,0 +1,473 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import hashlib
+import logging
+import math
+import os
+import StringIO
+import subprocess
+import tempfile
+
+import gio
+import gtk
+
+
+def abswalk(*args, **kwargs):
+ '''Like os.walk, but return absolute pathnames for dirs, filenames.
+
+ Arguments are as for os.walk.
+
+ For example, abswalk might return the tuple
+ ('/etc', ['/etc/default'], ['/etc/passwd', '/etc/group']) where
+ os.walk might return ('/etc', ['default'], ['passwd', 'group']).
+
+ abswalk os convenient when the caller wants to handle full pathnames
+ anyway, as it saves the caller from having to do os.path.join itself.
+
+ '''
+ def abs(dirname, list):
+ return [os.path.join(dirname, x) for x in list]
+ for dirname, dirnames, filenames in os.walk(*args, **kwargs):
+ yield dirname, abs(dirname, dirnames), abs(dirname, filenames)
+
+
+def filterabswalk(is_ok, *args, **kwargs):
+ '''Like abswalk, but filenames (not dirnames) can be filtered.
+
+ The is_ok argument is a function that gets the fully qualified name of
+ a file (not directory) and returns True/False to indicate whether it
+ should be included in the results.
+
+ All other arguments are as for os.walk.
+
+ '''
+ for dirname, dirnames, pathnames in abswalk(*args, **kwargs):
+ filenames = [x for x in pathnames if is_ok(x)]
+ yield dirname, dirnames, filenames
+
+
+def safe_copy(input_name, output_name, callback):
+ """Copy contents of input_name to new file called output_name.
+
+ If the output_name already exists, fail. If anything else goes
+ wrong, fail. Ensure the data is on disk using fsync on the output
+ name and on the directory containing the output.
+
+ The permissions and other stat information for the input are NOT
+ copied to the output.
+
+ """
+
+ infd = os.open(input_name, os.O_RDONLY)
+ outfd = os.open(output_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0644)
+
+ total_copied = 0
+ while True:
+ data = os.read(infd, 1024**2)
+ if not data:
+ break
+ os.write(outfd, data)
+ total_copied += len(data)
+ if callback:
+ callback(input_name, output_name, total_copied)
+
+ os.close(infd)
+ os.fsync(outfd)
+ os.close(outfd)
+
+ output_dir = os.path.dirname(output_name) or "."
+ dirfd = os.open(output_dir, os.O_RDONLY)
+ os.fsync(dirfd)
+ os.close(dirfd)
+
+
+def filter_cmd(argv, input_data):
+ '''Filter input data through an external command.'''
+
+ fd, name = tempfile.mkstemp()
+ os.write(fd, input_data)
+ os.lseek(fd, 0, 0)
+ os.remove(name)
+
+ p = subprocess.Popen(argv, stdin=fd, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ stdout, stderr = p.communicate()
+ if p.returncode:
+ raise Exception('command %s failed: exit code %s\n%s' %
+ (argv, p.returncode, stderr or ''))
+ return stdout
+
+
+def image_data_to_pixbuf(image_data):
+ '''Create a gdk.Pixbuf out of some image data.'''
+
+ loader = gtk.gdk.PixbufLoader()
+ loader.write(image_data)
+ loader.close()
+ return loader.get_pixbuf()
+
+
+def pixbuf_to_image_data(pixbuf, format, options=None): # pragma: no cover
+ f = StringIO.StringIO()
+ def save_func(buf):
+ f.write(buf)
+ return True
+ pixbuf.save_to_callback(save_func, format, options=options)
+ return f.getvalue()
+
+
+def image_data_to_image_data(data, format, options=None): # pragma: no cover
+ pixbuf = image_data_to_pixbuf(data)
+ return pixbuf_to_image_data(pixbuf, format, options=options)
+
+
+def scale_pixbuf(pixbuf, maxw, maxh):
+ '''Scale a pixbuf so it fits within maxw and maxh.
+
+ Keep aspect ratio.
+
+ '''
+
+ w = pixbuf.get_width()
+ h = pixbuf.get_height()
+
+ fw = float(maxw) / float(w)
+ fh = float(maxh) / float(h)
+ f = min(fw, fh)
+ w2 = int(f * w)
+ h2 = int(f * h)
+ assert w2 <= maxw
+ assert h2 <= maxh
+
+ return pixbuf.scale_simple(w2, h2, gtk.gdk.INTERP_BILINEAR)
+
+
+def rotate_pixbuf(pixbuf, angle):
+ '''Rotate pixbuf in 90 degree angles.'''
+ values = {
+ 90: gtk.gdk.PIXBUF_ROTATE_COUNTERCLOCKWISE,
+ 180: gtk.gdk.PIXBUF_ROTATE_UPSIDEDOWN,
+ 270: gtk.gdk.PIXBUF_ROTATE_CLOCKWISE,
+ }
+ return pixbuf.rotate_simple(values.get(angle, gtk.gdk.PIXBUF_ROTATE_NONE))
+
+
+def encode_dnd_tagids(tagids):
+ '''Encode a list of tagids for drag-and-drop.'''
+ return ' '. join(str(tagid) for tagid in tagids)
+
+
+def decode_dnd_tagids(encoded):
+ '''Reverse operation of encode_dnd_tagids.'''
+ return [int(s) for s in encoded.split()]
+
+
+class TreeBuilder(object):
+
+ '''Build a tree out of a sequence of nodes.
+
+ The caller provides zero or more node descriptions (see add method).
+ After the caller is done (see done method), they can access the
+ tree built out of the nodes as the tree property. The tree is
+ represented as a list of node tuples (nodeid, data, child_nodes).
+
+ If a node should have a parent, but the parent is not added, the
+ node becomes a root node.
+
+ '''
+
+ def __init__(self):
+ # We store nodes in a dictionary indexed by the nodeid.
+ # The value is a tuple of (data, parentid, childids).
+ # Childids is initially an empty set, it'll be used by
+ # the done method.
+ self.nodes = dict()
+
+ def add(self, nodeid, data, sortkey, parentid):
+ '''Add a node to tree.
+
+ nodeid is the identifier of the node itself.
+ sortkey is used when sorting children with the same parent.
+ parentid is the identifier of its parent, or None.
+
+ data is the data associated with the node.
+
+ '''
+
+ self.nodes[nodeid] = (data, sortkey, parentid, set())
+
+ def done(self):
+ '''Caller is done adding nodes, compute the tree.
+
+ Caller MUST call this; until this is called, self.tree does not
+ exist.
+
+ '''
+
+ # First we put each node into its parents' childids.
+ # If parent is missing, we pretend it was always None.
+ for nodeid in self.nodes:
+ data, sortkey, parentid, children = self.nodes[nodeid]
+ if parentid in self.nodes:
+ self.nodes[parentid][3].add(nodeid)
+ else:
+ self.nodes[nodeid] = (data, sortkey, None, children)
+
+ # Next we find all root nodes: all nodes whose parentid is None.
+ roots = [nodeid
+ for nodeid in self.nodes
+ if self.nodes[nodeid][2] is None]
+
+ # Next we build the tree for each root node, and add those
+ # to the tree.
+ rootlist = [(self.nodes[rootid][1], rootid) for rootid in roots]
+ rootlist.sort()
+ roots = [rootid for sortkey, rootid in rootlist]
+ self.tree = [self.build_one_tree(rootid) for rootid in roots]
+
+ def build_one_tree(self, rootid):
+ data, sortkey, parentid, childids = self.nodes[rootid]
+ childlist = [(self.nodes[kid][1], kid) for kid in childids]
+ childlist.sort()
+ childids = [kid for sortkey, kid in childlist]
+ return (rootid, data,
+ [self.build_one_tree(childid) for childid in childids])
+
+
+class DcrawTypeCache(object):
+
+ '''Cache 'dcraw -i' results.
+
+ dcraw does not export a list of MIME types it recognizes, but it does
+ have an option to test whether it supports the format of a particular
+ file. That's slow, so we cache the results using this class.
+
+ The results are stored in a format compatible with what
+ gtk.gdk.pixbuf_get_formats returns: a list of dictionaries with
+ keys 'name', 'mime_types', and 'extension'. (This is a subset of
+ the keys for pixbufs.)
+
+ '''
+
+ def __init__(self):
+ self.formats = []
+ self.fail_extensions = set()
+ self.fail_mime_types = set()
+
+ def update(self, format, mime_type, extension):
+ if mime_type not in format['mime_types']:
+ format['mime_types'].append(mime_type)
+ for ext in [extension, extension.lower(), extension.upper()]:
+ if ext not in format['extensions']:
+ format['extensions'].append(ext)
+
+ def add_format(self, name, mime_type, extension):
+ for format in self.formats:
+ if format['name'] == name:
+ self.update(format, mime_type, extension)
+ return
+ elif mime_type in format['mime_types']:
+ self.update(format, mime_type, extension)
+ return
+ elif extension in format['extensions']:
+ self.update(format, mime_type, extension)
+ return
+
+ format = {
+ 'name': name,
+ 'mime_types': [],
+ 'extensions': [],
+ }
+ self.update(format, mime_type, extension)
+ self.formats.append(format)
+
+ def extension_is_known(self, ext):
+ return [x for x in self.formats if ext in x['extensions']]
+
+ def mime_type_is_known(self, mimetype):
+ return [x for x in self.formats if mimetype in x['mime_types']]
+
+ def get_mime_type(self, filename):
+ f = gio.File(path=filename)
+ fi = f.query_info(gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE)
+ return gio.content_type_get_mime_type(fi.get_content_type())
+
+ def get_dcraw(self, filename):
+ try:
+ p = subprocess.Popen(['dcraw', '-i', filename],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ stdout, stderr = p.communicate('')
+ except OSError, e: # pragma: no cover
+ logging.debug('Cannot run dcraw: %s' % str(e))
+ return None
+ if p.returncode == 0:
+ prefix = '%s is a ' % filename
+ if stdout.startswith(prefix):
+ stdout = stdout[len(prefix):]
+ suffix = ' image.\n'
+ if stdout.endswith(suffix):
+ stdout = stdout[:-len(suffix)]
+ return stdout
+ else:
+ return None
+
+ def add_from_file(self, filename):
+ prefix, ext = os.path.splitext(filename)
+ if ext.startswith('.'):
+ ext = ext[1:]
+ if self.extension_is_known(ext):
+ return
+ if ext in self.fail_extensions:
+ return
+
+ mime_type = self.get_mime_type(filename)
+ if self.mime_type_is_known(mime_type):
+ return
+ if mime_type in self.fail_mime_types:
+ return
+
+ desc = self.get_dcraw(filename)
+ if desc is None:
+ self.fail_extensions.add(ext)
+ self.fail_mime_types.add(mime_type)
+ else:
+ self.add_format(ext, mime_type, ext)
+
+ def supported(self, filename):
+ self.add_from_file(filename)
+ name, ext = os.path.splitext(filename)
+ if ext.startswith('.'):
+ ext = ext[1:]
+ if self.extension_is_known(ext):
+ return True
+ mime_type = self.get_mime_type(filename)
+ return self.mime_type_is_known(mime_type)
+
+
+def draw_star(drawable, gc, x, y, dim): # pragma: no cover
+ '''Draw a five-pointed star.
+
+ The star will be drawn inside a square of dim pixels, whose top left
+ corner is at (x,y). The star will be filled. The graphics context gc
+ is used for drawing and filling.
+
+ '''
+
+ # To follow this code, imagine a circle inscribed in the square.
+ # The star is a pentagram drawn inside the circle, situated so that
+ # one of its points is pointing upwards. The five points are called
+ # A through E. Inside the pentagram is an upside down pentagon. It's
+ # lowest point is directly below A (same x co-ordinate), and is called
+ # F. We draw the pentagram by drawing three filled triangles: ACF,
+ # ADF, and BEF.
+ #
+ # The co-ordinates of the six points are a bit tricky, or I am stupid.
+ # First we find the co-ordinates with the assumption that the center of
+ # the square (and circle and pentagram) is at origin, then we displace
+ # them to the right place. Note also that screen and geometrical
+ # y-axis are in opposite direction.
+ #
+ # The radius of the circle is R = dim/2.
+ #
+ # The angle AOB is 2*pi/5.
+ #
+ # A is simple: (0, R).
+ #
+ # B: angle between FB and x-axis is (pi/2 - AOB) = (pi/2 - 2*pi/5) =
+ # (5*pi/10 - 4*pi/10) = pi/10 = alpha.
+ # Thus B = (R*cos alpha, R*sin alpha).
+ #
+ # C: angle between FC and x-axis is (BOC - alpha) = (2*pi/5 - alpha) =
+ # (2*pi/5 - pi/10) = (4*pi/10 - pi/10) = 3*pi/10 = beta.
+ # Thus C = (R*cos beta, R*sin beta).
+ #
+ # D = (-Cx, Cy).
+ #
+ # E = (-Bx, By).
+ #
+ # F: Let P = (Cx, By), Z = (0, By). The triangle EZF is shaped like
+ # EPC, but smaller. EZ/ZF = EP/PC <=> ZF = EZ*PC/EP. Also,
+ # ZF = ZO + OF so OF = EZ*PC/EP - ZO. We have the co-ordinates for
+ # everything except F, and F = (0, OF). Thus:
+ # Fy = -(Bx*(By-Cy)/(Bx+Cx) - By) = Bx*(Cy-By)/(Bx+Cx)+By.
+
+ R = float(dim) / 2.0
+ alpha = math.pi / 10.0
+ beta = -3.0 * math.pi / 10.0
+
+ # These calculations are done in normal math co-ordinate system.
+ # (Y grows upwards.)
+ A = (0, R)
+ B = (R * math.cos(alpha), R * math.sin(alpha))
+ C = (R * math.cos(beta), R * math.sin(beta))
+ D = (-C[0], C[1])
+ E = (-B[0], B[1])
+ F = (0, -(B[0] * (B[1] - C[1]) / (B[0] + C[0]) - B[1]))
+ F = (0, B[0] * (C[1] - B[1]) / (B[0] + C[0]) + B[1])
+
+ # Transform co-ordinates to screen: move origin to center of square,
+ # and change direction of Y axis.
+ def xform(coords):
+ return int(x + R + coords[0]), int(y + R - coords[1])
+
+ A = xform(A)
+ B = xform(B)
+ C = xform(C)
+ D = xform(D)
+ E = xform(E)
+ F = xform(F)
+
+ # Draw the three triangles.
+ drawable.draw_polygon(gc, True, (A, F, C, A))
+ drawable.draw_polygon(gc, True, (A, F, D, A))
+ drawable.draw_polygon(gc, True, (B, E, F, B))
+
+
+def draw_stars(n_stars, drawable, gc, x, y, dim): # pragma: no cover
+ '''Like draw_star, but draws n_stars stars.
+
+ The drawable MUST be wide enough to have space for five (5) stars.
+ That area will be cleared.
+
+ '''
+
+ drawable.clear_area(x, y, 5 * dim, dim)
+ for i in range(n_stars):
+ draw_star(drawable, gc, x + i*dim, y, dim)
+
+
+def sha1(filename): # pragma: no cover
+ '''Compute SHA1 checksum of a file.
+
+ Return None if there were errors.
+
+ '''
+ try:
+ f = file(filename)
+ except IOError:
+ return None
+
+ c = hashlib.new('sha1')
+ while True:
+ data = f.read(64*1024)
+ if not data:
+ break
+ c.update(data)
+ f.close()
+ return c.hexdigest()
+
diff --git a/trunk/dimbola/utils_tests.py b/trunk/dimbola/utils_tests.py
new file mode 100644
index 0000000..3fb1cc1
--- /dev/null
+++ b/trunk/dimbola/utils_tests.py
@@ -0,0 +1,363 @@
+# Copyright (C) 2009 Lars Wirzenius <liw@liw.fi>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+import os
+import shutil
+import tempfile
+import unittest
+
+import gtk
+
+import dimbola
+
+
+class AbswalkTests(unittest.TestCase):
+
+ def setUp(self):
+ self.root = tempfile.mkdtemp()
+ self.dirname = tempfile.mkdtemp(dir=self.root)
+ fd, self.filename = tempfile.mkstemp(dir=self.root)
+ os.close(fd)
+
+ def tearDown(self):
+ shutil.rmtree(self.root)
+
+ def test_returns_full_paths(self):
+ results = list(dimbola.abswalk(self.root))
+ self.assertEqual(results,
+ [(self.root, [self.dirname], [self.filename]),
+ (self.dirname, [], [])])
+
+
+class FilterAbswalkTests(unittest.TestCase):
+
+ def setUp(self):
+ self.root = tempfile.mkdtemp()
+ self.dirname = tempfile.mkdtemp(dir=self.root)
+ fd, self.filename = tempfile.mkstemp(dir=self.root)
+ os.close(fd)
+
+ def tearDown(self):
+ shutil.rmtree(self.root)
+
+ def test_returns_everything_if_is_ok_always_returns_true(self):
+ results = list(dimbola.filterabswalk(lambda x: True, self.root))
+ self.assertEqual(results,
+ [(self.root, [self.dirname], [self.filename]),
+ (self.dirname, [], [])])
+
+ def test_returns_nothing_if_is_ok_always_returns_false(self):
+ results = list(dimbola.filterabswalk(lambda x: False, self.root))
+ self.assertEqual(results,
+ [(self.root, [self.dirname], []),
+ (self.dirname, [], [])])
+
+
+class SafeCopyTests(unittest.TestCase):
+
+ def setUp(self):
+ self.root = tempfile.mkdtemp()
+
+ def tearDown(self):
+ shutil.rmtree(self.root)
+
+ def make_file(self, contents):
+ fd, name = tempfile.mkstemp(dir=self.root)
+ os.write(fd, contents)
+ os.close(fd)
+ return name
+
+ def cat(self, filename):
+ return file(filename).read()
+
+ def callback(self, *args):
+ self.callback_args = args
+
+ def test_copies_file_correctly(self):
+ old = self.make_file('foobar')
+ new = self.make_file('')
+ os.remove(new)
+ dimbola.safe_copy(old, new, self.callback)
+ self.assertEqual(self.cat(new), 'foobar')
+ self.assertEqual(self.callback_args, (old, new, len('foobar')))
+
+ def test_fails_if_output_file_already_exists(self):
+ name = self.make_file('foobar')
+ self.assertRaises(Exception, dimbola.safe_copy, name, name, None)
+
+
+class FilterCmdTests(unittest.TestCase):
+
+ def test_raises_exception_for_nonexistent_command(self):
+ self.assertRaises(Exception, dimbola.filter_cmd,
+ ['this-command-does-not-exist'], '')
+
+ def test_raises_exception_for_failing_command(self):
+ self.assertRaises(Exception, dimbola.filter_cmd, ['false'], '')
+
+ def test_filters_cleanly_through_cat(self):
+ self.assertEqual(dimbola.filter_cmd(['cat'], 'foo'), 'foo')
+
+ def test_filters_a_lot_of_data_cleanly_through_cat(self):
+ data = 'x' * (1024**2)
+ self.assertEqual(dimbola.filter_cmd(['cat'], data), data)
+
+
+class ImageDataToPixbufTests(unittest.TestCase):
+
+ def test_makes_pixbuf_out_of_jpeg(self):
+ jpeg = file('test-plugins/test.jpg').read()
+ pixbuf = dimbola.image_data_to_pixbuf(jpeg)
+ self.assert_(isinstance(pixbuf, gtk.gdk.Pixbuf))
+
+
+class ScalePixbufTests(unittest.TestCase):
+
+ def setUp(self):
+ jpeg = file('test-plugins/test.jpg').read()
+ self.pixbuf = dimbola.image_data_to_pixbuf(jpeg)
+ self.small = dimbola.scale_pixbuf(self.pixbuf, 100, 100)
+
+ def test_width_is_correct(self):
+ self.assertEqual(self.small.get_width(), 100)
+
+ def test_height_is_correct(self):
+ w = self.pixbuf.get_width()
+ h = self.pixbuf.get_height()
+ sh = int(100 * float(h)/w)
+ self.assertEqual(self.small.get_height(), sh)
+
+
+class RotatePixbufTests(unittest.TestCase):
+
+ def setUp(self):
+ jpeg = file('test-plugins/test.jpg').read()
+ self.pixbuf = dimbola.image_data_to_pixbuf(jpeg)
+
+ def test_rotates_left_and_back_to_original(self):
+ temp = dimbola.rotate_pixbuf(self.pixbuf, 180)
+ temp2 = dimbola.rotate_pixbuf(temp, 180)
+ self.assertEqual(self.pixbuf.get_pixels(), temp2.get_pixels())
+
+
+class DndTagidsTests(unittest.TestCase):
+
+ def setUp(self):
+ self.tagids = [1, 2, 3]
+
+ def test_round_trip_works(self):
+ encoded = dimbola.encode_dnd_tagids(self.tagids)
+ decoded = dimbola.decode_dnd_tagids(encoded)
+ self.assertEqual(self.tagids, decoded)
+
+
+class TreeBuilderTests(unittest.TestCase):
+
+ def setUp(self):
+ self.tb = dimbola.TreeBuilder()
+
+ def test_returns_empty_tree_by_default(self):
+ self.tb.done()
+ self.assertEqual(self.tb.tree, [])
+
+ def test_returns_single_node_when_only_one_item(self):
+ self.tb.add('node', 'data', 'sortkey', None)
+ self.tb.done()
+ self.assertEqual(self.tb.tree, [('node', 'data', [])])
+
+ def test_returns_two_root_nodes_when_no_children(self):
+ self.tb.add('node1', 'data1', None, None)
+ self.tb.add('node2', 'data2', None, None)
+ self.tb.done()
+ self.assertEqual(self.tb.tree,
+ [('node1', 'data1', []),
+ ('node2', 'data2', [])])
+
+ def test_returns_children_nodes_when_there_is_one(self):
+ self.tb.add('child', 'data3', None, 'node1')
+ self.tb.add('node1', 'data1', None, None)
+ self.tb.add('node2', 'data2', None, None)
+ self.tb.done()
+ self.assertEqual(self.tb.tree,
+ [('node1', 'data1', [('child', 'data3', [])]),
+ ('node2', 'data2', [])])
+
+ def test_sorts_children_with_same_parent(self):
+ self.tb.add('parent', 'data', None, None)
+ self.tb.add('foo', 'data1', 'key2', 'parent')
+ self.tb.add('bar', 'data2', 'key1', 'parent')
+ self.tb.done()
+ self.assertEqual(self.tb.tree,
+ [('parent', 'data',
+ [('bar', 'data2', []),
+ ('foo', 'data1', [])])])
+
+ def test_sorts_roots(self):
+ self.tb.add('foo', 'data1', 'key2', None)
+ self.tb.add('bar', 'data2', 'key1', None)
+ self.tb.done()
+ self.assertEqual(self.tb.tree,
+ [('bar', 'data2', []),
+ ('foo', 'data1', [])])
+
+
+class DcrawTypeCacheTests(unittest.TestCase):
+
+ def setUp(self):
+ self.dtc = dimbola.DcrawTypeCache()
+ self.dtc.get_mime_type = self.fake_get_mime_type
+ self.dtc.get_dcraw = self.fake_get_dcraw
+ self.fake_get_mime_type_called = False
+ self.fake_get_dcraw_called = False
+
+ def fake_get_mime_type(self, filename):
+ self.fake_get_mime_type_called = True
+ return 'mime/type'
+
+ def fake_get_dcraw(self, filename):
+ self.fake_get_dcraw_called = True
+ return 'desc'
+
+ def fake_get_dcraw_fail(self, filename):
+ self.fake_get_dcraw_called = True
+ return None
+
+ def test_lists_nothing_by_default(self):
+ self.assertEqual(self.dtc.formats, [])
+
+ def test_adding_format_lists_it(self):
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.assertEqual(self.dtc.formats,
+ [{ 'name': 'name',
+ 'mime_types': ['mime/type'],
+ 'extensions': ['ext', 'EXT'],
+ }])
+
+ def test_adding_format_with_same_name_does_not_add_it(self):
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.assertEqual(len(self.dtc.formats), 1)
+
+ def test_adding_with_same_name_new_mime_type_modifies_existing(self):
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.dtc.add_format('name', 'mime/type2', 'ext2')
+ self.assertEqual(self.dtc.formats,
+ [{ 'name': 'name',
+ 'mime_types': ['mime/type', 'mime/type2'],
+ 'extensions': ['ext', 'EXT', 'ext2', 'EXT2'],
+ }])
+
+ def test_adding_with_diff_name_same_mime_type_modifies_existing(self):
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.dtc.add_format('name2', 'mime/type', 'ext2')
+ self.assertEqual(self.dtc.formats,
+ [{ 'name': 'name',
+ 'mime_types': ['mime/type'],
+ 'extensions': ['ext', 'EXT', 'ext2', 'EXT2'],
+ }])
+
+ def test_adding_with_same_extension_modifies_existing(self):
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.dtc.add_format('name2', 'mime/type2', 'ext')
+ self.assertEqual(self.dtc.formats,
+ [{ 'name': 'name',
+ 'mime_types': ['mime/type', 'mime/type2'],
+ 'extensions': ['ext', 'EXT'],
+ }])
+
+ def test_known_extension_is_known(self):
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.assert_(self.dtc.extension_is_known('ext'))
+
+ def test_unknown_extension_is_unknown(self):
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.assertFalse(self.dtc.extension_is_known('ext2'))
+
+ def test_known_mime_type_is_known(self):
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.assert_(self.dtc.mime_type_is_known('mime/type'))
+
+ def test_unknown_mime_type_is_unknown(self):
+ self.dtc.add_format('name', 'mime/type', 'ext')
+ self.assertFalse(self.dtc.extension_is_known('mime/type2'))
+
+ def test_recognizes_jpeg_mime_type(self):
+ dtc = dimbola.DcrawTypeCache()
+ self.assertEqual(dtc.get_mime_type('test-plugins/test.jpg'),
+ 'image/jpeg')
+
+ def test_recognizes_raw_file_type(self):
+ dtc = dimbola.DcrawTypeCache()
+ self.assertEqual(dtc.get_dcraw('test-plugins/test.cr2'),
+ 'Canon EOS 5D')
+
+ def test_dcraw_returns_None_for_unknown_filetype(self):
+ dtc = dimbola.DcrawTypeCache()
+ self.assertEqual(dtc.get_dcraw('README'), None)
+
+ def test_recognizes_existing_format_based_on_mime_type(self):
+ self.dtc.add_from_file('filename.ext')
+ self.fake_get_mime_type_called = False
+ self.fake_get_dcraw_called = False
+ self.dtc.add_from_file('filename.ext2')
+ self.assertFalse(self.fake_get_dcraw_called)
+ self.assert_(self.fake_get_mime_type_called)
+
+ def test_recognizes_existing_format_based_on_extension(self):
+ self.dtc.add_from_file('filename.ext')
+ self.fake_get_mime_type_called = False
+ self.fake_get_dcraw_called = False
+ self.dtc.add_from_file('filename.ext')
+ self.assertFalse(self.fake_get_mime_type_called)
+ self.assertFalse(self.fake_get_dcraw_called)
+
+ def test_does_not_add_unrecognized_format(self):
+ self.dtc.get_dcraw = self.fake_get_dcraw_fail
+ self.dtc.add_from_file('filename.ext')
+ self.assertEqual(self.dtc.formats, [])
+
+ def test_adds_format_from_file_with_dcraw(self):
+ self.dtc.add_from_file('filename.ext')
+ self.assertEqual(self.dtc.formats,
+ [{ 'name': 'ext',
+ 'mime_types': ['mime/type'],
+ 'extensions': ['ext', 'EXT'],
+ }])
+
+ def test_does_not_test_twice_for_unknown_type_with_same_ext(self):
+ self.dtc.get_dcraw = self.fake_get_dcraw_fail
+ self.dtc.add_from_file('filename.ext')
+ self.fake_get_mime_type_called = False
+ self.fake_get_dcraw_called = False
+ self.dtc.add_from_file('filename.ext')
+ self.assertFalse(self.fake_get_mime_type_called)
+ self.assertFalse(self.fake_get_dcraw_called)
+
+ def test_does_not_test_twice_for_unknown_type_with_same_mime(self):
+ self.dtc.get_dcraw = self.fake_get_dcraw_fail
+ self.dtc.add_from_file('filename.ext')
+ self.fake_get_mime_type_called = False
+ self.fake_get_dcraw_called = False
+ self.dtc.add_from_file('filename.ext2')
+ self.assertFalse(self.fake_get_dcraw_called)
+
+ def test_supported_returns_false_for_unsupported_file(self):
+ self.dtc.get_dcraw = self.fake_get_dcraw_fail
+ self.assertFalse(self.dtc.supported('filename.ext'))
+
+ def test_supported_returns_true_for_unsupported_file(self):
+ self.assert_(self.dtc.supported('filename.ext'))
+