summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--dimbola.com/.bzrignore1
-rw-r--r--dimbola.com/branches.mdwn8
-rw-r--r--dimbola.com/dimbola-0.0.2.jpgbin0 -> 46038 bytes
-rw-r--r--dimbola.com/index.mdwn72
-rw-r--r--dimbola.com/local.css1
-rw-r--r--dimbola.com/problems.mdwn71
-rw-r--r--dimbola.com/releasing.mdwn32
-rw-r--r--dimbola.com/roadmap.mdwn56
-rw-r--r--dimbola.com/testing.mdwn95
-rw-r--r--dimbola.com/wishlist.mdwn153
-rw-r--r--trunk/.bzr-builddeb/default.conf3
-rw-r--r--trunk/.bzrignore2
-rw-r--r--trunk/.coveragebin0 -> 1183 bytes
-rw-r--r--trunk/HACKING173
-rw-r--r--trunk/Makefile20
-rw-r--r--trunk/Makefile.~1~20
-rw-r--r--trunk/NEWS181
-rw-r--r--trunk/NEWS.template18
-rw-r--r--trunk/README30
-rw-r--r--trunk/debian/changelog43
-rw-r--r--trunk/debian/compat1
-rw-r--r--trunk/debian/control22
-rw-r--r--trunk/debian/control.~1~22
-rw-r--r--trunk/debian/copyright23
-rw-r--r--trunk/debian/dimbola.docs2
-rw-r--r--trunk/debian/pycompat1
-rwxr-xr-xtrunk/debian/rules8
-rw-r--r--trunk/default.dimbolabin0 -> 4082688 bytes
-rwxr-xr-xtrunk/dimbola-copy5
-rwxr-xr-xtrunk/dimbola-gtk4
-rw-r--r--trunk/dimbola-gtk.114
-rwxr-xr-xtrunk/dimbola-info54
-rw-r--r--trunk/dimbola.desktop.in10
-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
-rw-r--r--trunk/foo.py11
-rw-r--r--trunk/gallery/dimbola-gallery.py198
-rw-r--r--trunk/gallery/image.template9
-rw-r--r--trunk/gallery/index.template10
-rw-r--r--trunk/gallery/indexdir.template1
-rw-r--r--trunk/gallery/indeximage.template3
-rw-r--r--trunk/no-unit-tests.txt25
-rw-r--r--trunk/po/Makefile27
-rw-r--r--trunk/po/POTFILES.in2
-rw-r--r--trunk/po/dimbola.pot29
-rw-r--r--trunk/po/fi.po28
-rwxr-xr-xtrunk/scripts/checksum-benchmark23
-rwxr-xr-xtrunk/scripts/list-gdkpixbuf-formats17
-rwxr-xr-xtrunk/scripts/pyexiv2dump15
-rwxr-xr-xtrunk/scripts/test-bgjobs47
-rw-r--r--trunk/setup.cfg4
-rw-r--r--trunk/setup.py51
-rw-r--r--trunk/test-plugins/aaa_hello_plugin.py8
-rw-r--r--trunk/test-plugins/hello_plugin.py11
-rw-r--r--trunk/test-plugins/oldhello_plugin.py9
-rw-r--r--trunk/test-plugins/test.cr2bin0 -> 11398232 bytes
-rw-r--r--trunk/test-plugins/test.jpgbin0 -> 24257 bytes
-rw-r--r--trunk/test-plugins/wrongversion_plugin.py12
99 files changed, 9642 insertions, 0 deletions
diff --git a/dimbola.com/.bzrignore b/dimbola.com/.bzrignore
new file mode 100644
index 0000000..edd4856
--- /dev/null
+++ b/dimbola.com/.bzrignore
@@ -0,0 +1 @@
+.ikiwiki
diff --git a/dimbola.com/branches.mdwn b/dimbola.com/branches.mdwn
new file mode 100644
index 0000000..c6e92a8
--- /dev/null
+++ b/dimbola.com/branches.mdwn
@@ -0,0 +1,8 @@
+These are the publically announced [bzr](http://bazaar.canonical.com/en/)
+branches for Dimbola. To check out the code, run this command:
+
+ bzr get http://code.liw.fi/dimbola/bzr/trunk/
+
+(replace the URL with whatever branch you want, of course).
+
+* [liw trunk](http://code.liw.fi/dimbola/bzr/trunk/)
diff --git a/dimbola.com/dimbola-0.0.2.jpg b/dimbola.com/dimbola-0.0.2.jpg
new file mode 100644
index 0000000..80ccf2b
--- /dev/null
+++ b/dimbola.com/dimbola-0.0.2.jpg
Binary files differ
diff --git a/dimbola.com/index.mdwn b/dimbola.com/index.mdwn
new file mode 100644
index 0000000..7d90fc7
--- /dev/null
+++ b/dimbola.com/index.mdwn
@@ -0,0 +1,72 @@
+[[!meta title="Dimbola photo manager"]]
+
+Helping Linux-using photographers be awesome at finding their photos.
+
+Dimbola
+-------
+
+Dimbola is, or will be, a free Linux/GNOME application for
+**managing digital photographs.** It is aimed at serious
+photographers. It is currently **nearing alpha state**,
+some basic functionality is there and works, but lots is
+still missing.
+
+This page is a placeholder for until there is some real
+content. (Help wanted! This is a wiki, just fix things
+if you see something broken.)
+
+See the [[roadmap]] for what will hopefully happen in
+the future.
+
+
+[[dimbola-0.0.2.jpg]]
+
+
+Download
+--------
+
+The **current version is 0.0.3.**
+
+To hear about new releases, subscribe to the announcement mailing list by
+sending e-mail to `announce-subscribe@dimbola.org` (you will get further
+instructions in reply).
+
+See [NEWS file](http://bazaar.launchpad.net/%7Edimbola-team/dimbola/trunk/annotate/head%3A/NEWS)
+for what's new in each release. It is also available as Help/News in the program.
+
+See [[branches]] for version control branches.
+
+The program is developed under Ubuntu 9.04 (jaunty) and
+9.10 (karmic), but should work elsewhere with Python 2.6,
+PyGTK, dcraw, pyexiv2, and netpbm.
+
+Dimbola is licensed under the GNU General Public License,
+version 3 or later.
+
+
+Releasing
+---------
+
+See [[testing]] for a manual test manuscript, and
+[[releasing]] for a release checklist.
+
+
+Problems and stuff to improve
+-----------------------------
+
+See [[problems]] page for a list of problems (things
+that are done, but work badly) and the [[wishlist]]
+page for new things that would be nice to add.
+
+
+Authors
+-------
+
+Currently being developed by Lars Wirzenius (liw@liw.fi).
+Help is quite welcome.
+
+
+See also
+--------
+
+Lars's [blog entries](http://blog.liw.fi/tag/dimbola/) about Dimbola.
diff --git a/dimbola.com/local.css b/dimbola.com/local.css
new file mode 100644
index 0000000..79a8bf6
--- /dev/null
+++ b/dimbola.com/local.css
@@ -0,0 +1 @@
+@import "http://files.liw.fi/ikiwiki-theme/local.css";
diff --git a/dimbola.com/problems.mdwn b/dimbola.com/problems.mdwn
new file mode 100644
index 0000000..f796cff
--- /dev/null
+++ b/dimbola.com/problems.mdwn
@@ -0,0 +1,71 @@
+This page lists **problems** (or bugs): things that go wrong,
+in the parts that have been implemented already. For example,
+a failure to rotate an image would belong on this page.
+See also [[wishlist]] page.
+
+Things that must be fixed before the next release
+-------------------------------------------------
+
+* Nothing right now.
+
+Fixed in bzr, but not yet in a release
+--------------------------------------
+
+* dimbola-gtk needs a manual page.
+* Drag-and-dropping tags from tree should be possible from tag name,
+ currently it just starts editing immediately.
+* If dcraw is not installed, the error message is quite mysterious,
+ and should instead say something sensible like dcraw not being
+ installed. Requiring it to be installed is perhaps not entirely
+ warranted, in case people only want to use JPEGs or PNGs or
+ other formats that GdkPixbuf supports natively.
+ - catch OSError when running external programs with subprocess
+ and report error to user in a clear fashion
+ - demote dcraw dependency in .deb to a recommendation
+ - have dcraw type caching silently handle the case of there not
+ being dcraw installed by return "does not support" for everything
+* Folders are not sorted by name in the folder tree.
+* Photo's tags not sorted case-insensitively.
+
+Current
+-------
+
+* Does not build a working .deb under Debian (works under Ubuntu).
+ **Help needed.**
+* Cannot drag-and-drop tags to image preview.
+* Can't enter รค into tag name. This is perhaps an indication that unicode
+ is not enforced properly in the database layer.
+* When changing folder, selection should be reset in grid, and
+ photo's info should be updated accordingly.
+* Grid's scrollbar does not seem to be set correctly when
+ changing to a folder.
+* Is not internationalized (i18n) and therefore not localized (l10n).
+ **Help needed.**
+* Database schema should be more systematic about how tables are named.
+ photos and folders are main tables, but tags just references two other
+ tables. (Suggested by jiivonen)
+* Database should build indexes for things to keep things speed. Exactly
+ what needs indexing should be determined by benchmarks. (Suggested by
+ jiivonen)
+
+
+Bugs in other people's code that Dimbola users may hit upon
+-----------------------------------------------------------
+
+* When exporting, can't select the directory that opens as default.
+ See <https://bugzilla.gnome.org/show_bug.cgi?id=557689>.
+ Workaround: go to parent directly, and select the desired directory
+ there.
+
+
+Stuff to fix some time later in the future
+-------------------------------------------
+
+* Database schema should use FOREIGN KEYs. Can't be fixed yet, since
+ it requires Sqlite 3.6.19 and Ubuntu karmic only has 3.6.16.
+ (Suggested by jiivonen)
+
+Help for Dimbola:
+-------------------------------------------
+* [Overview of dimbola](https://launchpad.net/dimbola)
+* [Bazaar branches of dimbola](https://code.launchpad.net/dimbola)
diff --git a/dimbola.com/releasing.mdwn b/dimbola.com/releasing.mdwn
new file mode 100644
index 0000000..3766355
--- /dev/null
+++ b/dimbola.com/releasing.mdwn
@@ -0,0 +1,32 @@
+This is a checklist of making a Dimbola release.
+
+1. Branch trunk into a temporary branch for making
+ release preparations:
+
+ bzr branch trunk release-prep
+1. Make sure `make clean check` passes.
+1. Update `NEWS`.
+1. Update `debian/changelog`.
+1. Run through the [[testing]] manuscript. If there
+ are any serious bugs, abort release process.
+1. Increase version number in `dimbola/__init__.py`.
+1. Build `.deb` package, install it, and test it.
+
+ rm -rf ../build-area && bzr bd
+ sudo dpkg -i ../build-area/*.deb
+1. Make sure Help/News is up to date when
+ running the newly installed package.
+1. Upload `.deb` package to main upload target.
+1. Merge temporary branch to trunk.
+1. Tag:
+
+ bzr tag version_x_y_z
+1. Push changes to trunk to master location.
+1. For each other Debian-like upload target,
+ make a new temporary branch, update
+ debian/changelog with a very short entry
+ marked for the upload target, build package,
+ and upload. (You may have to fix building
+ on anything than Ubuntu first, though.)
+1. Make a new screenshot and put it on the
+ front page.
diff --git a/dimbola.com/roadmap.mdwn b/dimbola.com/roadmap.mdwn
new file mode 100644
index 0000000..e06944f
--- /dev/null
+++ b/dimbola.com/roadmap.mdwn
@@ -0,0 +1,56 @@
+Roadmap for Dimbola
+===================
+
+
+Dimbola 1.0
+-----------
+
+This is the feature set that 1.0 will hopefully have.
+Since Dimbola is a hobby project, changes are always
+possible.
+
+* Copy photos from camera/memory card to hard disk. (dimbola-copy is
+ a command line program that most of this, but needs to be
+ integrated into the GUI.)
+ * Optionally rename files.
+ * Optionally copy files to two locations.
+ * Also add to currently open database.
+* Add photos to database from hard disk.
+ * Individual, hand-picked images. **Done.**
+ * Recursively for a whole directory tree.
+* Tag, rate, rotate photos efficiently.
+ * Tags can be created. **Done.**
+ * Tags can be arranged in a hierarchy. **Done.**
+ * One or more tags can be added to one or more photos in one operation.
+ * Tags can be added to photos when they're exported.
+ * Tag lists can be exported and imported (merged to current list),
+ so people can share them.
+ * Tags have flags for controlling export to photos, sharing.
+* Import and display EXIF fields.
+ * Display all fields.
+ * Display user-specified selection of fields.
+ * Display a basic, pre-configured set of fields. **Done.**
+* Display/edit all IPTC fields.
+ * Display all fields.
+ * Display user-specified selection of fields.
+ * Display a basic, pre-configured set of fields.
+* Search photos based on various criteria.
+ * Which folder they're in. **Done.**
+ * Tags they have. **Done.**
+ * Rating. **Done.**
+ * EXIF fields.
+ * IPTC fields.
+ * Searches can be saved.
+ * Search results (saved or not) are updated automatically when something changes.
+* Virtual folders ("collections" in Lightroom).
+* View photos in another window, which can be made fullscreen (perhaps on
+ a second monitor). **Done.**
+* Export original or JPEG versions of photos. **Done.**
+ * Exported files contain or are accompanied by metadata.
+* Generate a simple HTML gallery from selected photos.
+* Databases can be synchronized between different computers.
+ * For example, between desktop and laptop (sync everything), or
+ two collaborating people (sync just some stuff).
+ * Two-way synchronization: changes to either end will be combined to
+ the other, without overwriting the other's changes.
+* Everything that sensibly can, can be done with only mouse or keyboard.
diff --git a/dimbola.com/testing.mdwn b/dimbola.com/testing.mdwn
new file mode 100644
index 0000000..811f728
--- /dev/null
+++ b/dimbola.com/testing.mdwn
@@ -0,0 +1,95 @@
+Manual testing of Dimbola
+=========================
+
+**Note: This version is for the upcoming 0.0.2 release.**
+
+Dimbola has an automatic unit test suite with decent coverage. It does
+not test everything, however, and particularly the UI parts of the code
+are untested. Thus, for a Dimbola release, the following manual test
+manuscript is supposed to be followed. It also does not cover everything,
+but ensures at least that the basics work. As Dimbola evolves, this test
+manuscript will evolve with it, to match the new or changed stuff in a
+new release.
+
+
+Update test manuscript for new release
+--------------------------------------
+
+If you are testing in preparation for a new release, first update
+this test manuscript to match the upcoming release.
+
+
+Setup
+-----
+
+Get the Dimbola test images from
+<http://files.liw.fi/dimbola-test-images.tar> and unpack them
+in a location that is suitable for you.
+
+If you have a default.dimbola file, please rename it for the
+duration of this test manuscript, or run dimbola with
+a filename for a new database file.
+
+Start Dimbola, either from the command line or the GNOME menus.
+Verify that there are no folders and no tags.
+
+
+Importing
+---------
+
+Import all photos in dimbola-test-images (which you unpacked
+in the setup section).
+
+Verify that all folders exist and images have a thumbnail, and can
+be viewed in full size.
+
+
+Browsing
+--------
+
+Select some photos. Verify that they have relevant EXIF data
+which looks correct or at least does not not look obviously
+incorrect.
+
+
+Exporting
+---------
+
+Select some photos and export them twice: once as the original
+files, and once as JPEGs. Export each to a different (preferably
+new) folder.
+
+Verify that the exported files look correct by opening them in
+a suitable image viewer.
+
+
+Tagging
+-------
+
+Create some tags. Arrange them in a hierarchy by dragging and dropping
+them.
+
+Add tags to photos by dragging and dropping to them, and to the
+currently selected photos' tag list. Select several tags, and
+several photos, and drag the tags to the photos. Verify all
+photos got all tags.
+
+Remove tags from the photos' tag list using the popup menu.
+
+Remove tags from the global list. Also remove a tag that apply to
+some photos, and verify they get removed from the photos as well.
+
+
+Rating
+------
+
+Select photos and add ratings to them. Then change the ratings.
+Verify that they ratings stick if you select some other photo and
+then go back.
+
+
+Searching
+---------
+
+With some photos and tags and ratings in the database, search for
+them with various criteria. Verify that you find the right ones.
diff --git a/dimbola.com/wishlist.mdwn b/dimbola.com/wishlist.mdwn
new file mode 100644
index 0000000..7567480
--- /dev/null
+++ b/dimbola.com/wishlist.mdwn
@@ -0,0 +1,153 @@
+[[!meta title="wishlist for Dimbola"]]
+
+This page lists wishes for new stuff to add to Dimbola.
+See also [[problems]] page.
+
+Done in bzr, but not in a release yet
+-------------------------------------
+
+* Allow user to remove photos from database and optionally from disk as well.
+
+
+Planned for the next release
+----------------------------
+
+* Dimbola should write (and rotate) a log file that could be added
+ to bug reports easily.
+* Add date of import. This could be used for an iPhoto-like "list of
+ imports" view. Perhaps a "import id" as well?
+
+
+Website
+-------
+
+* Website wants to be loved by a web designer. **Help needed.**
+* Make a feedback form for each release, using ikiwiki polling features.
+
+
+Importing photos
+----------------
+
+* Importing should detect duplicate photos and optionally not add them.
+* Maybe importing should add photos really fast, and then add thumbnails,
+ previews in the background?
+* When importing images to database, the folders with the new images
+ should perhaps be selected so the images are visible immediately.
+* Import should optionally be recursive: everything in subdirectories
+ should be imported as well.
+* Add all exif fields into database, not just selected ones. Let user see a predefined set,
+ all of them, or a user-defined set (multiple user-defined sets?). Add
+ way for user to re-scan all files for Exif headers (useful for then (py)exiv2
+ gets new features?).
+
+Thumbnail grid
+--------------
+
+* Thumbnails should have some basic metadata displayed: stars for ratings,
+ at the very least.
+* Thumbnail grid should allow selecting by painting with the mouse.
+* Thumbnail grid shows images in semi-random order (photoid). Order by mtime, if nothing else.
+ Ideally user should be able to choose from different orderings, such as
+ Exif timestamp, camera photo serial number, or file mtime.
+
+Tag and tag management
+----------------------
+
+* Tag tree should allow showing all tags (expand all trees), only top level
+ tags (close all trees), or close everything except a single tree.
+* Photo's tag list: should let user select whether to show only tags
+ explicitly added, or also their parents (and aliases if those are added).
+* Should be able to add add multiple tags to multiple photos at a time with drag-and-drop.
+
+Exporting photos
+----------------
+
+* Add tags, exif to exported photos (jpegs, originals).
+
+Editing with the GIMP
+---------------------
+
+* When editing with gimp, can we export a .xcf? Or have gimp do raw conversion?
+
+Code reactoring
+---------------
+
+* When grid.photoids gets set, it should automatically trigger loading of thumbnails
+ from database, and also shouldn't forget thumbnails that are in the new value of
+ photoids.
+* remove_from_menu should be accompanied by method that just removes all menu entries
+ added by that plugin.
+
+New features
+------------
+
+* Preview sizes should perhaps be user-configurable.
+* Searching based on Exif headers would be good. ("Find all photos taken
+ with this camera and that lense on this time of day.")
+* It should be easy to copy tags from one photo to another.
+* Folders, tags (in tag tree), and grid should display photo counts.
+* Remember some settings across restarts, such as size of thumbnails.
+* Undo/redo: adding, renaming, removing tags in tag tree; adding, removing
+ tags to photos; rotate; ratings; ...
+* Tags should perhaps have aliases, flags (export with photo, export when
+ sharing tag lists). See also <http://blog.liw.fi/posts/dimbola-tag-system/>.
+* Search tags in tag tree. Add a search box at the top.
+* Should handle the case when photos move in the filesystem: have a way to
+ search for and reconnect with photos after they've moved. (Might happen
+ 'cause they're on an external disk and the mount point changes.) Store
+ checksum, size of original files and use that to find the photos if they
+ move.
+* Would be nice to handle offline images properly: indicate when they're
+ not there, and warn (not crash) when doing things that would require
+ them.
+* Would be nice to have an icon for Dimbola. **Help wanted.**
+* Add the functionality of dimbola-copy to dimbola-gtk.
+* A performance stress tester for the database would be good, so we know
+ how big a photo set it can handle.
+* Support IPTC tags.
+* Make it possible to have "presets" of tags and other metadata that get
+ added to images when they're imported, or later.
+* Background jobs should estimate remaining time.
+* Smart collections or saved searches, a la Lightroom.
+* Collections or manually created virtual folders, a la Lightroom.
+* Should use unicode only in the database, and not allow any other kind
+ of text.
+* Synchronization between Dimbola databases would be really cool.
+* Add support for arbitrary key/value pairs as metadata?
+* Dimbola should have File/New, File/Open, File/Close. Not File/Save, since
+ all changes are saved immediately and implicitly, but perhaps
+ File/Save as.
+* Need to add i18n and l10n.
+* .dimbola files need a MIME type of their own, so double-clicking on them
+ will open them up in Dimbola without hassle. But not all Sqlite files!
+* Verify checksums of photos, at opportune moments. Also, a background job to
+ verify checksums of selected photos, or all photos.
+* Export metadata to photos (not doable for RAW images, mostly) or XMP files.
+ Either manually, or automatically for each change.
+* Large thumbnail or small preview of the selected photo, a la Lightroom.
+* Film strip to show all photos on the grid, for non-grid views to show context.
+* View two photos at a time to compare them.
+* Survey mode: show all selected photos, then allow user to deselect
+ and make the remaining thumbs bigger (as big as they can be while
+ showing everything). does this emphasize "find faults" rather than "find good stuff"?
+ (see [Carl Weese on editing](http://theonlinephotographer.typepad.com/the_online_photographer/2009/10/editing-a-large-set-of-digital-captures.html)
+ and [followup](http://theonlinephotographer.typepad.com/the_online_photographer/2009/10/zen-slap.html),
+ both at [The Online Photographer blog](http://theonlinephotographer.com/))
+* Make a PDF, video file, and web slideshow of selected photos.
+* A "previous import" collection, or perhaps a way to show all imports as folders.
+* When no photo is selected, say so in the photo info and other sidebar sections, rather
+ than just making them insensitive. This would provide a hint to the user what the
+ reason for the insensitivity is.
+* Do some database stress testing: add logging of what requests are done to the db.py module,
+ then make a dummy database that is very large, and do the requests on that many times.
+ Does it work? What's slow? Memory usage?
+
+Definitely not until after 1.0
+------------------------------
+
+* Some RAW processing support would be good. (Set parameters for dcraw
+ when it is run.)
+* Support geo stuff: add location tags from GPS traces (match on timestamps),
+ find by location, perhaps show and search on maps.
+* A plugin to export selected photos as a PDF formatted for printing as a
+ book would be nice.
diff --git a/trunk/.bzr-builddeb/default.conf b/trunk/.bzr-builddeb/default.conf
new file mode 100644
index 0000000..be0e4c1
--- /dev/null
+++ b/trunk/.bzr-builddeb/default.conf
@@ -0,0 +1,3 @@
+[BUILDDEB]
+native = True
+
diff --git a/trunk/.bzrignore b/trunk/.bzrignore
new file mode 100644
index 0000000..a187e50
--- /dev/null
+++ b/trunk/.bzrignore
@@ -0,0 +1,2 @@
+default.dimbola
+dimbola/NEWS.html
diff --git a/trunk/.coverage b/trunk/.coverage
new file mode 100644
index 0000000..7a1752d
--- /dev/null
+++ b/trunk/.coverage
Binary files differ
diff --git a/trunk/HACKING b/trunk/HACKING
new file mode 100644
index 0000000..cafab29
--- /dev/null
+++ b/trunk/HACKING
@@ -0,0 +1,173 @@
+HACKING Dimbola for fun and very little profit
+==============================================
+
+
+Introduction
+------------
+
+This file has some notes about how Dimbola should be developed.
+
+All the generally accepted good Python coding stuff applies: PEP8
+formatting, using only spaces and no tabs for indentation, etc. We
+cover here only things specific to the Dimbola project.
+
+
+Overview of the code base
+-------------------------
+
+* dimbola-gtk -- the main program; this is intended to be a one-liner
+
+* dimbola/*.py -- Python package with the core of the app
+
+* dimbola/plugins/*_plugin.py -- plugins that come with the core app;
+ as much as possible is put into plugins
+
+* dimbola/ui.ui and dimbola/plugins/*.ui -- glade/GtkBuilder files; these
+ are loaded automatically
+
+* dimbola/*_tests.py and dimbola/plugins/*_tests.py -- unit tests for
+ the corresponding files
+
+
+Development priorities
+----------------------
+
+The main priority should always be to fix bugs. Each release should
+fix all bugs, if at all possible. It is obvious that this is not
+always possible, but it is the goal anyway. Better to attempt something
+really good and almost get it than to play it safe.
+
+
+Version numbering
+-----------------
+
+The canonical location of the version number is in dimbola/__init__.py
+as the version variable. setup.py will extract it from there. ui.py will
+use dimbola.version to set the version number in the Help/About dialog
+box.
+
+Version number until 1.0 will be as follows:
+
+ 0.0.x pre-alpha versions, not intended to be usable at all
+ 0.x.0 alpha and beta versions; these are intended to be usable,
+ though they are probably really buggy
+ 1.0.0 first release intended to be usable by a non-hacker
+
+Eventually, a ROADMAP file will be written to specify what 1.0.0 should
+contain.
+
+
+Writing plugins
+---------------
+
+To write a plugin, import the dimbola package, and subclass dimbola.Plugin.
+The initializer MUST have the following signature:
+
+ def __init__(self, mwc):
+
+To use the plugin, put it in a file in dimbola/plugins/foo_plugin.py.
+
+Plugins may access the MainWindowController's attributes and methods.
+
+When a Plugin subclass is instantiated, it MUST NOT cause any side
+effects: it must not modify the user interface, connect to signals,
+or whatever. All of that must be done in the enable() method, and
+un-done in the disable() method. This is necessary so that the
+user may enable and disable any installed plugin.
+
+Plugins may add new items to menus via MWC.add_to_menu. The UI file
+needs to specify a name for the menu widget, and this name is used
+with add_to_menu. Additionally, the menu may have separators whose
+names begin with prepend_separator or append_separator, and add_to_menu
+will put the new menu item before or after such a separator.
+
+To remove the menu item (when the plugin is disabled), the
+MWC.remove_from_menu method may be used.
+
+Similarily for sidebar sections: see MWC.add_to_sidebar and
+MWC.remove_from_sidebar.
+
+Plugins may have their own user interface definition files. For
+a plugin named foo_plugin.py, the file foo.ui is loaded automatically,
+if it exists. The plugin may access the widgets via
+MainWindowController.widgets.
+
+
+Signals and hooks
+-----------------
+
+We try use GObject signals for communicating between parts of
+the code. This decouples different modules, which is a good thing.
+For example, the thumbnail grid is implemented using
+model/view/controller. When the list of photos in the model
+changes, the view must redraw itself. Rather than having the model
+call a method on the view, the view connects to a signal in the
+model. Thus, the model need not know anything about the view.
+Indeed, other things than the view can make use of the same
+signal.
+
+In order to connect to a signal, you need to know the object the
+signal applies to. This is awkward when a plugin provides the
+object and the signal. We work around this by adding the signal
+to a well-known object, the MainWindowController, using the
+new_hook method.
+
+Thus, in Dimbola, a hook is a custom signal added to MWC.
+
+
+Unit tests and coverage
+-----------------------
+
+The goal is to make Dimbola a very good program. Part of that is to make
+it very reliable, and the way to get there is to have an automated unit test
+suite with very good coverage.
+
+Currently, there is abysmal coverage. The current code was written in haste,
+to get a proof-of-concept prototype done very quickly. Writing tests would
+have slowed things down.
+
+The strategy to get to good coverage is as follows: all of the current
+GUI code exists in dimbola/db.py and dimbola/ui.py. db.py may get some
+unit tests later on, but ui.py is going to be test-less. However, as
+much code as possible will be broken out of it, into other modules, and
+those modules will have unit tests. The goal is to have those other
+modules have 100% statement coverage.
+
+Almost all the code will be called in response to GTK+ events and signals,
+from callback functions. The general design principle here, as far as
+testing is concerned, is that the actual callback function will be not
+be tested, but it will be as simple as possible and call functions
+elsewhere to do the actual work. Those functions will be unit tested.
+
+
+Using Glade
+-----------
+
+The UI is defined using Glade and GtkBuilder. Everything that can
+reasonably be done in Glade shall be done in Glade.
+
+Widgets in the .glade file and their signal callbacks will be
+connected automatically based on a naming convention. For a widget
+named foo, and its signal named bar, a callback will be connected
+automatically if the code has a method called on_foo_bar in a relevant
+controller object.
+
+This means the .glade file should not have any signal handlers defined.
+
+Widgets that do not have callbacks can use whatever name Glade gives
+them by default (e.g., "textview123"). If a callback is needed, the
+widget should be renamed in a sensible way that indicates its use
+(e.g., "photo_tags_textview").
+
+See dimbola/gtkapp.py for details on the magic.
+
+
+Background processing
+---------------------
+
+Threads are evil, at least in the context of Python and GTK+. We do not
+use (explicit) threads in Dimbola. Instead, we use glib.idle_add and
+the Python standard library module multiprocessing. The latter is wrapped
+inside the BackgroundManager and BackgroundJob classes; see the
+dimbola/bgjobs.py module.
+
diff --git a/trunk/Makefile b/trunk/Makefile
new file mode 100644
index 0000000..e95414a
--- /dev/null
+++ b/trunk/Makefile
@@ -0,0 +1,20 @@
+DOMAIN=dimbola
+DESKTOP_FILES := dimbola.desktop
+
+all: dimbola/NEWS.html dimbola.desktop
+
+dimbola.desktop: dimbola.desktop.in po/$(DOMAIN).pot
+ intltool-merge -d po $< $@
+
+dimbola/NEWS.html: NEWS
+ (sed '/%%%MARKDOWN%%%/,$$d' NEWS.template; \
+ markdown NEWS; \
+ sed '1,/%%%MARKDOWN%%%/d' NEWS.template) > dimbola/NEWS.html
+
+check:
+ python -m CoverageTestRunner --ignore-missing-from no-unit-tests.txt
+ rm -f .coverage
+
+clean:
+ rm -f dimbola/*.pyc .coverage dimbola/NEWS.html dimbola.desktop
+
diff --git a/trunk/Makefile.~1~ b/trunk/Makefile.~1~
new file mode 100644
index 0000000..2f826ca
--- /dev/null
+++ b/trunk/Makefile.~1~
@@ -0,0 +1,20 @@
+DOMAIN=dimbola
+DESKTOP_FILES := dimbola.desktop
+
+all: dimbola/NEWS.html dimbola.desktop
+
+dimbola.desktop: dimbola.desktop.in po/$(DOMAIN).pot
+ intltool-merge -d po $< $@
+
+dimbola/NEWS.html: NEWS
+ (sed '/%%%MARKDOWN%%%/,$$d' NEWS.template; \
+ markdown NEWS; \
+ sed '1,/%%%MARKDOWN%%%/d' NEWS.template) > dimbola/NEWS.html
+
+check:
+ python2.6 -m CoverageTestRunner --ignore-missing-from no-unit-tests.txt
+ rm -f .coverage
+
+clean:
+ rm -f dimbola/*.pyc .coverage dimbola/NEWS.html dimbola.desktop
+
diff --git a/trunk/NEWS b/trunk/NEWS
new file mode 100644
index 0000000..827c844
--- /dev/null
+++ b/trunk/NEWS
@@ -0,0 +1,181 @@
+NEWS about Dimbola
+==================
+
+Dimbola is a photo management application.
+
+
+Version 0.0.3, 2009-10-24
+-------------------------
+
+NEW STUFF
+
+* Ratings are now shown as actual stars rather than asterisk
+ characters (*).
+* Tag name editing now happens in the tag hierarchy, rather than
+ in a popup dialog.
+* Photos can be rated with the 0-5 keys in the thumbnail grid and photo
+ views.
+* Default thumbnail size in the grid is now 200 pixels, because liw got
+ tired of having to adjust it every time he started Dimbola.
+* The Escape key returns from full screen mode in the photo window.
+* Control-W closes the photo window.
+
+CHANGED STUFF
+
+* The thumbnail grid and photo views are now switched via the menu.
+ It was too difficult to find a solution for switching between the
+ them in the tabbed interface, without the tabs occasionally getting
+ focus and making things non-deterministic for the user.
+* Netpbm command line tools are no longer used to do image format
+ conversions. Instead, the internal GdkPixbuf routines do that.
+
+PROBLEMS FIXED
+
+* When adding new tags, the tag hierarchy is kept sorted.
+* When tags are renamed or removed, the updates are immediately shown in the
+ photo's tag list, and the search tag list.
+* Photo's tag list is now insensitive when no photo or more than one photo
+ is selected. It can only be used with one selected photo.
+* When bringing up the popup menu in the photo or search tag list, if
+ no tag is selected, the tag under the cursor is selected. The menu's
+ "remove" menu entry is properly sensitive.
+* The "stop" button at the bottom left of the main window is now properly
+ sensitive, and actually does stop background jobs.
+* The "remove tag" button ("-") at the top of the tag hierarchy is properly
+ sensitive.
+* Folder list can now be scrolled.
+* Dialog windows are transient for the main window. This helps the window
+ manager position them properly.
+* Thumbnail grid and photo view have a visual focus indicator and grab the
+ focus when clicked upon.
+
+CODE STUFF
+
+
+Version 0.0.2, 2009-10-11
+-------------------------
+
+This release makes further improvements to code quality, and has
+few user-visible improvements.
+
+NEW STUFF
+
+* Application starts up in maximized mode.
+
+* Tags can now be arranged in a hierarchy, and tags can renamed.
+
+* Folders are now a hierachy as well, the way they are on disk.
+
+* All image file formats supported by dcraw (RAW images) and
+ the installed GTK+ library (other images) are now supported.
+ The import file chooser lets the user choose whether to look
+ at all files, all image files, or just RAW image files.
+
+PROBLEMS FIXED
+
+* Added a build-dependency on markdown. Thanks to Timo Jyrinki.
+
+* Help/News now actually works on the `.deb` package.
+
+* When a tag is removed, the reference to it is also removed from
+ the photos that had it. Previously, if one removed the most recently
+ created tag, then added a new tag, all the photos with the removed
+ tag would get the new tag. Quite confusing.
+
+CODE STUFF
+
+* Plugins may define order of menu entries and sidebar sections they
+ add, using "weights".
+
+* "make check" now assumes a version of CoverageTestRunner that supports
+ --ignore-missing-from. It's available from me.
+
+* All code modules either have a unit test module, or are listed in
+ no-unit-test.txt. CoverageTestRunner will complain if it finds
+ things to be otherwise. Further, all code modules with unit tests
+ have 100% statement coverage, excluding parts of code that are
+ marked explicitly as being outside of coverage testing.
+
+* The thumbnail grid module has been rewritten to be a cleaner
+ model/view/controller design, and to have tests.
+
+* Various changes have been made to allow different parts of the code
+ to communicate via signals (see MainWindowController.new_hook, for
+ example), rather than direct method calls. This work is incomplete,
+ and will continue in the future.
+
+* Added Plugin.enable_signal and .disable_signal methods, for easier
+ management of connected signals in plugins.
+
+* All plugins with UI elements now have their own .ui file
+ (foo_plugin.py has foo.ui). This removes further coupling of the
+ core parts (ui.py, grid.py) and the plugins.
+
+* The selected photo's tag list and the search tag list now share code.
+ (See dimbola/taglist.py.)
+
+* Various updates to the HACKING file.
+
+* Now supports Python 2.5, and therefore Debian unstable (sid).
+
+
+Version 0.0.1, 2009-09-24
+-------------------------
+
+This release aims primarily to improve the code so that it is
+easier and more reliable to add new features later. Right now,
+the user interface is a bit of a mess (things are in random
+order in menus or the sidebars), but this will be fixed soon.
+
+BUG FIXES
+
+* Debian packaging now declares a dependency on Python 2.6.
+
+* Canon RAW files are recognized even when the filename suffix is in
+ lower case.
+
+* Menu entries are now made sensitive or insensitive based on whether
+ the action is doable or not.
+
+* The thumbnail grid's vertical scroll bar now works correctly. Previously
+ the limits were wrong and it would allow scrolling past the end of the
+ grid.
+
+* Clicking outside of any thumbnails in the grid now does the right thing,
+ rather than causing a runtime error.
+
+* The side bars now should no longer change side automatically. It was
+ quite disturbing at times.
+
+* Display of EXIF headers is fixed. 77/8 was a very weird shutter speed.
+
+* All windows now have minimally sensible titles.
+
+* The separate photo viewer window can now get keyboard focus.
+
+
+NEW FEATURES
+
+* This NEWS file is included in the package, and accessible via the
+ Help menu.
+
+* New option --version.
+
+
+DEVELOPER STUFF
+
+* There is now a plugin mechanism. The API is not very rich yet, but
+ as much core functionality as possible will be implemented with
+ plugins, too, to ensure the API will be useful for many people.
+
+* A HACKING file now documents stuff to get people up to speed with
+ doing stuff for Dimbola. In particular, it has some info on how to
+ write plugins.
+
+
+Version 0.0.0, 2009-09-17
+-------------------------
+
+* First release. This is not useful yet, but I need to make a release
+ so that I can make .deb packages.
+
diff --git a/trunk/NEWS.template b/trunk/NEWS.template
new file mode 100644
index 0000000..aa9651f
--- /dev/null
+++ b/trunk/NEWS.template
@@ -0,0 +1,18 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+<title>Dimbola NEWS</title>
+
+<style>
+ body { max-width: 60em; }
+</style>
+
+</head>
+<body>
+
+%%%MARKDOWN%%%
+
+</body>
+</html>
diff --git a/trunk/README b/trunk/README
new file mode 100644
index 0000000..3a90569
--- /dev/null
+++ b/trunk/README
@@ -0,0 +1,30 @@
+README for Dimbola
+==================
+
+Dimbola is a toy project for software tools for photography.
+it is currently in the pre-alpha stage: some stuff works, but it is not
+really usable yet even for its developers.
+
+To run dimbola from the source tree:
+
+ ./dimbola-gtk
+
+You'll need at least the following installed (Ubuntu karmic packages
+names in parentheses): python 2.6 (python), PyGTK (python-gtk2),
+pyexiv2 (python-pyexiv2), dcraw (dcraw), netpbm (netpbm). Versions
+in Ubuntu jaunty should also work.
+
+The program currently only accepts Canon RAW files (*.CR2). A small
+selection of samples available at
+
+ http://files.liw.fi/dimbola-test-images.tar
+
+(additions are welcome).
+
+See https://launchpad.net/dimbola for more details.
+
+The name is a reference to Dimbola Lodge, the residence of Victorian
+pioneer photographer Julia Margaret Cameron. See
+http://en.wikipedia.org/wiki/Dimbola_Lodge for more information.
+Steve Langasek came up with the name for the project.
+
diff --git a/trunk/debian/changelog b/trunk/debian/changelog
new file mode 100644
index 0000000..d9be639
--- /dev/null
+++ b/trunk/debian/changelog
@@ -0,0 +1,43 @@
+dimbola (0.0.4) UNRELEASED; urgency=low
+
+ * debian/control: Dependency on dcraw demoted to recommendation,
+ since Dimbola does not absolutely have to have dcraw installed.
+
+ -- Lars Wirzenius <liw@liw.fi> Wed, 25 Nov 2009 21:47:17 +0200
+
+dimbola (0.0.3) karmic; urgency=low
+
+ * New upstream version.
+ * debian/control: No longer need to depend on netpbm.
+ * debian/control: Build-Depends on python 2.5, not 2.6, to allow
+ building on Debian sid.
+
+ -- Lars Wirzenius <liw@liw.fi> Sat, 24 Oct 2009 14:27:00 +0300
+
+dimbola (0.0.2) karmic; urgency=low
+
+ * New upstream version.
+
+ -- Lars Wirzenius <liw@liw.fi> Sun, 11 Oct 2009 18:00:17 +0300
+
+dimbola (0.0.1debian1) karmic; urgency=low
+
+ * Changes for getting Dimbola work on Debian sid.
+ * Depend on python-multiprocessing and python 2.5, or python 2.6.
+ * Added "from __future__ import with_statement" to every module
+ that uses the with statement.
+ * Removed unnecessary import of fractions module.
+
+ -- Lars Wirzenius <liw@liw.fi> Thu, 24 Sep 2009 19:47:43 +0300
+
+dimbola (0.0.1) karmic; urgency=low
+
+ * New upstream version.
+
+ -- Lars Wirzenius <liw@liw.fi> Thu, 24 Sep 2009 19:47:43 +0300
+
+dimbola (0.0.0) karmic; urgency=low
+
+ * Initial packaging. Note that this version probably won't work.
+
+ -- Lars Wirzenius <liw@liw.fi> Thu, 17 Sep 2009 23:12:44 +0300
diff --git a/trunk/debian/compat b/trunk/debian/compat
new file mode 100644
index 0000000..7f8f011
--- /dev/null
+++ b/trunk/debian/compat
@@ -0,0 +1 @@
+7
diff --git a/trunk/debian/control b/trunk/debian/control
new file mode 100644
index 0000000..2094747
--- /dev/null
+++ b/trunk/debian/control
@@ -0,0 +1,22 @@
+Source: dimbola
+Maintainer: Lars Wirzenius <liw@liw.fi>
+Section: graphics
+Priority: optional
+Standards-Version: 3.8.2
+Build-Depends: debhelper (>= 7.3.8), python-support (>= 1.0.3),
+ python (>= 2.5), markdown
+XS-Python-Version: >= 2.6
+Vcs-Bzr: http://bazaar.launchpad.net/%7Edimbola-team/dimbola/trunk/
+Homepage: https://launchpad.net/dimbola
+
+Package: dimbola
+Architecture: all
+Depends: ${python:Depends}, ${misc:Depends}, python-gtk2,
+ python-gnome2, python-pyexiv2, python (>= 2.5),
+ python-multiprocessing | python (>= 2.6)
+Recommends: dcraw
+XB-Python-Version: ${python:Versions}
+Description: manage digital photograph collection
+ A photographer's tool for managing a collection of digital photographs,
+ primarly in the RAW format.
+
diff --git a/trunk/debian/control.~1~ b/trunk/debian/control.~1~
new file mode 100644
index 0000000..51dad1a
--- /dev/null
+++ b/trunk/debian/control.~1~
@@ -0,0 +1,22 @@
+Source: dimbola
+Maintainer: Lars Wirzenius <liw@liw.fi>
+Section: graphics
+Priority: optional
+Standards-Version: 3.8.2
+Build-Depends: debhelper (>= 7.3.8), python-support (>= 1.0.3),
+ python (>= 2.5), markdown
+XS-Python-Version: >= 2.6
+Vcs-Bzr: http://bazaar.launchpad.net/%7Edimbola-team/dimbola/trunk/
+Homepage: https://launchpad.net/dimbola
+
+Package: dimbola
+Architecture: all
+Depends: ${python:Depends}, ${misc:Depends}, python-gtk2,
+ python-gnome2, python-pyexiv2, python (>= 2.5),
+ python-multiprocessing | python (>= 2.6), python-pyexiv2
+Recommends: dcraw
+XB-Python-Version: ${python:Versions}
+Description: manage digital photograph collection
+ A photographer's tool for managing a collection of digital photographs,
+ primarly in the RAW format.
+
diff --git a/trunk/debian/copyright b/trunk/debian/copyright
new file mode 100644
index 0000000..98eeba5
--- /dev/null
+++ b/trunk/debian/copyright
@@ -0,0 +1,23 @@
+Dimbola was originally written Lars Wirzenius <liw@liw.fi>.
+
+All of the code is currently owned by Lars Wirzenius and licensed under
+the GNU GPL version 3, or later version.
+
+ 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/>.
+
+On a Debian system, you can find a copy of the GPL version 3 in
+/usr/share/common-licenses/GPL-3.
+
diff --git a/trunk/debian/dimbola.docs b/trunk/debian/dimbola.docs
new file mode 100644
index 0000000..9eafbe1
--- /dev/null
+++ b/trunk/debian/dimbola.docs
@@ -0,0 +1,2 @@
+README
+NEWS
diff --git a/trunk/debian/pycompat b/trunk/debian/pycompat
new file mode 100644
index 0000000..0cfbf08
--- /dev/null
+++ b/trunk/debian/pycompat
@@ -0,0 +1 @@
+2
diff --git a/trunk/debian/rules b/trunk/debian/rules
new file mode 100755
index 0000000..d0ad1c9
--- /dev/null
+++ b/trunk/debian/rules
@@ -0,0 +1,8 @@
+#!/usr/bin/make -f
+%:
+ dh $@ --buildsystem=python_distutils
+
+override_dh_auto_build:
+ $(MAKE)
+ dh_auto_build --buildsystem=python_distutils
+
diff --git a/trunk/default.dimbola b/trunk/default.dimbola
new file mode 100644
index 0000000..aeda57f
--- /dev/null
+++ b/trunk/default.dimbola
Binary files differ
diff --git a/trunk/dimbola-copy b/trunk/dimbola-copy
new file mode 100755
index 0000000..fffe9dc
--- /dev/null
+++ b/trunk/dimbola-copy
@@ -0,0 +1,5 @@
+#!/usr/bin/python
+
+import dimbola
+dimbola.Copier().run()
+
diff --git a/trunk/dimbola-gtk b/trunk/dimbola-gtk
new file mode 100755
index 0000000..1b25295
--- /dev/null
+++ b/trunk/dimbola-gtk
@@ -0,0 +1,4 @@
+#!/usr/bin/python
+import dimbola
+dimbola.UI().run()
+
diff --git a/trunk/dimbola-gtk.1 b/trunk/dimbola-gtk.1
new file mode 100644
index 0000000..042f486
--- /dev/null
+++ b/trunk/dimbola-gtk.1
@@ -0,0 +1,14 @@
+.TH DIMBOLA 1
+.SH NAME
+dimbola-gtk \- photo management for GNOME
+.SH SYNOPSIS
+.B dimbola-gtk
+.RI [ filename.dimbola "... ]"
+.SH DESCRIPTION
+.B dimbola-gtk
+is a photo management application for GNOME.
+.SH "SEE ALSO"
+.TP
+http://dimbola.org
+The Dimbola home page.
+
diff --git a/trunk/dimbola-info b/trunk/dimbola-info
new file mode 100755
index 0000000..095e470
--- /dev/null
+++ b/trunk/dimbola-info
@@ -0,0 +1,54 @@
+#!/usr/bin/python
+# 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/>.
+
+
+'''Self-standing program to dump information about a Dimbola database.'''
+
+
+import sqlite3
+import sys
+
+
+def dbinfo(filename):
+ conn = sqlite3.connect(filename)
+ c = conn.cursor()
+
+ c.execute('select tbl_name, sql from sqlite_master where type="table"')
+ tbl_names = []
+ for tbl_name, sql in c:
+ tbl_names.append(tbl_name)
+ print sql
+ print
+
+ for tbl_name in tbl_names:
+ print 'table:', tbl_name
+ c.execute('select * from %s' % tbl_name)
+ print 'columns:', ' '.join(t[0] for t in c.description)
+ for row in c:
+ for col in row:
+ print repr(col),
+ print
+ print
+
+
+def main():
+ for filename in sys.argv[1:]:
+ dbinfo(filename)
+
+
+if __name__ == '__main__':
+ main()
+
diff --git a/trunk/dimbola.desktop.in b/trunk/dimbola.desktop.in
new file mode 100644
index 0000000..df0766d
--- /dev/null
+++ b/trunk/dimbola.desktop.in
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Version=1.0
+Type=Application
+_Name=Dimbola
+_GenericName=Photo manager
+_Comment=Manage digital photographs
+TryExec=dimbola-gtk
+Exec=dimbola-gtk %F
+Categories=GNOME;Graphics;
+
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'))
+
diff --git a/trunk/foo.py b/trunk/foo.py
new file mode 100644
index 0000000..b23bea5
--- /dev/null
+++ b/trunk/foo.py
@@ -0,0 +1,11 @@
+import gobject
+import gtk
+
+
+builder = gtk.Builder()
+print dir(builder)
+print builder.add_from_file('dimbola/ui.ui')
+w = builder.get_object('preferences_dialog')
+print w
+print repr(w.get_property('name'))
+print [x for x in builder.props]
diff --git a/trunk/gallery/dimbola-gallery.py b/trunk/gallery/dimbola-gallery.py
new file mode 100644
index 0000000..04a5b6f
--- /dev/null
+++ b/trunk/gallery/dimbola-gallery.py
@@ -0,0 +1,198 @@
+#!/usr/bin/python
+
+import os
+import subprocess
+import sys
+
+import pyexiv2
+
+class DefaultDict(dict):
+
+ def __getitem__(self, key):
+ if key in self:
+ return dict.__getitem__(self, key)
+ else:
+ return ""
+
+class App(object):
+
+ def run(self):
+ self.target_dir = sys.argv[2]
+ self.root = os.path.abspath(sys.argv[1])
+ self.index_template = self.read_template("index")
+ self.image_template = self.read_template("image")
+ self.index_image_template = self.read_template("indeximage")
+ self.index_dir_template = self.read_template("indexdir")
+ for parent, dirname, subdirs, images in self.find_images(self.root):
+ self.generate_html_for_dir(parent, dirname, subdirs, images)
+
+ def read_template(self, basename):
+ return file(basename + ".template").read()
+
+ def find_images(self, root):
+ for dirname, dirnames, filenames in os.walk(root, topdown=False):
+ filenames = [x for x in filenames if self.is_image_file(dirname, x)]
+ dirnames = [x for x in dirnames if self.has_image_files(dirname, x)]
+ if dirnames or filenames:
+ if dirname == root:
+ parent = None
+ else:
+ parent = self.chop_root(root, os.path.dirname(dirname))
+ dirname = self.chop_root(root, dirname)
+ yield parent, dirname, dirnames, filenames
+
+ def chop_root(self, root, pathname):
+ if pathname == root:
+ return "."
+ if not root.endswith(os.sep):
+ root += os.sep
+ if pathname == root:
+ return "."
+ assert pathname.startswith(root), "%s %s" % (pathname, root)
+ return pathname[len(root):]
+
+ def is_image_file(self, dirname, basename):
+ return basename.lower().endswith(".jpg")
+
+ def has_image_files(self, dirname, basename):
+ fullname = os.path.join(dirname, basename)
+ basenames = os.listdir(fullname)
+ for x in basenames:
+ if self.is_image_file(fullname, x):
+ return True
+ return False
+
+ def generate_html_for_dir(self, parent, dirname, subdirs, images):
+ if not os.path.exists(self.target(dirname)):
+ os.makedirs(self.target(dirname))
+ self.generate_html_for_index(parent, dirname, subdirs, images)
+ for i in range(len(images)):
+ image = images[i]
+ prev = images[i-1]
+ next = images[(i+1) % len(images)]
+ self.generate_html_for_image(dirname, image, prev, next)
+
+ def target(self, pathname, *args):
+ return os.path.join(self.target_dir, pathname, *args)
+
+ def generate_html_for_index(self, parent, dirname, subdirs, images):
+ imagelist = self.generate_snippet_for_index_image_list(dirname, images)
+ subdirlist = self.generate_snippet_for_subdir_list(parent, dirname,
+ subdirs)
+ values = {
+ "directorydescription": os.path.abspath(dirname),
+ "indeximagelist": imagelist,
+ "subdirlist": subdirlist,
+ }
+ self.apply_template(dirname, "index.html", self.index_template, values)
+
+ def generate_snippet_for_index_image_list(self, dirname, images):
+ snippet = []
+
+ for image in images:
+ basename, suffix = os.path.splitext(image)
+ target_dirname = self.target(dirname)
+ image_path = os.path.join(self.root, dirname, image)
+ thumb = os.path.basename(self.thumbnail_path(dirname, image))
+ values = {
+ "imagebasename": basename,
+ "thumbnailname": thumb,
+ "imagepathname": self.relative_path(target_dirname, image_path)
+ }
+ snippet.append(self.index_image_template % values)
+
+ return "".join(snippet)
+
+ def generate_snippet_for_subdir_list(self, parent, dirname, subdirs):
+ snippet = []
+
+ if parent:
+ target_dirname = self.target(dirname)
+ target_parent = self.target(parent)
+ relative_parent = self.relative_path(target_dirname, target_parent)
+ subdirs = [relative_parent] + subdirs
+
+ for subdir in subdirs:
+ values = {
+ "subdirname": subdir,
+ }
+ snippet.append(self.index_dir_template % values)
+
+ return "".join(snippet)
+
+ def generate_html_for_image(self, dirname, image, prev, next):
+ basename, suffix = os.path.splitext(image)
+ target_dirname = self.target(dirname)
+ image_path = os.path.join(self.root, dirname, image)
+ prev_basename, suffix = os.path.splitext(os.path.basename(prev))
+ next_basename, suffix = os.path.splitext(os.path.basename(next))
+ values = DefaultDict({
+ "description": basename,
+ "imagepathname": self.relative_path(target_dirname, image_path),
+ "nextimage": next_basename + ".html",
+ "previmage": prev_basename + ".html",
+ })
+ img = pyexiv2.Image(image_path)
+ img.readMetadata()
+ for key in img.exifKeys() + img.iptcKeys():
+ values[key] = img[key]
+ self.apply_template(dirname, basename + ".html", self.image_template,
+ values)
+ self.create_thumbnail(dirname, image)
+
+ def common_ancestor(self, path1, path2):
+ """Find the common ancestor directory of two paths.
+
+ Both paths must be absolute.
+
+ """
+
+ parts1 = path1.split(os.sep)
+ parts2 = path2.split(os.sep)
+ parts = []
+ while parts1 and parts2 and parts1[0] == parts2[0]:
+ parts.append(parts1[0])
+ del parts1[0]
+ del parts2[0]
+ return os.sep.join(parts) + os.sep
+
+ def relative_path(self, dirname, pathname):
+ """Create a relative path from dirname to pathname.
+
+ For example, if dirname is '/home/liw/public_html/gallery' and
+ pathname is '/home/liw/public_html/images/lamola/img_1234.jpg',
+ this function would return '../images/lamola/img_1234.jpg'.
+
+ """
+
+ dirname = os.path.abspath(dirname)
+ pathname = os.path.abspath(pathname)
+ if not dirname.endswith(os.sep):
+ dirname += os.sep
+ common_ancestor = self.common_ancestor(dirname, pathname)
+ dirname_relative = dirname[len(common_ancestor):]
+ dirname_relative = [".." for x in dirname_relative.split(os.sep) if x]
+ dirname_relative = os.sep.join(dirname_relative)
+ pathname_relative = pathname[len(common_ancestor):]
+ return os.path.join(dirname_relative, pathname_relative)
+
+ def apply_template(self, dirname, filename, template, values):
+ self.write(self.target(dirname, filename), template % values)
+
+ def write(self, filename, contents):
+ f = file(filename, "w")
+ f.write(contents)
+ f.close()
+
+ def thumbnail_path(self, dirname, image):
+ basename, suffix = os.path.splitext(image)
+ return os.path.join(self.target_dir, dirname,
+ basename + "_thumbnail.jpg")
+
+ def create_thumbnail(self, dirname, image):
+ image_path = os.path.join(self.root, dirname, image)
+ subprocess.check_call(["convert", "-geometry", "200x200", image_path,
+ self.thumbnail_path(dirname, image)])
+
+if __name__ == "__main__":
+ App().run()
diff --git a/trunk/gallery/image.template b/trunk/gallery/image.template
new file mode 100644
index 0000000..1626f27
--- /dev/null
+++ b/trunk/gallery/image.template
@@ -0,0 +1,9 @@
+<html>
+<head>
+<title>%(description)s</title>
+</head>
+<p><a href="%(previmage)s">prev</a> <a href="%(nextimage)s">next</a></p>
+<h1>%(description)s</h1>
+<img src="%(imagepathname)s" />
+<p>%(Exif.Image.ImageDescription)s</p>
+</html>
diff --git a/trunk/gallery/index.template b/trunk/gallery/index.template
new file mode 100644
index 0000000..cbd2991
--- /dev/null
+++ b/trunk/gallery/index.template
@@ -0,0 +1,10 @@
+<html>
+<head>
+<title>%(directorydescription)s</title>
+</head>
+<h1>%(directorydescription)s</h1>
+<ul>%(subdirlist)s</ul>
+<ul>
+%(indeximagelist)s
+</ul>
+</html>
diff --git a/trunk/gallery/indexdir.template b/trunk/gallery/indexdir.template
new file mode 100644
index 0000000..0cb407a
--- /dev/null
+++ b/trunk/gallery/indexdir.template
@@ -0,0 +1 @@
+<li><a href="%(subdirname)s/index.html">%(subdirname)s</a></li>
diff --git a/trunk/gallery/indeximage.template b/trunk/gallery/indeximage.template
new file mode 100644
index 0000000..6d8895c
--- /dev/null
+++ b/trunk/gallery/indeximage.template
@@ -0,0 +1,3 @@
+<li>
+<a href="%(imagebasename)s.html"><img src="%(thumbnailname)s"</img></a>
+</li>
diff --git a/trunk/no-unit-tests.txt b/trunk/no-unit-tests.txt
new file mode 100644
index 0000000..3038344
--- /dev/null
+++ b/trunk/no-unit-tests.txt
@@ -0,0 +1,25 @@
+./setup.py
+./dimbola/__init__.py
+./dimbola/db.py
+./dimbola/bgjobs.py
+./dimbola/gtkapp.py
+./dimbola/prefs.py
+./dimbola/ui.py
+./dimbola/plugins/export_plugin.py
+./dimbola/plugins/folderlist_plugin.py
+./dimbola/plugins/gimp_plugin.py
+./dimbola/plugins/import_plugin.py
+./dimbola/plugins/news_plugin.py
+./dimbola/plugins/photoinfo_plugin.py
+./dimbola/plugins/phototags_plugin.py
+./dimbola/plugins/photoviewer_plugin.py
+./dimbola/plugins/rate_plugin.py
+./dimbola/plugins/search_plugin.py
+./dimbola/plugins/tagtree_plugin.py
+./dimbola/plugins/remove_photos_plugin.py
+./dimbola/plugins/checksum_plugin.py
+./gallery/dimbola-gallery.py
+./test-plugins/hello_plugin.py
+./test-plugins/wrongversion_plugin.py
+./test-plugins/oldhello_plugin.py
+./test-plugins/aaa_hello_plugin.py
diff --git a/trunk/po/Makefile b/trunk/po/Makefile
new file mode 100644
index 0000000..d0bec53
--- /dev/null
+++ b/trunk/po/Makefile
@@ -0,0 +1,27 @@
+# customized from update-manager package's po/Makefile
+# License GPL, original author Michael Vogt
+
+top_srcdir=`pwd`/..
+
+DOMAIN=dimbola
+PO_FILES := $(wildcard *.po)
+CONTACT=timo.jyrinki@iki.fi
+
+all: update-po
+
+# update the pot
+$(DOMAIN).pot:
+ XGETTEXT_ARGS=--msgid-bugs-address=$(CONTACT) intltool-update -p -g $(DOMAIN)
+
+# merge the new stuff into the po files
+merge-po: $(PO_FILES)
+ XGETTEXT_ARGS=--msgid-bugs-address=$(CONTACT) intltool-update -r -g $(DOMAIN);
+
+# create mo from the pos
+%.mo : %.po
+ mkdir -p mo/$(subst .po,,$<)/LC_MESSAGES/
+ msgfmt $< -o mo/$(subst .po,,$<)/LC_MESSAGES/$(DOMAIN).mo
+
+# dummy target
+update-po: $(DOMAIN).pot merge-po $(patsubst %.po,%.mo,$(wildcard *.po))
+
diff --git a/trunk/po/POTFILES.in b/trunk/po/POTFILES.in
new file mode 100644
index 0000000..652eff1
--- /dev/null
+++ b/trunk/po/POTFILES.in
@@ -0,0 +1,2 @@
+dimbola.desktop.in
+
diff --git a/trunk/po/dimbola.pot b/trunk/po/dimbola.pot
new file mode 100644
index 0000000..9814040
--- /dev/null
+++ b/trunk/po/dimbola.pot
@@ -0,0 +1,29 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2009-12-08 09:21+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=CHARSET\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: ../dimbola.desktop.in.h:1
+msgid "Dimbola"
+msgstr ""
+
+#: ../dimbola.desktop.in.h:2
+msgid "Manage digital photographs"
+msgstr ""
+
+#: ../dimbola.desktop.in.h:3
+msgid "Photo manager"
+msgstr ""
diff --git a/trunk/po/fi.po b/trunk/po/fi.po
new file mode 100644
index 0000000..86fd4c9
--- /dev/null
+++ b/trunk/po/fi.po
@@ -0,0 +1,28 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: trunk\n"
+"Report-Msgid-Bugs-To: timo.jyrinki@iki.fi\n"
+"POT-Creation-Date: 2009-12-08 09:21+0200\n"
+"PO-Revision-Date: 2009-12-08 09:20+0200\n"
+"Last-Translator: Timo Jyrinki <timo.jyrinki@iki.fi>\n"
+"Language-Team: Finnish <gnome-fi-laatu@lists.sourceforge.net>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: ../dimbola.desktop.in.h:1
+msgid "Dimbola"
+msgstr "Dimbola"
+
+#: ../dimbola.desktop.in.h:2
+msgid "Manage digital photographs"
+msgstr "Hallitse digitaalisia valokuvia"
+
+#: ../dimbola.desktop.in.h:3
+msgid "Photo manager"
+msgstr "Valokuvien hallinta"
diff --git a/trunk/scripts/checksum-benchmark b/trunk/scripts/checksum-benchmark
new file mode 100755
index 0000000..5f70017
--- /dev/null
+++ b/trunk/scripts/checksum-benchmark
@@ -0,0 +1,23 @@
+#!/bin/sh
+#
+# Compare various checksum algorithms for speed.
+
+set -e
+
+algo()
+{
+ printf "%6s: " "$1"
+ python -m timeit \
+ -c \
+ -s 'import hashlib' \
+ -s 'data = file("test-plugins/test.cr2").read()' \
+ "hashlib.new('$1', string=data)"
+}
+
+algo md5
+algo sha1
+algo sha224
+algo sha256
+algo sha384
+algo sha512
+
diff --git a/trunk/scripts/list-gdkpixbuf-formats b/trunk/scripts/list-gdkpixbuf-formats
new file mode 100755
index 0000000..d29b14b
--- /dev/null
+++ b/trunk/scripts/list-gdkpixbuf-formats
@@ -0,0 +1,17 @@
+#!/usr/bin/python
+#
+# A small Python script to list the file formats GdkPixbuf supports.
+
+
+import gtk
+
+
+for format in gtk.gdk.pixbuf_get_formats():
+ print '%s: ' % format['name']
+ print ' description: %s' % format['description']
+ print ' mime_types:'
+ for mime_type in format['mime_types']:
+ print ' %s' % mime_type
+ print ' extensions: %s' % ' '.join(format['extensions'])
+ print ' is writable: %s' % format['is_writable']
+ print
diff --git a/trunk/scripts/pyexiv2dump b/trunk/scripts/pyexiv2dump
new file mode 100755
index 0000000..80ac8a1
--- /dev/null
+++ b/trunk/scripts/pyexiv2dump
@@ -0,0 +1,15 @@
+#!/usr/bin/python
+
+import sys
+
+import pyexiv2
+
+
+for filename in sys.argv[1:]:
+ image = pyexiv2.Image(filename)
+ image.readMetadata()
+ print filename
+ for key in image.exifKeys():
+ print " %s: %s" % (key, repr(image[key]))
+ print " --> %s" % repr(image.interpretedExifValue(key))
+
diff --git a/trunk/scripts/test-bgjobs b/trunk/scripts/test-bgjobs
new file mode 100755
index 0000000..1526ca3
--- /dev/null
+++ b/trunk/scripts/test-bgjobs
@@ -0,0 +1,47 @@
+#!/usr/bin/python
+#
+# A small test script to excercise the bgjobs stuff. We create a number of
+# jobs, and make run them, and make sure the results are correct.
+#
+# By running this script a large number of times (say, 10 000) in a loop,
+# it may be possible to expose race conditions. (As of the time of committing,
+# no such are known.)
+#
+# If you want to run this in the source directory, do this:
+#
+# PYTHONPATH=. python scripts/test-bgjobs
+
+
+import Queue
+import time
+
+import dimbola
+
+
+N = 100000
+
+
+class SlowJob(dimbola.BackgroundJob):
+
+ def __init__(self, value):
+ self.value = value
+
+ def run(self):
+ return self.value
+
+
+manager = dimbola.BackgroundManager()
+for i in range(N):
+ manager.add_job(SlowJob(i))
+manager.start_jobs(maxproc=None)
+results = 0
+while manager.running:
+ try:
+ result = manager.results.get(block=False)
+ except Queue.Empty:
+ pass
+ else:
+ results += 1
+manager.stop_jobs()
+assert results == N, "results==%d (not %d)" % (results, N)
+
diff --git a/trunk/setup.cfg b/trunk/setup.cfg
new file mode 100644
index 0000000..8834082
--- /dev/null
+++ b/trunk/setup.cfg
@@ -0,0 +1,4 @@
+[build_i18n]
+domain=dimbola
+desktop_files=[("share/applications", ("dimbola.desktop.in")]
+
diff --git a/trunk/setup.py b/trunk/setup.py
new file mode 100644
index 0000000..efafd60
--- /dev/null
+++ b/trunk/setup.py
@@ -0,0 +1,51 @@
+# setup.py - distutils module for Dimbola
+# 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/>.
+
+
+from distutils.core import setup
+from DistUtilsExtra.command import *
+
+# We can't just import the dimbola package, since that would import
+# gtk, which doesn't work without X, and we need to be able build
+# even without X. Thus, we kludge.
+
+import re
+version = None
+for line in file('dimbola/__init__.py'):
+ m = re.match(r'^version = \'(?P<version>\d+\.\d+\.\d+)\'', line)
+ if m:
+ version = m.group('version')
+assert version is not None
+
+
+setup(name='dimbola',
+ version=version,
+ description='photo management',
+ author='Lars Wirzenius',
+ author_email='liw@liw.fi',
+ packages=['dimbola', 'dimbola/plugins'],
+ package_data={'dimbola': ['ui.ui', 'plugins/*.ui', 'NEWS.html']},
+ scripts=['dimbola-gtk'],
+ data_files=[
+ ('share/applications', ['dimbola.desktop']),
+ ('share/man/man1', ['dimbola-gtk.1']),
+ ],
+ cmdclass = { "build" : build_extra.build_extra,
+ "build_i18n" : build_i18n.build_i18n,
+ "build_help" : build_help.build_help,
+ "build_icons" : build_icons.build_icons }
+ )
+
diff --git a/trunk/test-plugins/aaa_hello_plugin.py b/trunk/test-plugins/aaa_hello_plugin.py
new file mode 100644
index 0000000..f7ca7e9
--- /dev/null
+++ b/trunk/test-plugins/aaa_hello_plugin.py
@@ -0,0 +1,8 @@
+import pluginmgr
+
+class Hello(pluginmgr.Plugin):
+
+ def __init__(self, foo, bar=None):
+ self.foo = foo
+ self.bar = bar
+
diff --git a/trunk/test-plugins/hello_plugin.py b/trunk/test-plugins/hello_plugin.py
new file mode 100644
index 0000000..7f0472c
--- /dev/null
+++ b/trunk/test-plugins/hello_plugin.py
@@ -0,0 +1,11 @@
+import pluginmgr
+
+class Hello(pluginmgr.Plugin):
+
+ def __init__(self, foo, bar=None):
+ self.foo = foo
+ self.bar = bar
+
+ @property
+ def version(self):
+ return '0.0.1'
diff --git a/trunk/test-plugins/oldhello_plugin.py b/trunk/test-plugins/oldhello_plugin.py
new file mode 100644
index 0000000..47b3b67
--- /dev/null
+++ b/trunk/test-plugins/oldhello_plugin.py
@@ -0,0 +1,9 @@
+import pluginmgr
+
+class Hello(pluginmgr.Plugin):
+
+ def __init__(self, foo, bar=None):
+ self.foo = foo
+ self.bar = bar
+
+
diff --git a/trunk/test-plugins/test.cr2 b/trunk/test-plugins/test.cr2
new file mode 100644
index 0000000..0d36264
--- /dev/null
+++ b/trunk/test-plugins/test.cr2
Binary files differ
diff --git a/trunk/test-plugins/test.jpg b/trunk/test-plugins/test.jpg
new file mode 100644
index 0000000..05516b0
--- /dev/null
+++ b/trunk/test-plugins/test.jpg
Binary files differ
diff --git a/trunk/test-plugins/wrongversion_plugin.py b/trunk/test-plugins/wrongversion_plugin.py
new file mode 100644
index 0000000..4ecc6aa
--- /dev/null
+++ b/trunk/test-plugins/wrongversion_plugin.py
@@ -0,0 +1,12 @@
+# This is a test plugin that requires a newer application version than
+# what the test harness specifies.
+
+import pluginmgr
+
+class WrongVersion(pluginmgr.Plugin):
+
+ required_application_version = '9999.9.9'
+
+ def __init__(self, *args, **kwargs):
+ pass
+