2016-12-17 04:02:08 +00:00
|
|
|
import os
|
|
|
|
import PIL.Image
|
|
|
|
import traceback
|
|
|
|
|
2017-02-05 03:55:13 +00:00
|
|
|
from . import constants
|
|
|
|
from . import decorators
|
|
|
|
from . import exceptions
|
|
|
|
from . import helpers
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
from voussoirkit import bytestring
|
|
|
|
from voussoirkit import pathclass
|
|
|
|
from voussoirkit import spinal
|
|
|
|
|
2017-02-25 06:07:59 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
class ObjectBase:
|
2017-04-23 04:16:30 +00:00
|
|
|
def __init__(self, photodb):
|
|
|
|
super().__init__()
|
|
|
|
self.photodb = photodb
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@property
|
|
|
|
def log(self):
|
|
|
|
return self.photodb.log
|
|
|
|
|
|
|
|
@property
|
|
|
|
def sql(self):
|
|
|
|
return self.photodb.sql
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def __eq__(self, other):
|
|
|
|
return (
|
|
|
|
isinstance(other, type(self)) and
|
|
|
|
self.photodb == other.photodb and
|
|
|
|
self.id == other.id
|
|
|
|
)
|
|
|
|
|
|
|
|
def __format__(self, formcode):
|
|
|
|
if formcode == 'r':
|
|
|
|
return repr(self)
|
|
|
|
else:
|
|
|
|
return str(self)
|
|
|
|
|
|
|
|
def __hash__(self):
|
|
|
|
return hash(self.id)
|
|
|
|
|
|
|
|
|
|
|
|
class GroupableMixin:
|
2017-03-04 09:13:22 +00:00
|
|
|
group_getter = None
|
|
|
|
group_sql_index = None
|
|
|
|
group_table = None
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def add(self, member, *, commit=True):
|
|
|
|
'''
|
|
|
|
Add a child object to this group.
|
|
|
|
Child must be of the same type as the calling object.
|
|
|
|
|
|
|
|
If that object is already a member of another group, an
|
|
|
|
exceptions.GroupExists is raised.
|
|
|
|
'''
|
|
|
|
if not isinstance(member, type(self)):
|
|
|
|
raise TypeError('Member must be of type %s' % type(self))
|
|
|
|
|
2017-03-04 07:44:43 +00:00
|
|
|
self.photodb.log.debug('Adding child %s to %s' % (member, self))
|
2017-03-10 23:27:40 +00:00
|
|
|
|
2016-12-21 02:31:09 +00:00
|
|
|
# Groupables are only allowed to have 1 parent.
|
|
|
|
# Unlike photos which can exist in multiple albums.
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
2017-03-04 09:13:22 +00:00
|
|
|
cur.execute(
|
|
|
|
'SELECT * FROM %s WHERE memberid == ?' % self.group_table,
|
|
|
|
[member.id]
|
|
|
|
)
|
2017-01-30 01:47:59 +00:00
|
|
|
fetch = cur.fetchone()
|
2016-12-17 04:02:08 +00:00
|
|
|
if fetch is not None:
|
2017-03-04 09:13:22 +00:00
|
|
|
parent_id = fetch[self.group_sql_index['parentid']]
|
2016-12-21 02:31:09 +00:00
|
|
|
if parent_id == self.id:
|
2017-03-10 23:27:40 +00:00
|
|
|
return
|
|
|
|
that_group = self.group_getter(id=parent_id)
|
2017-03-05 05:56:23 +00:00
|
|
|
raise exceptions.GroupExists(member=member, group=that_group)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-03-10 23:27:40 +00:00
|
|
|
for my_ancestor in self.walk_parents():
|
|
|
|
if my_ancestor == member:
|
2017-03-05 05:56:23 +00:00
|
|
|
raise exceptions.RecursiveGrouping(member=member, group=self)
|
2016-12-21 02:31:09 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb._cached_frozen_children = None
|
2017-03-04 09:13:22 +00:00
|
|
|
cur.execute(
|
|
|
|
'INSERT INTO %s VALUES(?, ?)' % self.group_table,
|
|
|
|
[self.id, member.id]
|
|
|
|
)
|
2017-05-02 04:16:10 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
2017-02-05 02:30:02 +00:00
|
|
|
self.photodb.log.debug('Committing - add to group')
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
def children(self):
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
2017-03-04 09:13:22 +00:00
|
|
|
|
|
|
|
cur.execute(
|
|
|
|
'SELECT * FROM %s WHERE parentid == ?' % self.group_table,
|
|
|
|
[self.id]
|
|
|
|
)
|
2017-01-30 01:47:59 +00:00
|
|
|
fetch = cur.fetchall()
|
2016-12-17 04:02:08 +00:00
|
|
|
results = []
|
|
|
|
for f in fetch:
|
2017-03-04 09:13:22 +00:00
|
|
|
memberid = f[self.group_sql_index['memberid']]
|
2016-12-17 04:02:08 +00:00
|
|
|
child = self.group_getter(id=memberid)
|
|
|
|
results.append(child)
|
|
|
|
if isinstance(self, Tag):
|
|
|
|
results.sort(key=lambda x: x.name)
|
|
|
|
else:
|
|
|
|
results.sort(key=lambda x: x.id)
|
|
|
|
return results
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def delete(self, *, delete_children=False, commit=True):
|
|
|
|
'''
|
|
|
|
Delete this object's relationships to other groupables.
|
|
|
|
Any unique / specific deletion methods should be written within the
|
|
|
|
inheriting class.
|
|
|
|
|
|
|
|
For example, Tag.delete calls here to remove the group links, but then
|
|
|
|
does the rest of the tag deletion process on its own.
|
|
|
|
|
|
|
|
delete_children:
|
|
|
|
If True, all children will be deleted.
|
|
|
|
Otherwise they'll just be raised up one level.
|
|
|
|
'''
|
|
|
|
self.photodb._cached_frozen_children = None
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
2016-12-17 04:02:08 +00:00
|
|
|
if delete_children:
|
|
|
|
for child in self.children():
|
|
|
|
child.delete(delete_children=delete_children, commit=False)
|
|
|
|
else:
|
|
|
|
# Lift children
|
|
|
|
parent = self.parent()
|
|
|
|
if parent is None:
|
2017-02-25 06:07:59 +00:00
|
|
|
# Since this group was a root, children become roots by removing
|
|
|
|
# the row.
|
2017-03-04 09:13:22 +00:00
|
|
|
cur.execute(
|
|
|
|
'DELETE FROM %s WHERE parentid == ?' % self.group_table,
|
|
|
|
[self.id]
|
|
|
|
)
|
2016-12-17 04:02:08 +00:00
|
|
|
else:
|
|
|
|
# Since this group was a child, its parent adopts all its children.
|
2017-01-30 01:47:59 +00:00
|
|
|
cur.execute(
|
2017-03-04 09:13:22 +00:00
|
|
|
'UPDATE %s SET parentid == ? WHERE parentid == ?' % self.group_table,
|
2016-12-17 04:02:08 +00:00
|
|
|
[parent.id, self.id]
|
|
|
|
)
|
2017-02-25 06:07:59 +00:00
|
|
|
# Note that this part comes after the deletion of children to prevent
|
|
|
|
# issues of recursion.
|
2017-03-04 09:13:22 +00:00
|
|
|
cur.execute(
|
|
|
|
'DELETE FROM %s WHERE memberid == ?' % self.group_table,
|
|
|
|
[self.id]
|
|
|
|
)
|
2017-05-02 04:16:10 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - delete tag')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
def parent(self):
|
|
|
|
'''
|
|
|
|
Return the group of which this is a member, or None.
|
|
|
|
Returned object will be of the same type as calling object.
|
|
|
|
'''
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
2017-03-04 09:13:22 +00:00
|
|
|
cur.execute(
|
|
|
|
'SELECT * FROM %s WHERE memberid == ?' % self.group_table,
|
|
|
|
[self.id]
|
|
|
|
)
|
2017-01-30 01:47:59 +00:00
|
|
|
fetch = cur.fetchone()
|
2016-12-17 04:02:08 +00:00
|
|
|
if fetch is None:
|
|
|
|
return None
|
|
|
|
|
2017-03-04 09:13:22 +00:00
|
|
|
parentid = fetch[self.group_sql_index['parentid']]
|
2016-12-17 04:02:08 +00:00
|
|
|
return self.group_getter(id=parentid)
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def join_group(self, group, *, commit=True):
|
|
|
|
'''
|
|
|
|
Leave the current group, then call `group.add(self)`.
|
|
|
|
'''
|
|
|
|
if not isinstance(group, type(self)):
|
|
|
|
raise TypeError('Group must also be %s' % type(self))
|
|
|
|
|
|
|
|
if self == group:
|
|
|
|
raise ValueError('Cant join self')
|
|
|
|
|
|
|
|
self.leave_group(commit=commit)
|
|
|
|
group.add(self, commit=commit)
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def leave_group(self, *, commit=True):
|
|
|
|
'''
|
|
|
|
Leave the current group and become independent.
|
|
|
|
'''
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb._cached_frozen_children = None
|
2017-03-04 09:13:22 +00:00
|
|
|
cur.execute(
|
|
|
|
'DELETE FROM %s WHERE memberid == ?' % self.group_table,
|
|
|
|
[self.id]
|
|
|
|
)
|
2017-05-02 04:16:10 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - leave group')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
def walk_children(self):
|
|
|
|
yield self
|
|
|
|
for child in self.children():
|
|
|
|
yield from child.walk_children()
|
|
|
|
|
|
|
|
def walk_parents(self):
|
|
|
|
parent = self.parent()
|
|
|
|
while parent is not None:
|
|
|
|
yield parent
|
|
|
|
parent = parent.parent()
|
|
|
|
|
|
|
|
|
|
|
|
class Album(ObjectBase, GroupableMixin):
|
2017-03-04 09:13:22 +00:00
|
|
|
group_sql_index = constants.SQL_ALBUMGROUP
|
|
|
|
group_table = 'album_group_rel'
|
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2017-03-15 04:18:42 +00:00
|
|
|
db_row = helpers.parallel_to_dict(constants.SQL_ALBUM_COLUMNS, db_row)
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
|
|
|
self.title = db_row['title']
|
|
|
|
self.description = db_row['description']
|
2016-12-17 04:02:08 +00:00
|
|
|
self.name = 'Album %s' % self.id
|
|
|
|
self.group_getter = self.photodb.get_album
|
2017-03-23 05:54:17 +00:00
|
|
|
self._sum_bytes_photos = None
|
|
|
|
self._sum_bytes_albums = None
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2016-12-21 03:53:06 +00:00
|
|
|
def __hash__(self):
|
|
|
|
return hash(self.id)
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def __repr__(self):
|
|
|
|
return 'Album:{id}'.format(id=self.id)
|
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
def _uncache(self):
|
|
|
|
self.photodb.caches['album'].remove(self.id)
|
|
|
|
self._sum_bytes_photos = None
|
|
|
|
self._sum_bytes_albums = None
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2017-05-02 03:43:45 +00:00
|
|
|
def add_associated_directory(self, filepath, *, commit=True):
|
|
|
|
filepath = pathclass.Path(filepath)
|
|
|
|
if not filepath.is_dir:
|
|
|
|
raise ValueError('%s is not a directory' % filepath)
|
|
|
|
|
|
|
|
try:
|
|
|
|
existing = self.photodb.get_album_by_path(filepath)
|
|
|
|
except exceptions.NoSuchAlbum:
|
|
|
|
existing = None
|
|
|
|
|
|
|
|
if existing is None:
|
|
|
|
pass
|
|
|
|
elif existing == self:
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
raise exceptions.AlbumExists(filepath)
|
|
|
|
|
|
|
|
data = {
|
|
|
|
'albumid': self.id,
|
|
|
|
'directory': filepath.absolute_path,
|
|
|
|
}
|
|
|
|
(qmarks, bindings) = helpers.binding_filler(constants.SQL_ALBUM_DIRECTORY_COLUMNS, data)
|
|
|
|
query = 'INSERT INTO album_associated_directories VALUES(%s)' % qmarks
|
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute(query, bindings)
|
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add associated directory')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def add_photo(self, photo, *, commit=True):
|
|
|
|
if self.photodb != photo.photodb:
|
|
|
|
raise ValueError('Not the same PhotoDB')
|
|
|
|
if self.has_photo(photo):
|
|
|
|
return
|
2017-03-04 07:44:43 +00:00
|
|
|
self.photodb.log.debug('Adding photo %s to %s' % (photo, self))
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('INSERT INTO album_photo_rel VALUES(?, ?)', [self.id, photo.id])
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add photo to album')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def add_tag_to_all(self, tag, *, nested_children=True, commit=True):
|
2017-03-10 23:01:12 +00:00
|
|
|
'''
|
|
|
|
Add this tag to every photo in the album. Saves you from having to
|
|
|
|
write the for-loop yourself.
|
|
|
|
|
|
|
|
nested_children:
|
|
|
|
If True, add the tag to photos contained in sub-albums.
|
|
|
|
Otherwise, only local photos.
|
|
|
|
'''
|
2016-12-17 04:02:08 +00:00
|
|
|
tag = self.photodb.get_tag(tag)
|
|
|
|
if nested_children:
|
|
|
|
photos = self.walk_photos()
|
|
|
|
else:
|
|
|
|
photos = self.photos()
|
|
|
|
for photo in photos:
|
|
|
|
photo.add_tag(tag, commit=False)
|
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add tag to all')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-04-23 04:39:07 +00:00
|
|
|
def associated_directories(self):
|
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute(
|
|
|
|
'SELECT directory FROM album_associated_directories WHERE albumid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
|
|
|
directories = [x[0] for x in cur.fetchall()]
|
|
|
|
directories = [pathclass.Path(x) for x in directories]
|
|
|
|
return directories
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def delete(self, *, delete_children=False, commit=True):
|
|
|
|
self.photodb.log.debug('Deleting album {album:r}'.format(album=self))
|
|
|
|
GroupableMixin.delete(self, delete_children=delete_children, commit=False)
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('DELETE FROM albums WHERE id == ?', [self.id])
|
|
|
|
cur.execute('DELETE FROM album_photo_rel WHERE albumid == ?', [self.id])
|
2017-03-20 01:48:12 +00:00
|
|
|
cur.execute('DELETE FROM album_associated_directories WHERE albumid == ?', [self.id])
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - delete album')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-03-11 01:08:38 +00:00
|
|
|
@property
|
|
|
|
def display_name(self):
|
|
|
|
if self.title:
|
|
|
|
return self.title
|
|
|
|
else:
|
|
|
|
return self.id
|
|
|
|
|
2017-05-02 03:41:56 +00:00
|
|
|
@decorators.required_feature('enable_album_edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def edit(self, title=None, description=None, *, commit=True):
|
2017-03-10 23:01:12 +00:00
|
|
|
'''
|
|
|
|
Change the title or description. Leave None to keep current value.
|
|
|
|
'''
|
2016-12-17 04:02:08 +00:00
|
|
|
if title is None:
|
|
|
|
title = self.title
|
|
|
|
if description is None:
|
|
|
|
description = self.description
|
2017-03-04 07:44:43 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
2017-01-30 01:47:59 +00:00
|
|
|
cur.execute(
|
2016-12-17 04:02:08 +00:00
|
|
|
'UPDATE albums SET title=?, description=? WHERE id == ?',
|
|
|
|
[title, description, self.id]
|
|
|
|
)
|
|
|
|
self.title = title
|
|
|
|
self.description = description
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - edit album')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
def has_photo(self, photo):
|
|
|
|
if not isinstance(photo, Photo):
|
|
|
|
raise TypeError('Must be a %s' % Photo)
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute(
|
2016-12-17 04:02:08 +00:00
|
|
|
'SELECT * FROM album_photo_rel WHERE albumid == ? AND photoid == ?',
|
|
|
|
[self.id, photo.id]
|
|
|
|
)
|
2017-01-30 01:47:59 +00:00
|
|
|
return cur.fetchone() is not None
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
def photos(self):
|
|
|
|
photos = []
|
|
|
|
generator = helpers.select_generator(
|
|
|
|
self.photodb.sql,
|
|
|
|
'SELECT * FROM album_photo_rel WHERE albumid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
|
|
|
for photo in generator:
|
|
|
|
photoid = photo[constants.SQL_ALBUMPHOTO['photoid']]
|
|
|
|
photo = self.photodb.get_photo(photoid)
|
|
|
|
photos.append(photo)
|
|
|
|
photos.sort(key=lambda x: x.basename.lower())
|
|
|
|
return photos
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def remove_photo(self, photo, *, commit=True):
|
|
|
|
if not self.has_photo(photo):
|
|
|
|
return
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute(
|
2016-12-17 04:02:08 +00:00
|
|
|
'DELETE FROM album_photo_rel WHERE albumid == ? AND photoid == ?',
|
|
|
|
[self.id, photo.id]
|
|
|
|
)
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
2017-02-05 02:30:02 +00:00
|
|
|
self.photodb.log.debug('Committing - remove photo from album')
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-02-25 06:07:59 +00:00
|
|
|
def sum_bytes(self, recurse=True, string=False):
|
2017-03-23 05:54:17 +00:00
|
|
|
if self._sum_bytes_photos is None:
|
|
|
|
self._sum_bytes_photos = sum(photo.bytes for photo in self.photos())
|
|
|
|
total = self._sum_bytes_photos
|
2017-02-25 06:07:59 +00:00
|
|
|
|
2017-03-23 05:54:17 +00:00
|
|
|
if recurse:
|
|
|
|
if self._sum_bytes_albums is None:
|
|
|
|
self._sum_bytes_albums = sum(a.sum_bytes(recurse=True) for a in self.children())
|
|
|
|
total += self._sum_bytes_albums
|
2017-02-25 06:07:59 +00:00
|
|
|
|
|
|
|
if string:
|
|
|
|
return bytestring.bytestring(total)
|
|
|
|
else:
|
|
|
|
return total
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def walk_photos(self):
|
|
|
|
yield from self.photos()
|
|
|
|
children = self.walk_children()
|
|
|
|
# The first yield is itself
|
|
|
|
next(children)
|
|
|
|
for child in children:
|
|
|
|
yield from child.walk_photos()
|
|
|
|
|
2017-02-05 02:30:02 +00:00
|
|
|
|
|
|
|
class Bookmark(ObjectBase):
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2017-03-15 04:18:42 +00:00
|
|
|
db_row = helpers.parallel_to_dict(constants.SQL_BOOKMARK_COLUMNS, db_row)
|
2017-02-05 02:30:02 +00:00
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
|
|
|
self.title = db_row['title']
|
|
|
|
self.url = db_row['url']
|
|
|
|
self.author_id = db_row['author_id']
|
2017-02-05 02:30:02 +00:00
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return 'Bookmark:{id}'.format(id=self.id)
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2017-02-05 02:30:02 +00:00
|
|
|
def delete(self, *, commit=True):
|
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('DELETE FROM bookmarks WHERE id == ?', [self.id])
|
|
|
|
if commit:
|
2017-03-17 04:44:49 +00:00
|
|
|
self.photodb.commit()
|
2017-02-05 02:30:02 +00:00
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2017-02-05 02:30:02 +00:00
|
|
|
def edit(self, title=None, url=None, *, commit=True):
|
|
|
|
if title is None and url is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
if title is not None:
|
|
|
|
self.title = title
|
|
|
|
|
|
|
|
if url is not None:
|
|
|
|
self.url = url
|
|
|
|
|
|
|
|
cur = self.photodb.sql.cursor()
|
2017-03-08 04:20:12 +00:00
|
|
|
cur.execute(
|
|
|
|
'UPDATE bookmarks SET title = ?, url = ? WHERE id == ?',
|
|
|
|
[self.title, self.url, self.id]
|
|
|
|
)
|
2017-02-05 02:30:02 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - edit bookmark')
|
2017-03-17 04:44:49 +00:00
|
|
|
self.photodb.commit()
|
2017-02-05 02:30:02 +00:00
|
|
|
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
class Photo(ObjectBase):
|
|
|
|
'''
|
|
|
|
A PhotoDB entry containing information about an image file.
|
|
|
|
Photo objects cannot exist without a corresponding PhotoDB object, because
|
|
|
|
Photos are not the actual image data, just the database entry.
|
|
|
|
'''
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2017-03-15 04:18:42 +00:00
|
|
|
db_row = helpers.parallel_to_dict(constants.SQL_PHOTO_COLUMNS, db_row)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
self.real_filepath = helpers.normalize_filepath(db_row['filepath'], allowed=':\\/')
|
2016-12-17 04:02:08 +00:00
|
|
|
self.real_path = pathclass.Path(self.real_filepath)
|
2016-12-21 05:33:14 +00:00
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
|
|
|
self.created = db_row['created']
|
|
|
|
self.author_id = db_row['author_id']
|
|
|
|
self.filepath = db_row['override_filename'] or self.real_path.absolute_path
|
|
|
|
self.basename = db_row['override_filename'] or self.real_path.basename
|
|
|
|
self.extension = db_row['extension']
|
|
|
|
self.tagged_at = db_row['tagged_at']
|
2016-12-21 05:33:14 +00:00
|
|
|
|
2016-12-25 02:34:34 +00:00
|
|
|
if self.extension == '':
|
|
|
|
self.dot_extension = ''
|
|
|
|
else:
|
|
|
|
self.dot_extension = '.' + self.extension
|
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
self.area = db_row['area']
|
|
|
|
self.bytes = db_row['bytes']
|
|
|
|
self.duration = db_row['duration']
|
|
|
|
self.width = db_row['width']
|
|
|
|
self.height = db_row['height']
|
|
|
|
self.ratio = db_row['ratio']
|
|
|
|
self.thumbnail = db_row['thumbnail']
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2016-12-24 03:49:51 +00:00
|
|
|
self.mimetype = helpers.get_mimetype(self.real_filepath)
|
2017-02-28 07:39:06 +00:00
|
|
|
if self.mimetype is None:
|
|
|
|
self.simple_mimetype = None
|
|
|
|
else:
|
|
|
|
self.simple_mimetype = self.mimetype.split('/')[0]
|
2016-12-24 03:49:51 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def __reinit__(self):
|
|
|
|
'''
|
|
|
|
Reload the row from the database and do __init__ with them.
|
|
|
|
'''
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('SELECT * FROM photos WHERE id == ?', [self.id])
|
|
|
|
row = cur.fetchone()
|
2016-12-17 04:02:08 +00:00
|
|
|
self.__init__(self.photodb, row)
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return 'Photo:{id}'.format(id=self.id)
|
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
def _uncache(self):
|
|
|
|
self.photodb.caches['photo'].remove(self.id)
|
|
|
|
|
2017-05-02 03:41:56 +00:00
|
|
|
@decorators.required_feature('enable_photo_add_remove_tag')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def add_tag(self, tag, *, commit=True):
|
|
|
|
tag = self.photodb.get_tag(tag)
|
|
|
|
|
2017-03-10 22:04:50 +00:00
|
|
|
existing = self.has_tag(tag, check_children=False)
|
|
|
|
if existing:
|
|
|
|
return existing
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-03-08 04:20:12 +00:00
|
|
|
# If the new tag is less specific than one we already have,
|
|
|
|
# keep our current one.
|
2016-12-17 04:02:08 +00:00
|
|
|
existing = self.has_tag(tag, check_children=True)
|
|
|
|
if existing:
|
|
|
|
message = 'Preferring existing {exi:s} over {tag:s}'.format(exi=existing, tag=tag)
|
|
|
|
self.photodb.log.debug(message)
|
2017-03-10 22:04:50 +00:00
|
|
|
return existing
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-03-08 04:20:12 +00:00
|
|
|
# If the new tag is more specific, remove our current one for it.
|
2016-12-17 04:02:08 +00:00
|
|
|
for parent in tag.walk_parents():
|
|
|
|
if self.has_tag(parent, check_children=False):
|
2017-03-15 04:18:42 +00:00
|
|
|
message = 'Preferring new {tag:s} over {par:s}'.format(tag=tag, par=parent)
|
|
|
|
self.photodb.log.debug(message)
|
2016-12-17 04:02:08 +00:00
|
|
|
self.remove_tag(parent)
|
|
|
|
|
|
|
|
self.photodb.log.debug('Applying tag {tag:s} to photo {pho:s}'.format(tag=tag, pho=self))
|
|
|
|
now = int(helpers.now())
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('INSERT INTO photo_tag_rel VALUES(?, ?)', [self.id, tag.id])
|
|
|
|
cur.execute('UPDATE photos SET tagged_at = ? WHERE id == ?', [now, self.id])
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add photo tag')
|
|
|
|
self.photodb.commit()
|
2017-03-10 22:04:50 +00:00
|
|
|
return tag
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
def albums(self):
|
|
|
|
'''
|
|
|
|
Return the albums of which this photo is a member.
|
|
|
|
'''
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('SELECT albumid FROM album_photo_rel WHERE photoid == ?', [self.id])
|
|
|
|
fetch = cur.fetchall()
|
2016-12-17 04:02:08 +00:00
|
|
|
albums = [self.photodb.get_album(f[0]) for f in fetch]
|
|
|
|
return albums
|
|
|
|
|
2016-12-20 22:54:23 +00:00
|
|
|
def author(self):
|
|
|
|
return self.photodb.get_user(id=self.author_id)
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def bytestring(self):
|
|
|
|
return bytestring.bytestring(self.bytes)
|
|
|
|
|
|
|
|
def copy_tags(self, other_photo):
|
2017-03-10 23:01:12 +00:00
|
|
|
'''
|
|
|
|
Take all of the tags owned by other_photo and apply them to this photo.
|
|
|
|
'''
|
2016-12-17 04:02:08 +00:00
|
|
|
for tag in other_photo.tags():
|
|
|
|
self.add_tag(tag)
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def delete(self, *, delete_file=False, commit=True):
|
|
|
|
'''
|
|
|
|
Delete the Photo and its relation to any tags and albums.
|
|
|
|
'''
|
|
|
|
self.photodb.log.debug('Deleting photo {photo:r}'.format(photo=self))
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('DELETE FROM photos WHERE id == ?', [self.id])
|
|
|
|
cur.execute('DELETE FROM photo_tag_rel WHERE photoid == ?', [self.id])
|
|
|
|
cur.execute('DELETE FROM album_photo_rel WHERE photoid == ?', [self.id])
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
if delete_file:
|
|
|
|
path = self.real_path.absolute_path
|
|
|
|
if commit:
|
|
|
|
os.remove(path)
|
|
|
|
else:
|
|
|
|
queue_action = {'action': os.remove, 'args': [path]}
|
|
|
|
self.photodb.on_commit_queue.append(queue_action)
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - delete photo')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-03-10 23:07:34 +00:00
|
|
|
@property
|
2016-12-21 00:33:40 +00:00
|
|
|
def duration_string(self):
|
|
|
|
if self.duration is None:
|
|
|
|
return None
|
|
|
|
return helpers.seconds_to_hms(self.duration)
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
#@decorators.time_me
|
2017-05-02 03:41:56 +00:00
|
|
|
@decorators.required_feature('enable_photo_generate_thumbnail')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def generate_thumbnail(self, *, commit=True, **special):
|
|
|
|
'''
|
|
|
|
special:
|
2017-03-20 00:34:19 +00:00
|
|
|
For videos, you can provide a `timestamp` to take the thumbnail at.
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
hopeful_filepath = self.make_thumbnail_filepath()
|
2016-12-25 01:13:45 +00:00
|
|
|
hopeful_filepath = hopeful_filepath.relative_path
|
|
|
|
#print(hopeful_filepath)
|
2016-12-17 04:02:08 +00:00
|
|
|
return_filepath = None
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
if self.simple_mimetype == 'image':
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.log.debug('Thumbnailing %s' % self.real_filepath)
|
|
|
|
try:
|
|
|
|
image = PIL.Image.open(self.real_filepath)
|
|
|
|
except (OSError, ValueError):
|
|
|
|
pass
|
|
|
|
else:
|
|
|
|
(width, height) = image.size
|
|
|
|
(new_width, new_height) = helpers.fit_into_bounds(
|
|
|
|
image_width=width,
|
|
|
|
image_height=height,
|
|
|
|
frame_width=self.photodb.config['thumbnail_width'],
|
|
|
|
frame_height=self.photodb.config['thumbnail_height'],
|
|
|
|
)
|
|
|
|
if new_width < width:
|
|
|
|
image = image.resize((new_width, new_height))
|
2017-03-05 03:27:03 +00:00
|
|
|
|
|
|
|
if image.mode == 'RGBA':
|
|
|
|
background = helpers.checkerboard_image(
|
|
|
|
color_1=(256, 256, 256),
|
|
|
|
color_2=(128, 128, 128),
|
|
|
|
image_size=image.size,
|
|
|
|
checker_size=8,
|
|
|
|
)
|
|
|
|
# Thanks Yuji Tomita
|
|
|
|
# http://stackoverflow.com/a/9459208
|
|
|
|
background.paste(image, mask=image.split()[3])
|
|
|
|
image = background
|
|
|
|
|
|
|
|
image = image.convert('RGB')
|
2016-12-17 04:02:08 +00:00
|
|
|
image.save(hopeful_filepath, quality=50)
|
|
|
|
return_filepath = hopeful_filepath
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
elif self.simple_mimetype == 'video' and constants.ffmpeg:
|
2016-12-17 04:02:08 +00:00
|
|
|
#print('video')
|
|
|
|
probe = constants.ffmpeg.probe(self.real_filepath)
|
|
|
|
try:
|
|
|
|
if probe.video:
|
|
|
|
size = helpers.fit_into_bounds(
|
|
|
|
image_width=probe.video.video_width,
|
|
|
|
image_height=probe.video.video_height,
|
|
|
|
frame_width=self.photodb.config['thumbnail_width'],
|
|
|
|
frame_height=self.photodb.config['thumbnail_height'],
|
|
|
|
)
|
|
|
|
size = '%dx%d' % size
|
|
|
|
duration = probe.video.duration
|
|
|
|
if 'timestamp' in special:
|
|
|
|
timestamp = special['timestamp']
|
|
|
|
else:
|
|
|
|
if duration < 3:
|
|
|
|
timestamp = 0
|
|
|
|
else:
|
|
|
|
timestamp = 2
|
2016-12-20 22:23:05 +00:00
|
|
|
constants.ffmpeg.thumbnail(
|
|
|
|
self.real_filepath,
|
|
|
|
outfile=hopeful_filepath,
|
|
|
|
quality=2,
|
|
|
|
size=size,
|
|
|
|
time=timestamp,
|
|
|
|
)
|
2016-12-17 04:02:08 +00:00
|
|
|
except:
|
|
|
|
traceback.print_exc()
|
|
|
|
else:
|
|
|
|
return_filepath = hopeful_filepath
|
|
|
|
|
|
|
|
|
|
|
|
if return_filepath != self.thumbnail:
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute(
|
2016-12-20 22:23:05 +00:00
|
|
|
'UPDATE photos SET thumbnail = ? WHERE id == ?',
|
|
|
|
[return_filepath, self.id]
|
|
|
|
)
|
2016-12-17 04:02:08 +00:00
|
|
|
self.thumbnail = return_filepath
|
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - generate thumbnail')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
self.__reinit__()
|
|
|
|
return self.thumbnail
|
|
|
|
|
|
|
|
def has_tag(self, tag, *, check_children=True):
|
|
|
|
'''
|
2017-03-08 04:20:12 +00:00
|
|
|
Return the Tag object if this photo contains that tag.
|
|
|
|
Otherwise return False.
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
check_children:
|
2017-03-08 04:20:12 +00:00
|
|
|
If True, children of the requested tag are accepted.
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
tag = self.photodb.get_tag(tag)
|
|
|
|
|
|
|
|
if check_children:
|
|
|
|
tags = tag.walk_children()
|
|
|
|
else:
|
|
|
|
tags = [tag]
|
|
|
|
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
2016-12-17 04:02:08 +00:00
|
|
|
for tag in tags:
|
2017-01-30 01:47:59 +00:00
|
|
|
cur.execute(
|
2016-12-17 04:02:08 +00:00
|
|
|
'SELECT * FROM photo_tag_rel WHERE photoid == ? AND tagid == ?',
|
|
|
|
[self.id, tag.id]
|
|
|
|
)
|
2017-01-30 01:47:59 +00:00
|
|
|
if cur.fetchone() is not None:
|
2016-12-17 04:02:08 +00:00
|
|
|
return tag
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
def make_thumbnail_filepath(self):
|
2017-03-08 04:20:12 +00:00
|
|
|
'''
|
|
|
|
Create the filepath that should be the location of our thumbnail.
|
|
|
|
'''
|
2016-12-17 04:02:08 +00:00
|
|
|
chunked_id = helpers.chunk_sequence(self.id, 3)
|
|
|
|
basename = chunked_id[-1]
|
|
|
|
folder = chunked_id[:-1]
|
|
|
|
folder = os.sep.join(folder)
|
2016-12-25 01:13:45 +00:00
|
|
|
folder = self.photodb.thumbnail_directory.join(folder)
|
2016-12-17 04:02:08 +00:00
|
|
|
if folder:
|
2016-12-25 01:13:45 +00:00
|
|
|
os.makedirs(folder.absolute_path, exist_ok=True)
|
|
|
|
hopeful_filepath = folder.with_child(basename + '.jpg')
|
2016-12-17 04:02:08 +00:00
|
|
|
return hopeful_filepath
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
#@decorators.time_me
|
2017-05-02 03:41:56 +00:00
|
|
|
@decorators.required_feature('enable_photo_reload_metadata')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def reload_metadata(self, *, commit=True):
|
|
|
|
'''
|
|
|
|
Load the file's height, width, etc as appropriate for this type of file.
|
|
|
|
'''
|
|
|
|
self.bytes = os.path.getsize(self.real_filepath)
|
|
|
|
self.width = None
|
|
|
|
self.height = None
|
|
|
|
self.area = None
|
|
|
|
self.ratio = None
|
|
|
|
self.duration = None
|
|
|
|
|
2017-02-05 02:30:02 +00:00
|
|
|
self.photodb.log.debug('Reloading metadata for {photo:r}'.format(photo=self))
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
if self.simple_mimetype == 'image':
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
|
|
|
image = PIL.Image.open(self.real_filepath)
|
|
|
|
except (OSError, ValueError):
|
|
|
|
self.photodb.log.debug('Failed to read image data for {photo:r}'.format(photo=self))
|
|
|
|
else:
|
|
|
|
(self.width, self.height) = image.size
|
|
|
|
image.close()
|
2017-02-28 07:39:06 +00:00
|
|
|
#self.photodb.log.debug('Loaded image data for {photo:r}'.format(photo=self))
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
elif self.simple_mimetype == 'video' and constants.ffmpeg:
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
|
|
|
probe = constants.ffmpeg.probe(self.real_filepath)
|
|
|
|
if probe and probe.video:
|
|
|
|
self.duration = probe.format.duration or probe.video.duration
|
|
|
|
self.width = probe.video.video_width
|
|
|
|
self.height = probe.video.video_height
|
|
|
|
except:
|
|
|
|
traceback.print_exc()
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
elif self.simple_mimetype == 'audio' and constants.ffmpeg:
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
|
|
|
probe = constants.ffmpeg.probe(self.real_filepath)
|
|
|
|
if probe and probe.audio:
|
|
|
|
self.duration = probe.audio.duration
|
|
|
|
except:
|
|
|
|
traceback.print_exc()
|
|
|
|
|
|
|
|
if self.width and self.height:
|
|
|
|
self.area = self.width * self.height
|
|
|
|
self.ratio = round(self.width / self.height, 2)
|
|
|
|
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute(
|
2016-12-17 04:02:08 +00:00
|
|
|
'UPDATE photos SET width=?, height=?, area=?, ratio=?, duration=?, bytes=? WHERE id==?',
|
|
|
|
[self.width, self.height, self.area, self.ratio, self.duration, self.bytes, self.id],
|
|
|
|
)
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - reload metadata')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2017-03-23 06:18:09 +00:00
|
|
|
def relocate(self, new_filepath, *, allow_duplicates=False, commit=True):
|
|
|
|
'''
|
|
|
|
Point the Photo object to a different filepath.
|
|
|
|
|
|
|
|
DOES NOT MOVE THE FILE, only acknowledges a move that was performed
|
|
|
|
outside of the system.
|
|
|
|
To rename or move the file, use `rename_file`.
|
|
|
|
|
|
|
|
allow_duplicates:
|
|
|
|
Allow even if there is another Photo for that path.
|
|
|
|
'''
|
|
|
|
new_filepath = pathclass.Path(new_filepath)
|
|
|
|
if not new_filepath.is_file:
|
|
|
|
raise FileNotFoundError(new_filepath.absolute_path)
|
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
if not allow_duplicates:
|
|
|
|
try:
|
|
|
|
existing = self.photodb.get_photo_by_path(new_filepath)
|
|
|
|
except exceptions.NoSuchPhoto:
|
|
|
|
# Good.
|
|
|
|
pass
|
|
|
|
else:
|
|
|
|
raise exceptions.PhotoExists(existing)
|
|
|
|
cur.execute(
|
|
|
|
'UPDATE photos SET filepath = ? WHERE id == ?',
|
|
|
|
[new_filepath.absolute_path, self.id]
|
|
|
|
)
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2017-03-23 06:18:09 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Commit - relocate photo')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-05-02 03:41:56 +00:00
|
|
|
@decorators.required_feature('enable_photo_add_remove_tag')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def remove_tag(self, tag, *, commit=True):
|
|
|
|
tag = self.photodb.get_tag(tag)
|
|
|
|
|
|
|
|
self.photodb.log.debug('Removing tag {t} from photo {p}'.format(t=repr(tag), p=repr(self)))
|
|
|
|
tags = list(tag.walk_children())
|
2017-01-30 01:47:59 +00:00
|
|
|
|
|
|
|
cur = self.photodb.sql.cursor()
|
2016-12-17 04:02:08 +00:00
|
|
|
for tag in tags:
|
2017-01-30 01:47:59 +00:00
|
|
|
cur.execute(
|
2016-12-17 04:02:08 +00:00
|
|
|
'DELETE FROM photo_tag_rel WHERE photoid == ? AND tagid == ?',
|
|
|
|
[self.id, tag.id]
|
|
|
|
)
|
|
|
|
now = int(helpers.now())
|
2017-01-30 01:47:59 +00:00
|
|
|
cur.execute('UPDATE photos SET tagged_at = ? WHERE id == ?', [now, self.id])
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - remove photo tag')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def rename_file(self, new_filename, *, move=False, commit=True):
|
|
|
|
'''
|
|
|
|
Rename the file on the disk as well as in the database.
|
2017-03-08 04:20:12 +00:00
|
|
|
|
|
|
|
move:
|
|
|
|
If True, allow the file to be moved into another directory.
|
|
|
|
Otherwise, the rename must be local.
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
old_path = self.real_path
|
|
|
|
old_path.correct_case()
|
|
|
|
|
2016-12-21 03:53:06 +00:00
|
|
|
new_filename = helpers.normalize_filepath(new_filename, allowed=':\\/')
|
2016-12-17 04:02:08 +00:00
|
|
|
if os.path.dirname(new_filename) == '':
|
|
|
|
new_path = old_path.parent.with_child(new_filename)
|
|
|
|
else:
|
|
|
|
new_path = pathclass.Path(new_filename)
|
2017-02-25 06:07:59 +00:00
|
|
|
#new_path.correct_case()
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
self.photodb.log.debug(old_path)
|
|
|
|
self.photodb.log.debug(new_path)
|
|
|
|
if (new_path.parent != old_path.parent) and not move:
|
|
|
|
raise ValueError('Cannot move the file without param move=True')
|
|
|
|
|
|
|
|
if new_path.absolute_path == old_path.absolute_path:
|
|
|
|
raise ValueError('The new and old names are the same')
|
|
|
|
|
|
|
|
os.makedirs(new_path.parent.absolute_path, exist_ok=True)
|
|
|
|
|
2017-02-26 06:47:20 +00:00
|
|
|
if new_path.normcase != old_path.normcase:
|
|
|
|
# It's possible on case-insensitive systems to have the paths point
|
|
|
|
# to the same place while being differently cased, thus we couldn't
|
|
|
|
# make the intermediate link.
|
|
|
|
# Instead, we will do a simple rename in just a moment.
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
|
|
|
os.link(old_path.absolute_path, new_path.absolute_path)
|
|
|
|
except OSError:
|
|
|
|
spinal.copy_file(old_path, new_path)
|
|
|
|
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute(
|
2016-12-17 04:02:08 +00:00
|
|
|
'UPDATE photos SET filepath = ? WHERE filepath == ?',
|
|
|
|
[new_path.absolute_path, old_path.absolute_path]
|
|
|
|
)
|
|
|
|
|
2017-02-26 06:47:20 +00:00
|
|
|
if new_path.normcase == old_path.normcase:
|
|
|
|
# If they are equivalent but differently cased, just rename.
|
|
|
|
action = os.rename
|
|
|
|
args = [old_path.absolute_path, new_path.absolute_path]
|
|
|
|
else:
|
|
|
|
# Delete the original, leaving only the new copy / hardlink.
|
|
|
|
action = os.remove
|
|
|
|
args = [old_path.absolute_path]
|
2017-03-23 07:04:44 +00:00
|
|
|
|
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
2017-02-26 06:47:20 +00:00
|
|
|
action(*args)
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.log.debug('Committing - rename file')
|
|
|
|
self.photodb.commit()
|
|
|
|
else:
|
2017-03-10 13:18:49 +00:00
|
|
|
queue_action = {'action': action, 'args': args}
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.on_commit_queue.append(queue_action)
|
|
|
|
|
|
|
|
self.__reinit__()
|
|
|
|
|
2016-12-21 00:33:40 +00:00
|
|
|
def sorted_tags(self):
|
|
|
|
tags = self.tags()
|
|
|
|
tags.sort(key=lambda x: x.qualified_name())
|
|
|
|
return tags
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def tags(self):
|
|
|
|
'''
|
|
|
|
Return the tags assigned to this Photo.
|
|
|
|
'''
|
|
|
|
tags = []
|
|
|
|
generator = helpers.select_generator(
|
|
|
|
self.photodb.sql,
|
|
|
|
'SELECT * FROM photo_tag_rel WHERE photoid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
|
|
|
for tag in generator:
|
|
|
|
tagid = tag[constants.SQL_PHOTOTAG['tagid']]
|
|
|
|
tag = self.photodb.get_tag(id=tagid)
|
|
|
|
tags.append(tag)
|
|
|
|
return tags
|
|
|
|
|
|
|
|
|
|
|
|
class Tag(ObjectBase, GroupableMixin):
|
|
|
|
'''
|
|
|
|
A Tag, which can be applied to Photos for organization.
|
|
|
|
'''
|
2017-03-04 09:13:22 +00:00
|
|
|
group_sql_index = constants.SQL_TAGGROUP
|
|
|
|
group_table = 'tag_group_rel'
|
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2017-03-15 04:18:42 +00:00
|
|
|
db_row = helpers.parallel_to_dict(constants.SQL_TAG_COLUMNS, db_row)
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
|
|
|
self.name = db_row['name']
|
2016-12-17 04:02:08 +00:00
|
|
|
self.group_getter = self.photodb.get_tag
|
|
|
|
self._cached_qualified_name = None
|
|
|
|
|
|
|
|
def __eq__(self, other):
|
|
|
|
return self.name == other or ObjectBase.__eq__(self, other)
|
|
|
|
|
|
|
|
def __hash__(self):
|
|
|
|
return hash(self.name)
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
rep = 'Tag:{id}:{name}'.format(name=self.name, id=self.id)
|
|
|
|
return rep
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
rep = 'Tag:{name}'.format(name=self.name)
|
|
|
|
return rep
|
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
def _uncache(self):
|
|
|
|
self.photodb.caches['tag'].remove(self.id)
|
2017-05-02 04:16:10 +00:00
|
|
|
self._cached_qualified_name = None
|
2017-03-23 07:04:44 +00:00
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def add_synonym(self, synname, *, commit=True):
|
|
|
|
synname = self.photodb.normalize_tagname(synname)
|
|
|
|
|
2017-03-05 05:56:23 +00:00
|
|
|
print(synname, self.name)
|
2016-12-17 04:02:08 +00:00
|
|
|
if synname == self.name:
|
2017-03-05 05:56:23 +00:00
|
|
|
raise exceptions.CantSynonymSelf()
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
try:
|
2017-03-05 06:27:24 +00:00
|
|
|
existing_tag = self.photodb.get_tag_by_name(synname)
|
2016-12-17 04:02:08 +00:00
|
|
|
except exceptions.NoSuchTag:
|
|
|
|
pass
|
|
|
|
else:
|
2017-03-05 06:27:24 +00:00
|
|
|
raise exceptions.TagExists(existing_tag)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
self.photodb._cached_frozen_children = None
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('INSERT INTO tag_synonyms VALUES(?, ?)', [synname, self.name])
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add synonym')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-04-21 01:28:11 +00:00
|
|
|
return synname
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def convert_to_synonym(self, mastertag, *, commit=True):
|
|
|
|
'''
|
2017-03-20 00:34:19 +00:00
|
|
|
Convert this tag into a synonym for a different tag.
|
|
|
|
All photos which possess the current tag will have it replaced with the
|
|
|
|
new master tag.
|
2016-12-17 04:02:08 +00:00
|
|
|
All synonyms of the old tag will point to the new tag.
|
|
|
|
|
|
|
|
Good for when two tags need to be merged under a single name.
|
|
|
|
'''
|
|
|
|
mastertag = self.photodb.get_tag(mastertag)
|
|
|
|
|
|
|
|
# Migrate the old tag's synonyms to the new one
|
|
|
|
# UPDATE is safe for this operation because there is no chance of duplicates.
|
|
|
|
self.photodb._cached_frozen_children = None
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute(
|
2016-12-17 04:02:08 +00:00
|
|
|
'UPDATE tag_synonyms SET mastername = ? WHERE mastername == ?',
|
|
|
|
[mastertag.name, self.name]
|
|
|
|
)
|
|
|
|
|
|
|
|
# Iterate over all photos with the old tag, and swap them to the new tag
|
|
|
|
# if they don't already have it.
|
2016-12-20 22:23:05 +00:00
|
|
|
generator = helpers.select_generator(
|
|
|
|
self.photodb.sql,
|
|
|
|
'SELECT * FROM photo_tag_rel WHERE tagid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
2016-12-17 04:02:08 +00:00
|
|
|
for relationship in generator:
|
|
|
|
photoid = relationship[constants.SQL_PHOTOTAG['photoid']]
|
2017-03-08 04:20:12 +00:00
|
|
|
cur.execute(
|
|
|
|
'SELECT * FROM photo_tag_rel WHERE photoid == ? AND tagid == ?',
|
|
|
|
[photoid, mastertag.id]
|
|
|
|
)
|
2017-01-30 01:47:59 +00:00
|
|
|
if cur.fetchone() is None:
|
2017-03-08 04:20:12 +00:00
|
|
|
cur.execute(
|
|
|
|
'INSERT INTO photo_tag_rel VALUES(?, ?)',
|
|
|
|
[photoid, mastertag.id]
|
|
|
|
)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
# Then delete the relationships with the old tag
|
|
|
|
self.delete()
|
|
|
|
|
|
|
|
# Enjoy your new life as a monk.
|
|
|
|
mastertag.add_synonym(self.name, commit=False)
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - convert to synonym')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def delete(self, *, delete_children=False, commit=True):
|
|
|
|
self.photodb.log.debug('Deleting tag {tag:r}'.format(tag=self))
|
|
|
|
self.photodb._cached_frozen_children = None
|
|
|
|
GroupableMixin.delete(self, delete_children=delete_children, commit=False)
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('DELETE FROM tags WHERE id == ?', [self.id])
|
|
|
|
cur.execute('DELETE FROM photo_tag_rel WHERE tagid == ?', [self.id])
|
|
|
|
cur.execute('DELETE FROM tag_synonyms WHERE mastername == ?', [self.name])
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - delete tag')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
def qualified_name(self):
|
|
|
|
'''
|
|
|
|
Return the 'group1.group2.tag' string for this tag.
|
|
|
|
'''
|
|
|
|
if self._cached_qualified_name:
|
|
|
|
return self._cached_qualified_name
|
|
|
|
qualname = self.name
|
|
|
|
for parent in self.walk_parents():
|
|
|
|
qualname = parent.name + '.' + qualname
|
|
|
|
self._cached_qualified_name = qualname
|
|
|
|
return qualname
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def remove_synonym(self, synname, *, commit=True):
|
|
|
|
'''
|
|
|
|
Delete a synonym.
|
|
|
|
This will have no effect on photos or other synonyms because
|
|
|
|
they always resolve to the master tag before application.
|
|
|
|
'''
|
|
|
|
synname = self.photodb.normalize_tagname(synname)
|
2017-02-26 08:33:26 +00:00
|
|
|
if synname == self.name:
|
|
|
|
raise exceptions.NoSuchSynonym(synname)
|
|
|
|
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
2017-03-08 04:20:12 +00:00
|
|
|
cur.execute(
|
|
|
|
'SELECT * FROM tag_synonyms WHERE mastername == ? AND name == ?',
|
|
|
|
[self.name, synname]
|
|
|
|
)
|
2017-01-30 01:47:59 +00:00
|
|
|
fetch = cur.fetchone()
|
2016-12-17 04:02:08 +00:00
|
|
|
if fetch is None:
|
|
|
|
raise exceptions.NoSuchSynonym(synname)
|
|
|
|
|
|
|
|
self.photodb._cached_frozen_children = None
|
2017-01-30 01:47:59 +00:00
|
|
|
cur.execute('DELETE FROM tag_synonyms WHERE name == ?', [synname])
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - remove synonym')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def rename(self, new_name, *, apply_to_synonyms=True, commit=True):
|
|
|
|
'''
|
|
|
|
Rename the tag. Does not affect its relation to Photos or tag groups.
|
|
|
|
'''
|
|
|
|
new_name = self.photodb.normalize_tagname(new_name)
|
|
|
|
if new_name == self.name:
|
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
2017-03-05 06:27:24 +00:00
|
|
|
existing_tag = self.photodb.get_tag(new_name)
|
2016-12-17 04:02:08 +00:00
|
|
|
except exceptions.NoSuchTag:
|
|
|
|
pass
|
|
|
|
else:
|
2017-03-05 06:27:24 +00:00
|
|
|
raise exceptions.TagExists(existing_tag)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
self._cached_qualified_name = None
|
|
|
|
self.photodb._cached_frozen_children = None
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('UPDATE tags SET name = ? WHERE id == ?', [new_name, self.id])
|
2016-12-17 04:02:08 +00:00
|
|
|
if apply_to_synonyms:
|
2017-01-30 01:47:59 +00:00
|
|
|
cur.execute(
|
2016-12-17 04:02:08 +00:00
|
|
|
'UPDATE tag_synonyms SET mastername = ? WHERE mastername = ?',
|
|
|
|
[new_name, self.name]
|
|
|
|
)
|
|
|
|
|
|
|
|
self.name = new_name
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - rename tag')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
def synonyms(self):
|
2017-01-30 01:47:59 +00:00
|
|
|
cur = self.photodb.sql.cursor()
|
|
|
|
cur.execute('SELECT name FROM tag_synonyms WHERE mastername == ?', [self.name])
|
|
|
|
fetch = cur.fetchall()
|
2016-12-17 04:02:08 +00:00
|
|
|
fetch = [f[0] for f in fetch]
|
|
|
|
fetch.sort()
|
|
|
|
return fetch
|
|
|
|
|
|
|
|
|
|
|
|
class User(ObjectBase):
|
|
|
|
'''
|
|
|
|
A dear friend of ours.
|
|
|
|
'''
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2017-03-15 04:18:42 +00:00
|
|
|
db_row = helpers.parallel_to_dict(constants.SQL_USER_COLUMNS, db_row)
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
|
|
|
self.username = db_row['username']
|
|
|
|
self.created = db_row['created']
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
rep = 'User:{id}:{username}'.format(id=self.id, username=self.username)
|
|
|
|
return rep
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
rep = 'User:{username}'.format(username=self.username)
|
|
|
|
return rep
|
2016-12-25 01:13:45 +00:00
|
|
|
|
2017-02-25 06:07:59 +00:00
|
|
|
|
2016-12-25 01:13:45 +00:00
|
|
|
class WarningBag:
|
|
|
|
def __init__(self):
|
|
|
|
self.warnings = set()
|
|
|
|
|
|
|
|
def add(self, warning):
|
|
|
|
self.warnings.add(warning)
|