PKzeHI"I"photofs/__init__.py#!/usr/bin/env python # coding: utf-8 # photofs # Copyright (C) 2012-2016 Moses Palmér # # 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 . import os import stat import threading import time # For FUSE import errno import fuse from ._image import Image, FileBasedImage from ._source import ImageSource from ._tag import Tag # Import the actual image sources from .sources import * class PhotoFS(fuse.LoggingMixIn, fuse.Operations): """An implementation of a *FUSE* file system. It presents the tagged image libraries from image sources as a tag tree in the file system. :param ImageSource source: The image source. :param database: An override for the default database file for the selected image source. :type database: str or None :param bool use_links: Whether to report file based images as links. :param dict filters: A mapping from top level directory to filtering function. If this is falsy, no filters are used, and root tags are used to populate the root directory. :param str video_path: The directory in the mounted root to contain videos. :param str date_format: The date format string used to construct file names from time stamps. :raises RuntimeError: if an error occurs """ def __init__( self, mountpoint, source=list(ImageSource.SOURCES.keys())[0], use_links=False, filters={}, date_format='%Y-%m-%d, %H.%M', **kwargs): super(PhotoFS, self).__init__() self.source = source self.use_links = use_links self.filters = filters Image.DATE_FORMAT = date_format self.creation = None self.dirstat = None self.image_source = None self.handles = {} # Create the image source self.image_source = ImageSource.get(self.source)(**kwargs) try: # Store the current time as timestamp for directories self.creation = int(time.time()) # Use the lstat result of the mount point for all directories self.dirstat = os.lstat(mountpoint) except Exception as e: try: raise RuntimeError( 'Failed to initialise file system: %s', e.args[0] % e.args[1:]) except: raise RuntimeError( 'Failed to initialise file system: %s', str(e)) def destroy(self, path): pass def recursive_filter(self, item, include): """The recursive filter used to actually filter the image source. This function will simply call include_filter in the outer scope if item is an instance of :class:`Image`, otherwise it will recursively call itself on all items in the tag, and return whether the filtered tag contains any subitems. :param item: The item to filter. :type item: Image or Tag :return: ``True`` if the item should be kept and ``False`` otherwise """ if isinstance(item, Image): return include(item) elif isinstance(item, Tag): return any( self.recursive_filter(item, include) for item in item.values()) else: return False def locate(self, path): """Locates a filter function and an image or tag resource. If the path denotes the root, :attr:`self.image_source` is returned :param str path: The absolute path of the resource. This must begin with :attr:`os.path.sep`. :return: the tuple ``(include, resource)``, where ``include`` is ``None`` if no filters are registered :raises KeyError: if the resource does not exist using the filter """ # The root path corresponds to the filters, if any registered, or the # image source root tags if path == os.path.sep: return (None, self.filters or self.image_source) # If any filters are registered, the first part of the path is the # filter name; the filter must allow the item if self.filters: root, rest = self.split_path(path) include = self.filters[root] if rest: item = self.image_source.locate(os.path.sep + rest) if not self.recursive_filter(item, include): raise KeyError(path) path = os.path.sep + rest else: include = None return ( include, self.image_source.locate(path) if path else self.image_source) def split_path(self, path): """Returns the tuple ``(root, rest)`` for a path, where ``root`` is the directory immediately beneath the root and ``rest`` is anything after that. :param str path: The path to split. This must begin with :attr:`os.path.sep`. :return: a tuple containing the split path, which may be empty strings :raises ValueError: if ``path`` does not begin with :attr:`os.path.sep` """ if path[0] != os.path.sep: raise ValueError( '%s is not a valid path', path) path = path[len(os.path.sep):] if os.path.sep in path: return path.split(os.path.sep, 1) else: return (path, '') def getattr(self, path, fh=None): try: include, item = self.locate(path) except KeyError: raise fuse.FuseOSError(errno.ENOENT) if self.use_links and isinstance(item, FileBasedImage): # This is a link st = os.stat_result((item.stat[0] | stat.S_IFLNK,) + item.stat[1:]) elif isinstance(item, Image): # This is a file st = item.stat elif isinstance(item, dict): # This is a directory; this matches both Tag and ImageSource st = self.dirstat else: raise RuntimeError( 'Unknown object: %s', path) return dict( # Remove write permission bits st_mode=st.st_mode & ~( stat.S_IWGRP | stat.S_IWUSR | stat.S_IWOTH), st_gid=st.st_gid, st_uid=st.st_uid, st_nlink=st.st_nlink, st_atime=st.st_atime, st_ctime=st.st_ctime, st_mtime=st.st_mtime, st_size=st.st_size) def readdir(self, path, offset): if path == os.path.sep: return [ k for k in (self.filters or self.image_source)] try: include, item = self.locate(path) except KeyError: raise fuse.FuseOSError(errno.ENOENT) if isinstance(item, dict): # This is a directory; this matches both Tag and # ImageSource return [ k for k, v in item.items() if self.recursive_filter(v, include)] else: raise RuntimeError( 'Unknown object: %s', os.path.join(root, path)) def readlink(self, path): include, item = self.locate(path) try: return item.location except: raise fuse.FuseOSError(errno.EINVAL) def open(self, path, flags): include, item = self.locate(path) if isinstance(item, Image): handle = item.open(flags) self.handles[id(handle)] = (handle, threading.Lock()) return id(handle) else: raise fuse.FuseOSError(errno.EINVAL) def release(self, path, fh): try: handle, lock = self.handles[fh] with lock: handle.close() del self.handles[fh] except: raise fuse.FuseOSError(errno.EINVAL) def read(self, path, size, offset, fh): handle, lock = self.handles[fh] with lock: if handle.tell() != offset: handle.seek(offset) return handle.read(size) PK\eH]!]!photofs/_source.py#!/usr/bin/env python # coding: utf-8 # photofs # Copyright (C) 2012-2016 Moses Palmér # # 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 . import os from ._util import make_unique from ._tag import Tag class ImageSource(dict): """A source of images and tags. This is an abstract class. """ #: A mapping of all registered sources by name to implementing classes SOURCES = {} @classmethod def add_arguments(self, argparser): """Adds all command line arguments for this image source to an argument parser. :param argparse.ArgumentParser argparser: The argument parser to which to add arguments. """ pass @classmethod def register(self, name): """A decorator that registers an :class:`ImageSource` subclass as an image source. :param str name: The name of the image source. """ def inner(cls): self.SOURCES[name] = cls return cls return inner @classmethod def get(self, name): """Returns the class responsible for a named image source. :param str name: The name of the source. :return: the corresponding class :raises ValueError: if ``name`` is not a known image source """ try: return self.SOURCES[name] except KeyError: raise ValueError('%s is not a valid image source', name) def _break_path(self, path): """Breaks an absolute path into its segments. :param str path: The absolute path to break, for example ``'/Tag/Other/Third'``, which will yield ``['Tag', 'Other', 'Third']``. This string must begin with :attr:`os.path.sep`. :raises ValueError: if path does not begin with os.path.sep :return: the path elements :rtype: [str] """ # Make sure the path begins with a path separator if path[0] != os.path.sep: raise ValueError( '"%s" does not begin with "%s"', path, os.path.sep) elif path == os.path.sep: return [] return path.split(os.path.sep)[1:] def _make_unique(self, directory, base_name, ext): """Creates a unique key in the map ``directory``. See :func:`make_unique` for more information. :param dict directory: The map in which to create the unique key. :param str base_name: The name of the file without extension. :param str ext: The file extension. A dot (``'.'``) is not added automatically; this must be present in ``ext``. :return: a unique key :rtype: str """ return make_unique(directory, base_name, '%s%s', '%s (%d)%s', ext) def _make_tags(self, path): """Makes sure that all tags up until the last element of ``path`` exist. :param str path: The absolute path of the tag to make, for example ``'/Tag/Other/Third'``. This string must begin with :attr:`os.path.sep`. :raises ValueError: if ``path`` does not begin with :attr:`os.path.sep` :return: the last tag; ``Third`` in the example above :rtype: Tag """ segments = self._break_path(path) # Create all tags current = self for segment in segments: if segment not in current: tag = Tag(segment, current if current != self else None) if current == self: # If the tag does not exist, and this is a root tag # (current == self => this is the first iteration), add the # tag to self; the parent parameter to Tag above will # handle other cases self[segment] = tag current = tag else: current = current[segment] return current def __init__(self, **kwargs): """Creates a new ImageSource. :param str date_format: The date format to use when creating file names for images that do not have a title. """ if kwargs: raise ValueError( 'Unsupported command line argument: %s', ', '.join(k for k in kwargs)) super(ImageSource, self).__init__() def locate(self, path): """Locates an image or tag. :param str path: The absolute path of the item to locate, for example ``'/Tag/Other/Image.jpg'``. This string must begin with :attr:`os.path.sep`. :return: a tag or an image :rtype: Tag or Image :raises KeyError: if the item does not exist :raises ValueError: if path does not begin with os.path.sep """ segments = self._break_path(path) # Locate the last item current = self for segment in segments: current = current[segment] return current class FileBasedImageSource(ImageSource): """A source of images and tags where the backend is file based. This is an abstract class. """ @classmethod def add_arguments(self, argparser): """Adds all command line arguments for this image source to an argument parser. :param argparse.ArgumentParser argparser: The argument parser to which to add arguments. """ argparser.add_argument( '--database', help='The database file to use. If not specified, the default one ' 'is used.') def __init__(self, database=None, **kwargs): """Creates a new ImageSource. :param str database: The path to the backend database or directory for this image source. If :meth:`refresh` is not overloaded, this must be a valid file name. Its timestamp is used to determine whether to actually reload all images and tags. If this is not provided, a default location is used. """ super(FileBasedImageSource, self).__init__(**kwargs) self._path = database or self.default_location if self._path is None: raise ValueError('No database') self._timestamp = 0 def load_tags(self): """Loads the tags from the backend resource. This function is called by refresh if the timestamp of the backend resource has changed. :return: a list of tags with the images attached :rtype: [Tag] """ raise NotImplementedError() @property def default_location(self): """Returns the default location of the backend resource. :return: the default location of the backend resource, or ``None`` if none exists :rtype: str or None """ raise NotImplementedError() @property def path(self): """The path of the backend resource containing the images and tags.""" return self._path @property def timestamp(self): """The timestamp when the backend resource was last modified.""" return self._timestamp def refresh(self): """Reloads all images and tags from the backend resource if it has changed since the last update. If the last modification time of :attr:`path` has changed, the backend resource is considered to be changed as well. In this case, the internal timestamp is updated and :meth:`load_tags` is called. """ # Check the timestamp if self.path: timestamp = os.stat(self._path).st_mtime if timestamp == self._timestamp: return self._timestamp = timestamp # Release the old data and reload the tags self.clear() self.load_tags() def locate(self, path): self.refresh() return super(FileBasedImageSource, self).locate(path) PKWeHphotofs/_http.py# coding: utf-8 # photofs # Copyright (C) 2012-2016 Moses Palmér # # 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 . import urllib3 #: The connection pool __http = urllib3.PoolManager() def request(method, *args, **kwargs): """Initiates an HTTP request. This function is shorthand for ``urllib3.PoolManager().request(method, *args, **kwargs)´´. :param str method: The HTTP verb. """ return __http.request(method, *args, **kwargs) PK\eH. from ._image import Image from ._util import make_unique class Tag(dict): """A tag applied to an image or a video. An image or video may have several tags applied. In that case an image with the same location will be present in the image collection of several tags. The image references may not be equal. Tags are hierarchial. A tag may have zero or one parent, and any number of children. The parent-child relationship is noted in the name of tags: the name of a tag will be ``//``. """ def _make_unique(self, base_name, ext): """Creates a unique key in this dict. See :func:`make_unique` for more information. :param str base_name: The name of the file without extension. :param str ext: The file extension. A dot (``'.'``) is not added automatically; this must be present in ``ext``. :return: a unique key :rtype: str """ return make_unique(self, base_name, '%s%s', '%s (%d)%s', ext) def __setitem__(self, k, v): # Make sure keys are strings and items are images or tags if not isinstance(k, str) and not ( isinstance(v, Image) or isinstance(v, Tag)): raise ValueError( 'Cannot add %s to Tag', str(v)) super(Tag, self).__setitem__(k, v) def __init__(self, name, parent=None): """Initialises a named tag. :param str name: The name of the tag. :param Tag parent: The parent tag. This is used to create the full name of the tag. If this is ``None``, a root tag is created, otherwise this tag is added to the parent tag. """ super(Tag, self).__init__() self._name = name self._parent = parent self._has_video = False self._has_image = False # Make sure to add ourselves to the parent tag if specified if parent: parent.add(self) @property def name(self): """The name of this tag.""" return self._name @property def parent(self): """The parent of this tag.""" return self._parent @property def has_image(self): """Whether this tag contains at least one image""" return self._has_image @property def has_video(self): """Whether this tag contains at least one video""" return self._has_video def add(self, item): """Adds an image or tag to this tag. If a tag is added, it will be stored with the key ``item.name``. If this key already has a value, the following action is taken: - If the value is a tag, the new tag overwrites it. - If the value is an image, a new unique name is generated and the image is moved. :param item: An image or a tag. :type item: Image or Tag :raises ValueError: if item is not an instance of :class:`Image` or :class:`Tag` """ if isinstance(item, Image): # Make sure the key name is unique key = self._make_unique(item.title, '.' + item.extension) self[key] = item self._has_image = self._has_image or not item.is_video self._has_video = self._has_image or item.is_video elif isinstance(item, Tag): previous = self.get(item.name) self[item.name] = item # Re-add the previous item if it was an image if isinstance(previous, Image): self.add(previous) self._has_image = self._has_image or item.has_video self._has_video = self._has_image or item.has_image else: raise ValueError( 'Cannot add %s to a Tag', str(item)) PK-[eH=bbphotofs/_util.py#!/usr/bin/env python # coding: utf-8 # photofs # Copyright (C) 2012-2016 Moses Palmér # # 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 . def make_unique(mapping, base_name, format_1, format_n, *args): """Creates a unique key in a ``dict``. First the string ``format_1 % base_name`` is tried. If that is present in ``mapping``, the strings ``format_n % ((base_name, n) + args)`` with ``n`` incrementing from ``2`` are tried until a unique one is found. :param str base_name: The name of the file without extension. :param str format_1: The initial format string. This will be passed ``base_name`` followed by ``args``. :param str format_n: The fallback format string. This will be passed ``base_name`` followed by an index and then ``args``. :param args: Format string arguments used. :return: a unique key :rtype: str """ i = 1 key = format_1 % ((base_name,) + args) while key in mapping: i += 1 key = format_n % ((base_name, i) + args) return key PK!\eHViFphotofs/_image.py#!/usr/bin/env python # coding: utf-8 # photofs # Copyright (C) 2012-2016 Moses Palmér # # 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 . import datetime import mimetypes import os import time class Image(object): """An image or video. """ #: The date format used to construct the title when none is set DATE_FORMAT = '%Y-%m-%d, %H.%M' def __init__(self, title, extension, timestamp, st, is_video=None): """Initialises an image. :param str title: The title of the image. This should be used to generate the file name. If ``title`` is empty or ``None``, ``timestamp`` is used instead. :param str extension: The file extension. :param timestamp: The timestamp when this image or video was created. :type timestamp: int or datetime.datetime :param os.stat_result st: The ``stat`` value for this item. :param bool is_video: Whether this image is a video. This must be either ``True`` or ``False``, or ``None``. If it is ``None``, the type is inferred from the file *MIME type*. """ super(Image, self).__init__() self._title = title self._extension = extension if isinstance(timestamp, datetime.datetime): self._timestamp = timestamp else: self._timestamp = datetime.datetime.fromtimestamp(timestamp) if is_video is None: mime, encoding = mimetypes.guess_type('file.' + extension) is_video = mime and mime.startswith('video/') self._stat = st self._is_video = is_video @property def timestamp(self): """The timestamp when this image or video was created.""" return self._timestamp @property def title(self): """The title of this image. Use this to generate the file name if it is set.""" return self._title or time.strftime( self.DATE_FORMAT, self.timestamp.timetuple()) @property def extension(self): """The lower case file extension of this image.""" return self._extension @property def is_video(self): """Whether this image is a video.""" return self._is_video @property def stat(self): """The ``stat`` result for this image.""" return self._stat def open(self, flags): """Opens a readable stream to the file. :param int flags: Flags passed by *FUSE*. :return: an object supporting ``seek(offset)`` and ``read(size)`` from :class:`file` """ raise NotImplementedError() class FileBasedImage(Image): """An image or video. """ def __init__(self, title, location, timestamp, is_video=None): """Initialises a file based image. :param str title: The title of the image. This should be used to generate the file name. If ``title`` is empty or ``None``, ``timestamp`` is used instead. :param str location: The location of this image in the file system. :param timestamp: The timestamp when this image or video was created. :type timestamp: int or datetime.datetime :param bool is_video: Whether this image is a video. This must be either ``True`` or ``False``, or ``None``. If it is ``None``, the type is inferred from the file *MIME type*. """ super(FileBasedImage, self).__init__( title, location.rsplit('.', 1)[-1].lower(), timestamp, os.lstat(location), is_video) self._location = location @property def location(self): """The location of this image or video in the file system.""" return self._location @property def stat(self): """The ``stat`` result for this image.""" return os.lstat(self.location) def open(self, flags): return open(self.location, 'rb') PKzeHC,' AAphotofs/__main__.pyimport sys from photofs import * def main(): import argparse parser = argparse.ArgumentParser( prog='photofs', add_help=True, description='Explore tagged images from Shotwell in the file system.', epilog='In addition to the command line options specified above, this ' 'program accepts all standard FUSE command line options.') parser.add_argument( 'mountpoint', help='The file system mount point.') parser.add_argument( '--debug', '-d', help='Enable debug logging.', type=bool) parser.add_argument( '--use-links', '-l', help='Report images as links. This will generally improve performance', action='store_true') parser.add_argument( '--foreground', '-f', help='Run the daemon in the foreground.', action='store_true') def filter_type(name, include): def inner(value): if filter_type.filters is None: return try: filter_type.filters[value] = include except AttributeError: filter_types.filters = {name: include} return inner filter_type.filters = {} parser.add_argument( '--photo-path', help='The name of the top level directory that contains photos.', default='Photos', type=filter_type( '--photo-path', lambda i: not i.is_video if isinstance(i, Image) else not i.has_video)) parser.add_argument( '--video-path', help='The name of the top level directory that contains videos.', default='Videos', type=filter_type( '--video-path', lambda i: i.is_video if isinstance(i, Image) else i.has_video)) class FlatPresentationAction(argparse.Action): def __call__(self, parser, namespace, values, option_string): filter_type.filters = None parser.add_argument( '--flat-presentation', nargs=0, help='Do not separate photos and videos.', action=FlatPresentationAction) parser.add_argument( '--date-format', help='The format to use for timestamps.') fuse_args = {} class OAction(argparse.Action): def __call__(self, parser, namespace, values, option_string): try: name, value = values[0].split('=') except ValueError: name, value = values[0], True fuse_args[name] = value parser.add_argument( '-o', help='Any FUSE options.', nargs=1, action=OAction) # Add image source specific command line arguments for source in ImageSource.SOURCES.values(): source.add_arguments(parser) # First, let args be the argument dict, but remove undefined values args = { name: value for name, value in vars(parser.parse_args()).items() if value is not None} # Then pop these known items and pass them on to the FUSE constructor fuse_args.update({ name: args.pop(name) for name in ( 'foreground', 'debug') if name in args}) try: photo_fs = PhotoFS(filters=filter_type.filters, **args) fuse.FUSE(photo_fs, args['mountpoint'], fsname='photofs', **fuse_args) except Exception as e: import traceback traceback.print_exc() try: sys.stderr.write('%s\n' % e.args[0] % e.args[1:]) except: sys.stderr.write('%s\n' % str(e)) main() PKXeH<%%photofs/_cache.py# coding: utf-8 # photofs # Copyright (C) 2012-2015 Moses Palmér # # 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 . import errno import os import sqlite3 import threading class CachedFile(object): def __init__(self, path, size, condition): """A readable file-like object with blocking reading. :param str path: The path of the file. :param int size: The size of the file. This may be greater than the actual size of the file. Reading beyond this position will return no data. :param threading.Condition condition: A condition that will be waited upon when reading past the actual end of the file is requested. The process writing data to the file is responsible for signalling this when more data becomes available. """ super(CachedFile, self).__() self._file = open(path, 'rb') self._size = size self._condition = condition class Cache(object): """A cache of file resources. """ def __init__(self, base, name): """Creates a cache. :param str base: The base directory for caching. This directory is expected to exist :param str name: The name of the cache. :raises OSError: if the cache directory does not exist and cannot be created """ self._path = os.path.join(base, name) self._name = name # Make sure the directory exists try: os.mkdir(self.path) except OSError as e: if e.errno != errno.EEXIST: raise self._db_lock = threading.RLock() self._db_cursors = [] self._db = sqlite3.connect( os.path.join(os.path.join(base, name + '.db')), check_same_thread = False) self._upgrade_tables() self._files_lock = threading.RLock() self._files = {} @property def path(self): """The path to the cache directory.""" return self._path @property def name(self): """The name of this cache.""" return self._name def get(self, image_id, *args, **kwargs): """Retrieves a file object associated with an image ID. :param str image_id: The unique ID of the image. :param args: Positional arguments passed on to :meth:`open` if the image is not fully cached. :param kwargs: Keyword arguments passed on to :meth:`open` if the image is not fullkypy cached. :return: an object supporting the *FUSE* read protocol """ with self._files_lock: try: return self._files[image_id] except KeyError: self._files[image_id] = self.open(image_id, *args, **kwargs) return self._files[image_id] def open(self, *args, **kwargs): """Actually opens a file. The object returned supports the *FUSE* read protocol, and has the attributes ``size``, which is the total size, and ``timestamp``, which is the timestamp of the image. :return: a file object """ raise NotImplementedError() def __enter__(self): self._db_lock.acquire() self._db_cursors.append(self._db.cursor()) return self._db_cursors[-1] def __exit__(self, exc_type, exc_value, tb): try: self._db_cursors.pop().close() finally: self._db_lock.release() def _commit(self): """Commits the current transaction. This method calls :meth:`sqlite. """ self._db.commit() @property def _db_version(self): """The current database version.""" with self as cursor: try: cursor.execute(''' SELECT MAX(number) FROM Version''') number, = cursor.fetchone() return number except sqlite3.OperationalError: return 0 def _upgrade_tables(self): """Upgrades the database to the current version. """ while True: # Get the current version and see if we have a next version version = self._db_version try: create_tables = getattr(self, '_create_tables_%d' % (version + 1)) except AttributeError: # We have moved past the latest version break # Upgrade the database to the next version with self as cursor: create_tables(cursor) cursor.execute(''' INSERT INTO Version (number) VALUES (?)''', (version + 1,)) self._commit() def _create_tables_1(self, cursor): """Creates the ``Version`` table. :param sqlite.Cursor: The database cursor to use. """ cursor.execute(''' CREATE TABLE Version ( number INT, date TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') def _create_tables_2(self, cursor): """Creates the ``Image`` table. :param sqlite.Cursor: The database cursor to use. """ cursor.execute(''' CREATE TABLE Image ( id TEXT NOT NULL, size INT NOT NULL, timestamp TEXT NOT NULL, CONSTRAINT UNIQUE_Image_id UNIQUE (id))''') cursor.execute(''' CREATE INDEX Image_id ON Image(id)''') def _image_get(self, image_id): """Reads a row from the ``Image`` table. :param str image_id: The ID of the image. :return: the tuple ``(size, timestamp)`` :raises KeyError: if ``image_id`` is not a known ID """ with self as cursor: cursor.execute(''' SELECT size, datetime(timestamp) FROM Image WHERE id = ?''', (image_id,)) result = cursor.fetchone() if result is None: raise KeyError(image_id) else: return result def _image_add(self, image_id, size, timestamp): """Adds a row to the ``Image`` table. :param str image_id: The ID of the image. :param int size: The size of the image. :param timestamp: The timestamp of the image. """ with self as cursor: cursor.execute(''' INSERT INTO Image (id, size, timestamp) VALUES (?, ?, ?)''', (image_id, size, timestamp)) self._commit() def _image_del(self, image_id): """Deletes a row from the ``Image`` table. :param str image_id: The ID of the image. :raises KeyError: if ``image_id`` is not a known ID """ with self as cursor: cursor.execute(''' DELETE FROM Image WHERE id = ?''', (image_id,)) if cursor.rowcount < 1: raise KeyError(image_id) self._commit() def _image_path(self, image_id): """Returns the full path for an image. :param str image_id: The ID of the image. :return: the full path of the image :rtype: str """ return os.path.join(self.path, image_id) def _open(self, image_id, *args, **kwargs): """Opens a file. If a cache of the file exists, and its size is as is stored in the database, the actual cached file will be opened and a normal ``file`` object will be returned. If the file does not exist in the database or on disk, or the size on disk is less that the size in the database, :meth:`open` will be called with all parameters passed to this method. :param str image_id: The unique ID of the image. :param args: Positional arguments passed on to :meth:`open`. :param kwargs: Keyword arguments passed on to :meth:`open`. """ # Read the expected size from the database try: size, timestamp = self._image_get(image_id) except KeyError: # The image does not exist in the database; add it and then open an # actual file image = self.open(image_id, *args, **kwargs) self._image_add(image_id, image.size, image.timestamp) return image # Check the size of the file cache path = self._image_path(image_id) st = os.stat(path) if st.st_size == size: # The cached file is complete; simply return it return open(path, 'rb') return self.open(image_id, *args, **kwargs) PK-[eHQjphotofs/sources/__init__.pydef _names(): """Lists the names of all resources in this package. :return: the names of all resources :rtype: [str] """ import os import pkg_resources return ( pkg_resources.resource_listdir('photofs', 'sources') or os.listdir(os.path.dirname(__file__))) __all__ = [ name.rsplit('.', 1)[0] for name in _names() if name.endswith('.py') and not name[0] == '_'] PK\eHڻiNphotofs/sources/shotwell.py# coding: utf-8 # photofs # Copyright (C) 2012-2014 Moses Palmér # # 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 . import os from xdg.BaseDirectory import xdg_data_dirs from photofs._image import FileBasedImage from photofs._source import ImageSource, FileBasedImageSource # Try to import sqlite try: import sqlite3 except ImportError: sqlite = None @ImageSource.register('shotwell') class ShotwellSource(FileBasedImageSource): """Loads images and videos from Shotwell. """ def __init__(self, *args, **kwargs): if sqlite3 is None: raise RuntimeError('This program requires sqlite3') super(ShotwellSource, self).__init__(*args, **kwargs) @property def default_location(self): """Determines the location of the *Shotwell* database. :return: the location of the database, or ``None`` if it cannot be located :rtype: str or None """ for d in xdg_data_dirs: result = os.path.join(d, 'shotwell', 'data', 'photo.db') if os.access(result, os.R_OK): return result def load_tags(self): db = sqlite3.connect(self._path) try: # The descriptions of the different image tables; the value tuple # is the header of the ID in the tag table, the map of IDs to # images and whether the table contains videos db_tables = { 'phototable': ('thumb', {}, False), 'videotable': ('video-', {}, True)} # Load the images for table_name, (header, images, is_video) in db_tables.items(): results = db.execute(""" SELECT id, filename, exposure_time, title FROM %s""" % table_name) for r_id, r_filename, r_exposure_time, r_title in results: try: images[r_id] = FileBasedImage( r_title, r_filename, r_exposure_time, is_video) except OSError: # Ignore unreadable files pass # Load the tags results = db.execute(""" SELECT name, photo_id_list FROM tagtable ORDER BY name""") for r_name, r_photo_id_list in results: # Ignore unused tags if not r_photo_id_list: continue # Hierachial tag names start with '/' path = r_name.split('/') if r_name[0] == '/' else ['', r_name] path_name = os.path.sep.join(path) # Make sure that the tag and all its parents exist tag = self._make_tags(path_name) # The IDs are all in the text of photo_id_list, separated by # commas; there is an extra comma at the end ids = r_photo_id_list.split(',')[:-1] # Iterate over all image IDs and move them to this tag for i in ids: if i[0].isdigit(): # If the first character is a digit, this is a legacy # source ID and an ID in the photo table image = db_tables['phototable'][1].get(int(i)) else: # Iterate over all database tables and locate the image # instance for the current ID image = None for table_name, (header, images, is_video) \ in db_tables.items(): if not i.startswith(header): continue image = images.get(int(i[len(header):], 16)) break # Verify that the tag only references existing images if image is None: continue # Remove the image from the parent tags parent = tag.parent while parent: for k, v in parent.items(): if v == image: del parent[k] parent = parent.parent # Finally add the image to this tag tag.add(image) finally: db.close() PKEDk:j"photofs-1.2.1.data/scripts/photofs#!/bin/sh # photofs # Copyright (C) 2012-2014 Moses Palmér # # 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 . python -m photofs $@ PKifH%v'photofs-1.2.1.dist-info/DESCRIPTION.rstphotofs ======= *photofs* is an application that allows you to mount the tags in the photo database from `Shotwell `_ as directories in a virtual file system. Usage ----- To add directories for all tags under ``$PHOTOFS_PATH``, run the following command:: photofs $PHOTOFS_PATH After this, ``$PHOTOFS_PATH/Photos`` will contain directories for all photo tags, and ``$PHOTOFS_PATH/Videos`` will contain directories for all video tags. Run ``photofs --help`` for a list of all command line arguments. How do I change the names of photos and videos? ----------------------------------------------- *photofs* will use the title of an image as file name. If the image does not have a title, the exposure time will be used. If more than one image is shot at the same time, the file names will be made unique by appending *(1)*, *(2)* etc. to the file name. Run ``photofs --help`` to see how to change the time format used. Release Notes ============= 1.3 - Flat presentation ----------------------- * Added support for flat presentation of images and videos. * Corrected crash when a *Shotwell* image does not exist in the file system. 1.2.1 - Bugfixes ---------------- * Corrected data corruption when multiple threads access same handle 1.2 - Support for multiple architectures ---------------------------------------- * Changed FUSE library to one with support for multiple architectures 1.1.1 - Bugfixes ---------------- * Readded missing support for reading link targets * Optimised file listing to avoid checking all files 1.1 - Allow File Sync --------------------- * Added support for syncing database file * Added support for using temporary database file 1.0 - Initial Release --------------------- * Basic support for *Shotwell* FUSE file system PKifH]@%photofs-1.2.1.dist-info/metadata.json{"extensions": {"python.details": {"contacts": [{"email": "moses.palmer@gmail.com", "name": "Moses Palm\u00e9r", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/moses-palmer/photofs"}}}, "extras": [], "generator": "bdist_wheel (0.26.0)", "license": "GPLv3", "metadata_version": "2.0", "name": "photofs", "run_requires": [{"requires": ["fusepy (>=2.0.2)"]}], "summary": "Explore tagged photos from Shotwell in the filesystem", "version": "1.2.1"}PKifH9^.. photofs-1.2.1.dist-info/pbr.json{"is_release": true, "git_version": "9b3ec83"}PKifH$O%photofs-1.2.1.dist-info/top_level.txtphotofs PKE2 photofs-1.2.1.dist-info/zip-safe PKifH''\\photofs-1.2.1.dist-info/WHEELWheel-Version: 1.0 Generator: bdist_wheel (0.26.0) Root-Is-Purelib: true Tag: py2-none-any PKifHSq88 photofs-1.2.1.dist-info/METADATAMetadata-Version: 2.0 Name: photofs Version: 1.2.1 Summary: Explore tagged photos from Shotwell in the filesystem Home-page: https://github.com/moses-palmer/photofs Author: Moses Palmér Author-email: moses.palmer@gmail.com License: GPLv3 Platform: UNKNOWN Requires-Dist: fusepy (>=2.0.2) photofs ======= *photofs* is an application that allows you to mount the tags in the photo database from `Shotwell `_ as directories in a virtual file system. Usage ----- To add directories for all tags under ``$PHOTOFS_PATH``, run the following command:: photofs $PHOTOFS_PATH After this, ``$PHOTOFS_PATH/Photos`` will contain directories for all photo tags, and ``$PHOTOFS_PATH/Videos`` will contain directories for all video tags. Run ``photofs --help`` for a list of all command line arguments. How do I change the names of photos and videos? ----------------------------------------------- *photofs* will use the title of an image as file name. If the image does not have a title, the exposure time will be used. If more than one image is shot at the same time, the file names will be made unique by appending *(1)*, *(2)* etc. to the file name. Run ``photofs --help`` to see how to change the time format used. Release Notes ============= 1.3 - Flat presentation ----------------------- * Added support for flat presentation of images and videos. * Corrected crash when a *Shotwell* image does not exist in the file system. 1.2.1 - Bugfixes ---------------- * Corrected data corruption when multiple threads access same handle 1.2 - Support for multiple architectures ---------------------------------------- * Changed FUSE library to one with support for multiple architectures 1.1.1 - Bugfixes ---------------- * Readded missing support for reading link targets * Optimised file listing to avoid checking all files 1.1 - Allow File Sync --------------------- * Added support for syncing database file * Added support for using temporary database file 1.0 - Initial Release --------------------- * Basic support for *Shotwell* FUSE file system PKifHv8photofs-1.2.1.dist-info/RECORDphotofs/__init__.py,sha256=J7Uz6qy75gMG63p7nBeMzJASh_tRD8LewyHa7j68q0c,8777 photofs/__main__.py,sha256=fcaZRf1JsIUv0mOoSmWE-r8TUGBTNdTfRsaayO1lkzs,3649 photofs/_cache.py,sha256=nepB00et-4EXnXe-cRCgDBjI4svmMB7BlDj2-RSecUA,9474 photofs/_http.py,sha256=81LtrHOxi2Qk2zkqLjNUa71lSaGOLSev6qcN2GIkd7o,1044 photofs/_image.py,sha256=XTlidyANbqLYyqLdvMGr-wugXYL6aA6Qc4y45JY4sBA,4593 photofs/_source.py,sha256=T9mT9XM9tWl1DH9r_B3_nFsrpImyp1PUUTVDRq8ASac,8541 photofs/_tag.py,sha256=xJ337SqUzmSJ-rFqG1oARwNzjXLovsO68YBRJjduy2U,4605 photofs/_util.py,sha256=_59bjmGt0tIgIE0Ms4TLaKERuEnkhiPtFsrS1dj0Yio,1634 photofs/sources/__init__.py,sha256=03--fJmF78l19ian8POszqI3Fcns0hBS4ZjIJsInPl0,419 photofs/sources/shotwell.py,sha256=l7u5CkIvJStfTPPDf5Q9m0ccaMQYi4j_DbD8E9rlWaU,5124 photofs-1.2.1.data/scripts/photofs,sha256=te3wugYZ4Xy2DWmhPp4sIOVKukqX8nqo-nRowzyueo0,714 photofs-1.2.1.dist-info/DESCRIPTION.rst,sha256=1stXNxO8Bz8MhCfsht5kjsU-eilSLNJsS6qUQzTVAIs,1814 photofs-1.2.1.dist-info/METADATA,sha256=2TRVhichENJsCXN91ba1yUHzVMvQlMr-4btm8nlkemg,2104 photofs-1.2.1.dist-info/RECORD,, photofs-1.2.1.dist-info/WHEEL,sha256=JTb7YztR8fkPg6aSjc571Q4eiVHCwmUDlX8PhuuqIIE,92 photofs-1.2.1.dist-info/metadata.json,sha256=vkAjmR3r57nh7W69Imcu1Sn74UbzXKSId1vIBUt24HQ,516 photofs-1.2.1.dist-info/pbr.json,sha256=GTlfbfH6Smhm3Y4EgLhZ5Cwo4-_4eVZBcpWhiQ1iTiw,46 photofs-1.2.1.dist-info/top_level.txt,sha256=fayyXx17C5MUSv_gR6xZ4qrfYZzF8aJ0OcL41EDSOMs,8 photofs-1.2.1.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1 PKzeHI"I"photofs/__init__.pyPK\eH]!]!z"photofs/_source.pyPKWeHDphotofs/_http.pyPK\eH