Big! Liberate Groupables from strict heirarchy. Multiple parents.

I found that the strict heirarchy was not satisfying the situation
where one tag is the intersection of two others, but we can only
pick one as the parent

For example, does red_jacket belong under clothes.red_clothes or
clothes.jackets? A search for "red_clothes AND jackets" might
give us someone wearing red pants and a black jacket, so this
definitely needs to be a separate tag, but picking only one
parent for it is not sufficient. Now, a search for red_clothes
and a search for jackets will both find our red_jacket photo.

The change also applies to Albums because why not, and I'm sure
a similar case can be made.

Unfortunately this means tags no longer have one true qualname.
The concept of qualnames has not been completely phased out but
it's in progress.

This commit is very big because I was not sure for a long time
whether to go through with it, and so much stuff had to change
that I don't want to go back and figure out what could be grouped
together.
master
voussoir 2018-07-19 22:42:21 -07:00
parent c2cfa99752
commit 4c65ccaf68
19 changed files with 395 additions and 381 deletions

View File

@ -90,6 +90,7 @@ Here is a brief overview of the project to help you learn your way around:
- Fix album size cache when photo reload metadata and generally improve that validation. - Fix album size cache when photo reload metadata and generally improve that validation.
- Better bookmark url validation. - Better bookmark url validation.
- Create a textbox which gives autocomplete tag names. - Create a textbox which gives autocomplete tag names.
- In the same vein, use a dedicated endpoint with etag caching for providing the full list of tag names, so the client can check up on it and store the results in localstorage, and use for the autocomplete system.
- Consider if the "did you commit too early" warning should actually be an exception. - Consider if the "did you commit too early" warning should actually be an exception.
- Extension currently does not believe in the override filename. On one hand this is kind of good because if they override the name to have no extension, we can still provide a downloadable file with the correct extension by remembering it. But on the other hand it does break the illusion of override_filename. - Extension currently does not believe in the override filename. On one hand this is kind of good because if they override the name to have no extension, we can still provide a downloadable file with the correct extension by remembering it. But on the other hand it does break the illusion of override_filename.
- When batch fetching objects, consider whether or not a NoSuch should be raised. Perhaps a warningbag should be used. - When batch fetching objects, consider whether or not a NoSuch should be raised. Perhaps a warningbag should be used.

View File

@ -445,6 +445,7 @@ def split_easybake_string(ebstring):
if not tagname: if not tagname:
raise exceptions.EasyBakeError('No tag supplied') raise exceptions.EasyBakeError('No tag supplied')
tagname = tagname.strip('.')
return (tagname, synonym, rename_to) return (tagname, synonym, rename_to)
def sql_listify(items): def sql_listify(items):

View File

@ -12,12 +12,8 @@ def album(a, minimal=False):
} }
if not minimal: if not minimal:
j['photos'] = [photo(p) for p in a.get_photos()] j['photos'] = [photo(p) for p in a.get_photos()]
parent = a.get_parent() j['parents'] = [album(p, minimal=True) for p in a.get_parents()]
if parent is not None: j['sub_albums'] = [album(c, minimal=True) for c in a.get_children()]
j['parent'] = album(parent, minimal=True)
else:
j['parent'] = None
j['sub_albums'] = [child.id for child in a.get_children()]
return j return j
@ -74,7 +70,7 @@ def tag(t, include_synonyms=False, minimal=False):
if not minimal: if not minimal:
j['author'] = user_or_none(t.get_author()) j['author'] = user_or_none(t.get_author())
j['description'] = t.description j['description'] = t.description
j['qualified_name'] = t.qualified_name() j['children'] = [tag(c, minimal=True) for c in t.get_children()]
if include_synonyms: if include_synonyms:
j['synonyms'] = list(t.get_synonyms()) j['synonyms'] = list(t.get_synonyms())
@ -85,6 +81,7 @@ def user(u):
'id': u.id, 'id': u.id,
'username': u.username, 'username': u.username,
'created': u.created, 'created': u.created,
'display_name': u.display_name,
} }
return j return j

View File

@ -23,14 +23,6 @@ class ObjectBase:
super().__init__() super().__init__()
self.photodb = photodb self.photodb = photodb
@property
def log(self):
return self.photodb.log
@property
def sql(self):
return self.photodb.sql
def __eq__(self, other): def __eq__(self, other):
return ( return (
isinstance(other, type(self)) and isinstance(other, type(self)) and
@ -78,47 +70,27 @@ class GroupableMixin:
def _lift_children(self): def _lift_children(self):
''' '''
If this object is a root, all of its children become roots. If this object has parents, the parents adopt all of its children.
If this object is a child, its parent adopts all of its children. Otherwise the parental relationship is simply deleted.
''' '''
parent = self.get_parent() children = self.get_children()
if parent is None: if not children:
pairs = { return
'parentid': self.id,
} self.photodb.sql_delete(table=self.group_table, pairs={'parentid': self.id})
self.photodb.sql_delete(table=self.group_table, pairs=pairs)
else: parents = self.get_parents()
pairs = { for parent in parents:
'parentid': (self.id, parent.id), parent.add_children(children)
}
self.photodb.sql_update(table=self.group_table, pairs=pairs, where_key='parentid')
@decorators.transaction @decorators.transaction
def add_child(self, member, *, commit=True): def add_child(self, member, *, commit=True):
''' self.assert_same_type(member)
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 if self.has_child(member):
exceptions.GroupExists is raised. return
'''
if not isinstance(member, type(self)):
raise TypeError('Member must be of type %s' % type(self))
self.photodb.log.debug('Adding child %s to %s' % (member, self)) self.photodb.log.debug(f'Adding child {member} to {self}.')
# Groupables are only allowed to have 1 parent.
# Unlike photos which can exist in multiple albums.
parent_row = self.photodb.sql_select_one(
'SELECT parentid FROM %s WHERE memberid == ?' % self.group_table,
[member.id]
)
if parent_row is not None:
parent_id = parent_row[0]
if parent_id == self.id:
return
that_group = self.group_getter(id=parent_id)
raise exceptions.GroupExists(member=member, group=that_group)
for my_ancestor in self.walk_parents(): for my_ancestor in self.walk_parents():
if my_ancestor == member: if my_ancestor == member:
@ -136,6 +108,21 @@ class GroupableMixin:
self.photodb.log.debug('Committing - add to group') self.photodb.log.debug('Committing - add to group')
self.photodb.commit() self.photodb.commit()
@decorators.transaction
def add_children(self, members, *, commit=True):
for member in members:
self.add_child(member, commit=False)
if commit:
self.photodb.log.debug('Committing - add multiple to group')
self.photodb.commit()
def assert_same_type(self, other):
if not isinstance(other, type(self)):
raise TypeError(f'Object must be of type {type(self)}, not {type(other)}.')
if self.photodb != other.photodb:
raise TypeError(f'Objects must belong to the same PhotoDB.')
@decorators.transaction @decorators.transaction
def delete(self, *, delete_children=False, commit=True): def delete(self, *, delete_children=False, commit=True):
''' '''
@ -153,7 +140,7 @@ class GroupableMixin:
self.photodb._cached_frozen_children = None self.photodb._cached_frozen_children = None
if delete_children: if delete_children:
for child in self.get_children(): for child in self.get_children():
child.delete(delete_children=delete_children, commit=False) child.delete(delete_children=True, commit=False)
else: else:
self._lift_children() self._lift_children()
@ -179,46 +166,45 @@ class GroupableMixin:
children = sorted(children, key=lambda x: x.id) children = sorted(children, key=lambda x: x.id)
return children return children
def get_parent(self): def get_parents(self):
''' query = f'SELECT parentid FROM {self.group_table} WHERE memberid == ?'
Return the group of which this is a member, or None. parent_rows = self.photodb.sql_select(query, [self.id])
Returned object will be of the same type as calling object. parent_ids = [row[0] for row in parent_rows]
''' parents = list(self.group_getter_many(parent_ids))
parent_row = self.photodb.sql_select_one( return parents
'SELECT parentid FROM %s WHERE memberid == ?' % self.group_table,
[self.id]
)
if parent_row is None:
return None
return self.group_getter(id=parent_row[0]) def has_any_child(self):
query = f'SELECT 1 FROM {self.group_table} WHERE parentid == ? LIMIT 1'
row = self.photodb.sql_select_one(query, [self.id])
return row is not None
def has_any_parent(self):
query = f'SELECT 1 FROM {self.group_table} WHERE memberid == ? LIMIT 1'
row = self.photodb.sql_select_one(query, [self.id])
return row is not None
def has_child(self, member):
self.assert_same_type(member)
query = f'SELECT 1 FROM {self.group_table} WHERE parentid == ? AND memberid == ?'
row = self.photodb.sql_select_one(query, [self.id, member.id])
return row is not None
@decorators.transaction @decorators.transaction
def join_group(self, group, *, commit=True): def remove_child(self, member, *, commit=True):
''' if not self.has_child(member):
Leave the current group, then call `group.add_child(self)`.
'''
if not isinstance(group, type(self)):
raise TypeError('Group must also be %s' % type(self))
if self == group:
raise ValueError('Cant join self')
if self.get_parent() == group:
return return
self.leave_group(commit=False) self.photodb.log.debug(f'Removing child {member} from {self}.')
group.add_child(self, commit=commit)
@decorators.transaction pairs = {
def leave_group(self, *, commit=True): 'parentid': self.id,
''' 'memberid': member.id,
Leave the current group and become independent. }
''' self.photodb.sql_delete(table=self.group_table, pairs=pairs)
self.photodb._cached_frozen_children = None self.photodb._cached_frozen_children = None
self.photodb.sql_delete(table=self.group_table, pairs={'memberid': self.id})
if commit: if commit:
self.photodb.log.debug('Committing - leave group') self.photodb.log.debug('Committing - remove from group')
self.photodb.commit() self.photodb.commit()
def walk_children(self): def walk_children(self):
@ -227,10 +213,19 @@ class GroupableMixin:
yield from child.walk_children() yield from child.walk_children()
def walk_parents(self): def walk_parents(self):
parent = self.get_parent() '''
while parent is not None: Yield all ancestors in no particular order.
'''
parents = self.get_parents()
seen = set(parents)
todo = list(parents)
while len(todo) > 0:
parent = todo.pop(-1)
yield parent yield parent
parent = parent.get_parent() seen.add(parent)
more_parents = set(parent.get_parents())
more_parents = more_parents.difference(seen)
todo.extend(more_parents)
class Album(ObjectBase, GroupableMixin): class Album(ObjectBase, GroupableMixin):
@ -247,7 +242,6 @@ class Album(ObjectBase, GroupableMixin):
self.description = self.normalize_description(db_row['description']) self.description = self.normalize_description(db_row['description'])
self.author_id = self.normalize_author_id(db_row['author_id']) self.author_id = self.normalize_author_id(db_row['author_id'])
self.name = 'Album %s' % self.id
self.group_getter = self.photodb.get_album self.group_getter = self.photodb.get_album
self.group_getter_many = self.photodb.get_albums_by_id self.group_getter_many = self.photodb.get_albums_by_id
@ -264,7 +258,7 @@ class Album(ObjectBase, GroupableMixin):
return '' return ''
if not isinstance(description, str): if not isinstance(description, str):
raise TypeError('Description must be string, not %s' % type(description)) raise TypeError(f'Description must be string, not {type(description)}')
description = description.strip() description = description.strip()
@ -276,7 +270,7 @@ class Album(ObjectBase, GroupableMixin):
return '' return ''
if not isinstance(title, str): if not isinstance(title, str):
raise TypeError('Title must be string, not %s' % type(title)) raise TypeError(f'Title must be string, not {type(title)}')
title = title.strip() title = title.strip()
for whitespace in string.whitespace: for whitespace in string.whitespace:
@ -285,24 +279,19 @@ class Album(ObjectBase, GroupableMixin):
return title return title
def _uncache(self): def _uncache(self):
self._uncache_sums()
self.photodb.caches['album'].remove(self.id) self.photodb.caches['album'].remove(self.id)
def _uncache_sums(self):
self._sum_photos_recursive = None
self._sum_bytes_local = None
self._sum_bytes_recursive = None
parent = self.get_parent()
if parent is not None:
parent._uncache_sums()
@decorators.required_feature('album.edit') @decorators.required_feature('album.edit')
# GroupableMixin.add_child already has @transaction. # GroupableMixin.add_child already has @transaction.
def add_child(self, *args, **kwargs): def add_child(self, *args, **kwargs):
result = super().add_child(*args, **kwargs) result = super().add_child(*args, **kwargs)
self._uncache_sums()
return result return result
@decorators.required_feature('album.edit')
# GroupableMixin.add_children already has @transaction.
def add_children(self, *args, **kwargs):
return super().add_children(*args, **kwargs)
@decorators.required_feature('album.edit') @decorators.required_feature('album.edit')
@decorators.transaction @decorators.transaction
def add_associated_directory(self, filepath, *, commit=True): def add_associated_directory(self, filepath, *, commit=True):
@ -349,7 +338,6 @@ class Album(ObjectBase, GroupableMixin):
return return
self._add_photo(photo) self._add_photo(photo)
self._uncache_sums()
if commit: if commit:
self.photodb.log.debug('Committing - add photo to album') self.photodb.log.debug('Committing - add photo to album')
@ -364,7 +352,6 @@ class Album(ObjectBase, GroupableMixin):
for photo in photos: for photo in photos:
self._add_photo(photo) self._add_photo(photo)
self._uncache_sums()
if commit: if commit:
self.photodb.log.debug('Committing - add photos to album') self.photodb.log.debug('Committing - add photos to album')
@ -484,21 +471,6 @@ class Album(ObjectBase, GroupableMixin):
) )
return rel_row is not None return rel_row is not None
@decorators.required_feature('album.edit')
# GroupableMixin.join_group already has @transaction.
def join_group(self, *args, **kwargs):
result = super().join_group(*args, **kwargs)
return result
@decorators.required_feature('album.edit')
# GroupableMixin.leave_group already has @transaction.
def leave_group(self, *args, **kwargs):
parent = self.get_parent()
if parent is not None:
parent._uncache_sums()
result = super().leave_group(*args, **kwargs)
return result
def _remove_photo(self, photo): def _remove_photo(self, photo):
self.photodb.log.debug('Removing photo %s from %s', photo, self) self.photodb.log.debug('Removing photo %s from %s', photo, self)
pairs = {'albumid': self.id, 'photoid': photo.id} pairs = {'albumid': self.id, 'photoid': photo.id}
@ -508,7 +480,6 @@ class Album(ObjectBase, GroupableMixin):
@decorators.transaction @decorators.transaction
def remove_photo(self, photo, *, commit=True): def remove_photo(self, photo, *, commit=True):
self._remove_photo(photo) self._remove_photo(photo)
self._uncache_sums()
if commit: if commit:
self.photodb.log.debug('Committing - remove photo from album') self.photodb.log.debug('Committing - remove photo from album')
@ -523,7 +494,6 @@ class Album(ObjectBase, GroupableMixin):
for photo in photos: for photo in photos:
self._remove_photo(photo) self._remove_photo(photo)
self._uncache_sums()
if commit: if commit:
self.photodb.log.debug('Committing - remove photos from album') self.photodb.log.debug('Committing - remove photos from album')
@ -534,7 +504,7 @@ class Album(ObjectBase, GroupableMixin):
SELECT SUM(bytes) FROM photos SELECT SUM(bytes) FROM photos
WHERE photos.id IN ( WHERE photos.id IN (
SELECT photoid FROM album_photo_rel WHERE SELECT photoid FROM album_photo_rel WHERE
albumid IN {qmarks} albumid IN {albumids}
) )
''' '''
if recurse: if recurse:
@ -542,20 +512,26 @@ class Album(ObjectBase, GroupableMixin):
else: else:
albumids = [self.id] albumids = [self.id]
query = query.format(qmarks='(%s)' % ','.join('?' * len(albumids))) albumids = helpers.sql_listify(albumids)
bindings = albumids query = query.format(albumids=albumids)
total = self.photodb.sql_select_one(query, bindings)[0] total = self.photodb.sql_select_one(query)[0]
return total return total
def sum_photos(self): def sum_photos(self, recurse=True):
if self._sum_photos_recursive is None: query = '''
#print(self, 'sumphotos cache miss') SELECT COUNT(photoid)
total = 0 FROM album_photo_rel
total += sum(1 for x in self.get_photos()) WHERE albumid IN {albumids}
total += sum(child.sum_photos() for child in self.get_children()) '''
self._sum_photos_recursive = total if recurse:
albumids = [child.id for child in self.walk_children()]
else:
albumids = [self.id]
return self._sum_photos_recursive albumids = helpers.sql_listify(albumids)
query = query.format(albumids=albumids)
total = self.photodb.sql_select_one(query)[0]
return total
def walk_photos(self): def walk_photos(self):
yield from self.get_photos() yield from self.get_photos()
@ -609,10 +585,14 @@ class Bookmark(ObjectBase):
return url return url
def _uncache(self):
self.photodb.caches['bookmark'].remove(self.id)
@decorators.required_feature('bookmark.edit') @decorators.required_feature('bookmark.edit')
@decorators.transaction @decorators.transaction
def delete(self, *, commit=True): def delete(self, *, commit=True):
self.photodb.sql_delete(table='bookmarks', pairs={'id': self.id}) self.photodb.sql_delete(table='bookmarks', pairs={'id': self.id})
self._uncache()
if commit: if commit:
self.photodb.commit() self.photodb.commit()
@ -684,11 +664,6 @@ class Photo(ObjectBase):
self.searchhidden = db_row['searchhidden'] self.searchhidden = db_row['searchhidden']
if self.duration and self.bytes is not None:
self.bitrate = (self.bytes / 128) / self.duration
else:
self.bitrate = None
self.mimetype = helpers.get_mimetype(self.real_path.basename) self.mimetype = helpers.get_mimetype(self.real_path.basename)
if self.mimetype is None: if self.mimetype is None:
self.simple_mimetype = None self.simple_mimetype = None
@ -748,13 +723,20 @@ class Photo(ObjectBase):
self.photodb.commit() self.photodb.commit()
return tag return tag
@property
def bitrate(self):
if self.duration and self.bytes is not None:
return (self.bytes / 128) / self.duration
else:
return None
@property @property
def bytestring(self): def bytestring(self):
if self.bytes is not None: if self.bytes is not None:
return bytestring.bytestring(self.bytes) return bytestring.bytestring(self.bytes)
return '??? b' return '??? b'
@decorators.required_feature('photo.add_remove_tag') # Photo.add_tag already has required_feature add_remove_tag
# Photo.add_tag already has @transaction. # Photo.add_tag already has @transaction.
def copy_tags(self, other_photo, *, commit=True): def copy_tags(self, other_photo, *, commit=True):
''' '''
@ -773,9 +755,9 @@ class Photo(ObjectBase):
Delete the Photo and its relation to any tags and albums. Delete the Photo and its relation to any tags and albums.
''' '''
self.photodb.log.debug('Deleting %s', self) self.photodb.log.debug('Deleting %s', self)
self.photodb.sql_delete(table='photos', pairs={'id': self.id})
self.photodb.sql_delete(table='photo_tag_rel', pairs={'photoid': self.id}) self.photodb.sql_delete(table='photo_tag_rel', pairs={'photoid': self.id})
self.photodb.sql_delete(table='album_photo_rel', pairs={'photoid': self.id}) self.photodb.sql_delete(table='album_photo_rel', pairs={'photoid': self.id})
self.photodb.sql_delete(table='photos', pairs={'id': self.id})
if delete_file: if delete_file:
path = self.real_path.absolute_path path = self.real_path.absolute_path
@ -1149,6 +1131,9 @@ class Tag(ObjectBase, GroupableMixin):
if isinstance(db_row, (list, tuple)): if isinstance(db_row, (list, tuple)):
db_row = dict(zip(constants.SQL_COLUMNS['tags'], db_row)) db_row = dict(zip(constants.SQL_COLUMNS['tags'], db_row))
self.id = db_row['id'] self.id = db_row['id']
# Do not pass the name through the normalizer. It may be grandfathered
# from previous character / length rules.
self.name = db_row['name'] self.name = db_row['name']
self.description = self.normalize_description(db_row['description']) self.description = self.normalize_description(db_row['description'])
self.author_id = self.normalize_author_id(db_row['author_id']) self.author_id = self.normalize_author_id(db_row['author_id'])
@ -1157,12 +1142,6 @@ class Tag(ObjectBase, GroupableMixin):
self.group_getter_many = self.photodb.get_tags_by_id self.group_getter_many = self.photodb.get_tags_by_id
self._cached_synonyms = None self._cached_synonyms = None
def __eq__(self, other):
return self.name == other or ObjectBase.__eq__(self, other)
def __hash__(self):
return hash(self.name)
def __repr__(self): def __repr__(self):
return f'Tag:{self.id}:{self.name}' return f'Tag:{self.id}:{self.name}'
@ -1185,13 +1164,14 @@ class Tag(ObjectBase, GroupableMixin):
def normalize_name(name, valid_chars=None, min_length=None, max_length=None): def normalize_name(name, valid_chars=None, min_length=None, max_length=None):
original_name = name original_name = name
if valid_chars is None: if valid_chars is None:
valid_chars = constants.DEFAULT_CONFIG['tag']['valid_chars'] valid_chars = constants.DEFAULT_CONFIGURATION['tag']['valid_chars']
name = name.lower() name = name.lower().strip()
name = name.strip('.+')
name = name.split('+')[0].split('.')[-1]
name = name.replace('-', '_') name = name.replace('-', '_')
name = name.replace(' ', '_') name = name.replace(' ', '_')
name = (c for c in name if c in valid_chars) name = ''.join(c for c in name if c in valid_chars)
name = ''.join(name)
if min_length is not None and len(name) < min_length: if min_length is not None and len(name) < min_length:
raise exceptions.TagTooShort(original_name) raise exceptions.TagTooShort(original_name)
@ -1209,6 +1189,11 @@ class Tag(ObjectBase, GroupableMixin):
def add_child(self, *args, **kwargs): def add_child(self, *args, **kwargs):
return super().add_child(*args, **kwargs) return super().add_child(*args, **kwargs)
@decorators.required_feature('tag.edit')
# GroupableMixin.add_children already has @transaction.
def add_children(self, *args, **kwargs):
return super().add_children(*args, **kwargs)
@decorators.required_feature('tag.edit') @decorators.required_feature('tag.edit')
@decorators.transaction @decorators.transaction
def add_synonym(self, synname, *, commit=True): def add_synonym(self, synname, *, commit=True):
@ -1219,7 +1204,7 @@ class Tag(ObjectBase, GroupableMixin):
self.photodb.assert_no_such_tag(name=synname) self.photodb.assert_no_such_tag(name=synname)
self.log.debug('New synonym %s of %s', synname, self.name) self.photodb.log.debug('New synonym %s of %s', synname, self.name)
self.photodb._cached_frozen_children = None self.photodb._cached_frozen_children = None
@ -1354,49 +1339,11 @@ class Tag(ObjectBase, GroupableMixin):
self._cached_synonyms = synonyms.copy() self._cached_synonyms = synonyms.copy()
return synonyms return synonyms
@decorators.required_feature('tag.edit')
# GroupableMixin.join_group already has @transaction.
def join_group(self, *args, **kwargs):
return super().join_group(*args, **kwargs)
@decorators.required_feature('tag.edit') @decorators.required_feature('tag.edit')
# GroupableMixin.leave_group already has @transaction. # GroupableMixin.leave_group already has @transaction.
def leave_group(self, *args, **kwargs): def leave_group(self, *args, **kwargs):
return super().leave_group(*args, **kwargs) return super().leave_group(*args, **kwargs)
def qualified_name(self, *, max_len=None):
'''
Return the 'group1.group2.tag' string for this tag.
If `max_len` is not None, bring the length of the qualname down
by first stripping off ancestors, then slicing the end off of the
name if necessary.
('people.family.mother', max_len=25) -> 'people.family.mother'
('people.family.mother', max_len=15) -> 'family.mother'
('people.family.mother', max_len=10) -> 'mother'
('people.family.mother', max_len=4) -> 'moth'
'''
if max_len is not None:
if len(self.name) == max_len:
return self.name
if len(self.name) > max_len:
return self.name[:max_len]
parent = self.get_parent()
if parent is None:
qualname = self.name
else:
qualname = parent.qualified_name() + '.' + self.name
if max_len is None or len(qualname) <= max_len:
return qualname
while len(qualname) > max_len:
qualname = qualname.split('.', 1)[1]
return qualname
@decorators.required_feature('tag.edit') @decorators.required_feature('tag.edit')
@decorators.transaction @decorators.transaction
def remove_synonym(self, synname, *, commit=True): def remove_synonym(self, synname, *, commit=True):
@ -1477,6 +1424,7 @@ class User(ObjectBase):
self.username = db_row['username'] self.username = db_row['username']
self.created = db_row['created'] self.created = db_row['created']
self.password_hash = db_row['password'] self.password_hash = db_row['password']
# Do not enforce maxlen here, they may be grandfathered in.
self._display_name = self.normalize_display_name(db_row['display_name']) self._display_name = self.normalize_display_name(db_row['display_name'])
def __repr__(self): def __repr__(self):

View File

@ -292,17 +292,23 @@ class PDBPhotoMixin:
if photo.real_path.exists: if photo.real_path.exists:
continue continue
photo.delete(commit=False) photo.delete(commit=False)
if commit: if commit:
self.log.debug('Committing - purge deleted photos') self.log.debug('Committing - purge deleted photos')
self.commit() self.commit()
@decorators.transaction @decorators.transaction
def purge_empty_albums(self, *, commit=True): def purge_empty_albums(self, *, commit=True):
albums = self.get_albums() to_check = list(self.get_albums())
for album in albums:
while to_check:
album = to_check.pop()
if album.get_children() or album.get_photos(): if album.get_children() or album.get_photos():
continue continue
# This may have been the last child of an otherwise empty parent.
to_check.extend(album.get_parents())
album.delete(commit=False) album.delete(commit=False)
if commit: if commit:
self.log.debug('Committing - purge empty albums') self.log.debug('Committing - purge empty albums')
self.commit() self.commit()
@ -464,25 +470,21 @@ class PDBPhotoMixin:
if extension is not None and extension_not is not None: if extension is not None and extension_not is not None:
extension = extension.difference(extension_not) extension = extension.difference(extension_not)
mmf_expression_noconflict = searchhelpers.check_mmf_expression_exclusive( tags_fixed = searchhelpers.normalize_mmf_vs_expression_conflict(
tag_musts, tag_musts,
tag_mays, tag_mays,
tag_forbids, tag_forbids,
tag_expression, tag_expression,
warning_bag warning_bag,
) )
if not mmf_expression_noconflict: (tag_musts, tag_mays, tag_forbids, tag_expression) = tags_fixed
tag_musts = None
tag_mays = None
tag_forbids = None
tag_expression = None
if tag_expression: if tag_expression:
frozen_children = self.get_cached_frozen_children() frozen_children = self.get_cached_frozen_children()
tag_expression_tree = searchhelpers.tag_expression_tree_builder( tag_expression_tree = searchhelpers.tag_expression_tree_builder(
tag_expression=tag_expression, tag_expression=tag_expression,
photodb=self, photodb=self,
frozen_children=frozen_children,
warning_bag=warning_bag, warning_bag=warning_bag,
) )
if tag_expression_tree is None: if tag_expression_tree is None:
@ -490,7 +492,6 @@ class PDBPhotoMixin:
tag_expression = None tag_expression = None
else: else:
giveback_tag_expression = str(tag_expression_tree) giveback_tag_expression = str(tag_expression_tree)
print(giveback_tag_expression)
tag_match_function = searchhelpers.tag_expression_matcher_builder(frozen_children) tag_match_function = searchhelpers.tag_expression_matcher_builder(frozen_children)
else: else:
giveback_tag_expression = None giveback_tag_expression = None
@ -565,16 +566,16 @@ class PDBPhotoMixin:
if '*' in extension: if '*' in extension:
wheres.append('extension != ""') wheres.append('extension != ""')
else: else:
binders = ', '.join('?' * len(extension)) qmarks = ', '.join('?' * len(extension))
wheres.append('extension IN (%s)' % binders) wheres.append('extension IN (%s)' % qmarks)
bindings.extend(extension) bindings.extend(extension)
if extension_not: if extension_not:
if '*' in extension_not: if '*' in extension_not:
wheres.append('extension == ""') wheres.append('extension == ""')
else: else:
binders = ', '.join('?' * len(extension_not)) qmarks = ', '.join('?' * len(extension_not))
wheres.append('extension NOT IN (%s)' % binders) wheres.append('extension NOT IN (%s)' % qmarks)
bindings.extend(extension_not) bindings.extend(extension_not)
if mimetype: if mimetype:
@ -781,6 +782,16 @@ class PDBTagMixin:
else: else:
raise exceptions.TagExists(existing_tag) raise exceptions.TagExists(existing_tag)
def get_all_tag_names(self):
'''
Return a list containing the names of all tags as strings.
Useful for when you don't want the overhead of actual Tag objects.
'''
query = 'SELECT name FROM tags'
rows = self.sql_select(query)
names = [row[0] for row in rows]
return names
def get_root_tags(self): def get_root_tags(self):
''' '''
Yield Tags that have no parent. Yield Tags that have no parent.
@ -808,9 +819,6 @@ class PDBTagMixin:
return tagname return tagname
tagname = tagname.tagname tagname = tagname.tagname
tagname = tagname.strip('.+')
tagname = tagname.split('.')[-1].split('+')[0]
try: try:
tagname = self.normalize_tagname(tagname) tagname = self.normalize_tagname(tagname)
except (exceptions.TagTooShort, exceptions.TagTooLong): except (exceptions.TagTooShort, exceptions.TagTooLong):
@ -851,6 +859,7 @@ class PDBTagMixin:
''' '''
tagname = self.normalize_tagname(tagname) tagname = self.normalize_tagname(tagname)
self.assert_no_such_tag(name=tagname) self.assert_no_such_tag(name=tagname)
description = objects.Tag.normalize_description(description) description = objects.Tag.normalize_description(description)
self.log.debug('New Tag: %s', tagname) self.log.debug('New Tag: %s', tagname)
@ -1002,7 +1011,7 @@ class PDBUserMixin:
@decorators.required_feature('user.new') @decorators.required_feature('user.new')
@decorators.transaction @decorators.transaction
def register_user(self, username, password, *, commit=True): def register_user(self, username, password, *, display_name=None, commit=True):
self.assert_valid_username(username) self.assert_valid_username(username)
if not isinstance(password, bytes): if not isinstance(password, bytes):
@ -1011,6 +1020,11 @@ class PDBUserMixin:
self.assert_valid_password(password) self.assert_valid_password(password)
self.assert_no_such_user(username=username) self.assert_no_such_user(username=username)
display_name = objects.User.normalize_display_name(
display_name,
max_length=self.config['user']['max_display_name_length'],
)
self.log.debug('New User: %s', username) self.log.debug('New User: %s', username)
user_id = self.generate_user_id() user_id = self.generate_user_id()
@ -1022,6 +1036,7 @@ class PDBUserMixin:
'username': username, 'username': username,
'password': hashed_password, 'password': hashed_password,
'created': created, 'created': created,
'display_name': display_name,
} }
self.sql_insert(table='users', data=data) self.sql_insert(table='users', data=data)
@ -1079,8 +1094,14 @@ class PDBUtilMixin:
return new_photo_kwargs return new_photo_kwargs
def _normalize_new_photo_ratelimit(new_photo_ratelimit): def _normalize_new_photo_ratelimit(new_photo_ratelimit):
if isinstance(new_photo_ratelimit, (int, float)): if isinstance(new_photo_ratelimit, ratelimiter.Ratelimiter):
pass
elif new_photo_ratelimit is None:
pass
elif isinstance(new_photo_ratelimit, (int, float)):
new_photo_ratelimit = ratelimiter.Ratelimiter(allowance=1, period=new_photo_ratelimit) new_photo_ratelimit = ratelimiter.Ratelimiter(allowance=1, period=new_photo_ratelimit)
else:
raise TypeError(new_photo_ratelimit)
return new_photo_ratelimit return new_photo_ratelimit
def create_or_fetch_photos(filepaths, new_photo_kwargs): def create_or_fetch_photos(filepaths, new_photo_kwargs):
@ -1117,7 +1138,11 @@ class PDBUtilMixin:
return current_album return current_album
def orphan_join_parent_album(albums_by_path, current_album, current_directory): def orphan_join_parent_album(albums_by_path, current_album, current_directory):
if current_album.get_parent() is None: '''
If the current album is an orphan, let's check if there exists an
album for the parent directory. If so, add the current album to it.
'''
if not current_album.has_any_parent():
parent = albums_by_path.get(current_directory.parent.absolute_path, None) parent = albums_by_path.get(current_directory.parent.absolute_path, None)
if parent is not None: if parent is not None:
parent.add_child(current_album, commit=False) parent.add_child(current_album, commit=False)
@ -1192,7 +1217,7 @@ class PDBUtilMixin:
tags = [create_or_get(t) for t in tag_parts] tags = [create_or_get(t) for t in tag_parts]
for (higher, lower) in zip(tags, tags[1:]): for (higher, lower) in zip(tags, tags[1:]):
try: try:
lower.join_group(higher, commit=False) higher.add_child(lower, commit=False)
note = ('join_group', f'{higher.name}.{lower.name}') note = ('join_group', f'{higher.name}.{lower.name}')
output_notes.append(note) output_notes.append(note)
except exceptions.GroupExists: except exceptions.GroupExists:
@ -1286,7 +1311,6 @@ class PhotoDB(
# OTHER # OTHER
self._cached_frozen_children = None self._cached_frozen_children = None
self._cached_qualname_map = None
self.caches = { self.caches = {
'album': cacheclass.Cache(maxlen=self.config['cache_size']['album']), 'album': cacheclass.Cache(maxlen=self.config['cache_size']['album']),
@ -1297,6 +1321,10 @@ class PhotoDB(
} }
def _check_version(self): def _check_version(self):
'''
Compare database's user_version against constants.DATABASE_VERSION,
raising exceptions.DatabaseOutOfDate if not correct.
'''
existing_version = self.sql_execute('PRAGMA user_version').fetchone()[0] existing_version = self.sql_execute('PRAGMA user_version').fetchone()[0]
if existing_version != constants.DATABASE_VERSION: if existing_version != constants.DATABASE_VERSION:
exc = exceptions.DatabaseOutOfDate( exc = exceptions.DatabaseOutOfDate(
@ -1327,7 +1355,6 @@ class PhotoDB(
def _uncache(self): def _uncache(self):
self._cached_frozen_children = None self._cached_frozen_children = None
self._cached_qualname_map = None
def close(self): def close(self):
# Wrapped in hasattr because if the object fails __init__, Python will # Wrapped in hasattr because if the object fails __init__, Python will
@ -1397,12 +1424,10 @@ class PhotoDB(
thing_cache[thing_id] = thing thing_cache[thing_id] = thing
return thing return thing
def get_cached_qualname_map(self):
if self._cached_qualname_map is None:
self._cached_qualname_map = tag_export.qualified_names(self.get_tags())
return self._cached_qualname_map
def get_root_things(self, thing_type): def get_root_things(self, thing_type):
'''
For Groupable types, yield things which have no parent.
'''
thing_map = _THING_CLASSES[thing_type] thing_map = _THING_CLASSES[thing_type]
thing_class = thing_map['class'] thing_class = thing_map['class']
@ -1445,6 +1470,9 @@ class PhotoDB(
return thing return thing
def get_things(self, thing_type): def get_things(self, thing_type):
'''
Yield things, unfiltered, in whatever order they appear in the database.
'''
thing_map = _THING_CLASSES[thing_type] thing_map = _THING_CLASSES[thing_type]
query = 'SELECT * FROM %s' % thing_map['table'] query = 'SELECT * FROM %s' % thing_map['table']

View File

@ -12,23 +12,6 @@ from . import objects
from voussoirkit import expressionmatch from voussoirkit import expressionmatch
def check_mmf_expression_exclusive(
tag_musts,
tag_mays,
tag_forbids,
tag_expression,
warning_bag=None
):
if (tag_musts or tag_mays or tag_forbids) and tag_expression:
exc = exceptions.NotExclusive(['tag_musts+mays+forbids', 'tag_expression'])
if warning_bag:
warning_bag.add(exc.error_message)
else:
raise exc
return False
return True
def expand_mmf(tag_musts, tag_mays, tag_forbids): def expand_mmf(tag_musts, tag_mays, tag_forbids):
def _set(x): def _set(x):
if x is None: if x is None:
@ -245,6 +228,33 @@ def normalize_mimetype(mimetype, warning_bag=None):
''' '''
return normalize_extension(mimetype, warning_bag) return normalize_extension(mimetype, warning_bag)
def normalize_mmf_vs_expression_conflict(
tag_musts,
tag_mays,
tag_forbids,
tag_expression,
warning_bag=None,
):
'''
The user cannot provide both mmf sets and tag expression at the same time.
If both are provided, nullify everything.
'''
if (tag_musts or tag_mays or tag_forbids) and tag_expression:
exc = exceptions.NotExclusive(['tag_musts+mays+forbids', 'tag_expression'])
if warning_bag:
warning_bag.add(exc.error_message)
else:
raise exc
conflict = True
conflict = False
if conflict:
tag_musts = None
tag_mays = None
tag_forbids = None
tag_expression = None
return (tag_musts, tag_mays, tag_forbids, tag_expression)
def normalize_offset(offset, warning_bag=None): def normalize_offset(offset, warning_bag=None):
''' '''
Either: Either:
@ -424,7 +434,6 @@ def normalize_tagset(photodb, tags, warning_bag=None):
def tag_expression_tree_builder( def tag_expression_tree_builder(
tag_expression, tag_expression,
photodb, photodb,
frozen_children,
warning_bag=None warning_bag=None
): ):
if not tag_expression: if not tag_expression:
@ -439,9 +448,9 @@ def tag_expression_tree_builder(
for node in expression_tree.walk_leaves(): for node in expression_tree.walk_leaves():
try: try:
node.token = photodb.normalize_tagname(node.token) node.token = photodb.get_tag(name=node.token).name
except (exceptions.TagTooShort, exceptions.TagTooLong) as exc: except (exceptions.NoSuchTag) as exc:
if warning_bag is not None: if warning_bag:
warning_bag.add(exc.error_message) warning_bag.add(exc.error_message)
node.token = None node.token = None
else: else:
@ -450,14 +459,6 @@ def tag_expression_tree_builder(
if node.token is None: if node.token is None:
continue continue
if node.token not in frozen_children:
exc = exceptions.NoSuchTag(node.token)
if warning_bag is not None:
warning_bag.add(exc.error_message)
node.token = None
else:
raise exc
expression_tree.prune() expression_tree.prune()
if expression_tree.token is None: if expression_tree.token is None:
return None return None

View File

@ -3,7 +3,7 @@ This file provides a variety of functions for exporting a PDB's tags into other
formats. Strings, dicts, etc. formats. Strings, dicts, etc.
''' '''
def easybake(tags): def easybake(tags, include_synonyms=True, with_objects=False):
''' '''
A string where every line is the qualified name of a tag or its synonyms. A string where every line is the qualified name of a tag or its synonyms.
@ -13,12 +13,33 @@ def easybake(tags):
people.family.mother+mom people.family.mother+mom
''' '''
lines = [] lines = []
tags = list(tags)
for tag in tags: for tag in tags:
qualname = tag.qualified_name() if with_objects:
lines.append(qualname) my_line = (tag.name, tag)
lines.extend(qualname + '+' + syn for syn in tag.get_synonyms()) else:
return '\n'.join(lines) my_line = tag.name
lines.append(my_line)
if include_synonyms:
syn_lines = tag.get_synonyms()
syn_lines = [f'{tag.name}+{syn}' for syn in syn_lines]
if with_objects:
syn_lines = [(line, tag) for line in syn_lines]
lines.extend(syn_lines)
child_lines = easybake(
tag.get_children(),
include_synonyms=include_synonyms,
with_objects=with_objects,
)
if with_objects:
child_lines = [(f'{tag.name}.{line[0]}', line[1]) for line in child_lines]
else:
child_lines = [f'{tag.name}.{line}' for line in child_lines]
lines.extend(child_lines)
lines.sort()
return lines
def flat_dict(tags): def flat_dict(tags):
''' '''
@ -65,26 +86,6 @@ def nested_dict(tags):
return result return result
def qualified_names(tags):
'''
A dictionary where keys are string names, values are qualified names.
Synonyms included.
{
'people': 'people',
'family': 'people.family',
'mother': 'people.family.mother',
'mom': 'people.family.mother',
}
'''
results = {}
for tag in tags:
qualname = tag.qualified_name()
results[tag.name] = qualname
for synonym in tag.get_synonyms():
results[synonym] = qualname
return results
def stdout(tags, depth=0): def stdout(tags, depth=0):
for tag in tags: for tag in tags:
children = tag.get_children() children = tag.get_children()
@ -99,5 +100,5 @@ def stdout(tags, depth=0):
stdout(children, depth=depth+1) stdout(children, depth=depth+1)
if tag.get_parent() is None: if not tag.has_any_parent():
print() print()

View File

@ -34,7 +34,9 @@ site.jinja_env.trim_blocks = True
site.jinja_env.lstrip_blocks = True site.jinja_env.lstrip_blocks = True
site.jinja_env.filters['bytestring'] = jinja_filters.bytestring site.jinja_env.filters['bytestring'] = jinja_filters.bytestring
site.jinja_env.filters['file_link'] = jinja_filters.file_link site.jinja_env.filters['file_link'] = jinja_filters.file_link
site.jinja_env.filters['sort_by_qualname'] = jinja_filters.sort_by_qualname site.jinja_env.filters['sort_tags'] = jinja_filters.sort_tags
site.jinja_env.filters['timestamp_to_naturaldate'] = jinja_filters.timestamp_to_naturaldate
site.jinja_env.filters['timestamp_to_8601'] = jinja_filters.timestamp_to_8601
site.debug = True site.debug = True
P = etiquette.photodb.PhotoDB() P = etiquette.photodb.PhotoDB()
@ -93,6 +95,10 @@ def P_photos(photo_ids):
def P_tag(tagname): def P_tag(tagname):
return P.get_tag(name=tagname) return P.get_tag(name=tagname)
@P_wrapper
def P_tag_id(tag_id):
return P.get_tag(id=tag_id)
@P_wrapper @P_wrapper
def P_user(username): def P_user(username):
return P.get_user(username=username) return P.get_user(username=username)

View File

@ -312,7 +312,7 @@ def get_search_core():
author_helper = lambda users: ', '.join(user.username for user in users) if users else None author_helper = lambda users: ', '.join(user.username for user in users) if users else None
search_kwargs['author'] = author_helper(search_kwargs['author']) search_kwargs['author'] = author_helper(search_kwargs['author'])
tagname_helper = lambda tags: [tag.qualified_name() for tag in tags] if tags else None tagname_helper = lambda tags: [tag.name for tag in tags] if tags else None
search_kwargs['tag_musts'] = tagname_helper(search_kwargs['tag_musts']) search_kwargs['tag_musts'] = tagname_helper(search_kwargs['tag_musts'])
search_kwargs['tag_mays'] = tagname_helper(search_kwargs['tag_mays']) search_kwargs['tag_mays'] = tagname_helper(search_kwargs['tag_mays'])
search_kwargs['tag_forbids'] = tagname_helper(search_kwargs['tag_forbids']) search_kwargs['tag_forbids'] = tagname_helper(search_kwargs['tag_forbids'])
@ -331,7 +331,7 @@ def get_search_core():
for photo in photos: for photo in photos:
for tag in photo.get_tags(): for tag in photo.get_tags():
total_tags.add(tag) total_tags.add(tag)
total_tags = sorted(total_tags, key=lambda t: t.qualified_name()) total_tags = sorted(total_tags, key=lambda t: t.name)
# PREV-NEXT PAGE URLS # PREV-NEXT PAGE URLS
offset = search_kwargs['offset'] or 0 offset = search_kwargs['offset'] or 0
@ -371,14 +371,14 @@ def get_search_core():
def get_search_html(): def get_search_html():
search_results = get_search_core() search_results = get_search_core()
search_kwargs = search_results['search_kwargs'] search_kwargs = search_results['search_kwargs']
qualname_map = common.P.get_cached_qualname_map() all_tags = common.P.get_all_tag_names()
session = session_manager.get(request) session = session_manager.get(request)
response = flask.render_template( response = flask.render_template(
'search.html', 'search.html',
all_tags=json.dumps(all_tags),
next_page_url=search_results['next_page_url'], next_page_url=search_results['next_page_url'],
prev_page_url=search_results['prev_page_url'], prev_page_url=search_results['prev_page_url'],
photos=search_results['photos'], photos=search_results['photos'],
qualname_map=json.dumps(qualname_map),
search_kwargs=search_kwargs, search_kwargs=search_kwargs,
session=session, session=session,
total_tags=search_results['total_tags'], total_tags=search_results['total_tags'],

View File

@ -17,6 +17,18 @@ session_manager = common.session_manager
def get_tags_specific_redirect(specific_tag): def get_tags_specific_redirect(specific_tag):
return flask.redirect(request.url.replace('/tags/', '/tag/')) return flask.redirect(request.url.replace('/tags/', '/tag/'))
@site.route('/tagid/<tag_id>')
@site.route('/tagid/<tag_id>.json')
def get_tag_id_redirect(tag_id):
if request.url.endswith('.json'):
tag = common.P_tag_id(tag_id, response_type='json')
else:
tag = common.P_tag_id(tag_id, response_type='html')
url_from = '/tagid/' + tag_id
url_to = '/tag/' + tag.name
url = request.url.replace(url_from, url_to)
return flask.redirect(url)
# Tag metadata operations ########################################################################## # Tag metadata operations ##########################################################################
@site.route('/tag/<specific_tag>/edit', methods=['POST']) @site.route('/tag/<specific_tag>/edit', methods=['POST'])
@ -36,15 +48,6 @@ def post_tag_edit(specific_tag):
# Tag listings ##################################################################################### # Tag listings #####################################################################################
def get_tags_core(specific_tag=None):
if specific_tag is None:
tags = common.P.get_tags()
else:
tags = specific_tag.walk_children()
tags = list(tags)
tags.sort(key=lambda x: x.qualified_name())
return tags
@site.route('/tag/<specific_tag_name>') @site.route('/tag/<specific_tag_name>')
@site.route('/tags') @site.route('/tags')
@session_manager.give_token @session_manager.give_token
@ -57,10 +60,17 @@ def get_tags_html(specific_tag_name=None):
new_url = request.url.replace('/tag/' + specific_tag_name, '/tag/' + specific_tag.name) new_url = request.url.replace('/tag/' + specific_tag_name, '/tag/' + specific_tag.name)
response = flask.redirect(new_url) response = flask.redirect(new_url)
return response return response
tags = get_tags_core(specific_tag)
session = session_manager.get(request) session = session_manager.get(request)
include_synonyms = request.args.get('synonyms') include_synonyms = request.args.get('synonyms')
include_synonyms = include_synonyms is None or etiquette.helpers.truthystring(include_synonyms) include_synonyms = include_synonyms is None or etiquette.helpers.truthystring(include_synonyms)
if specific_tag is None:
tags = common.P.get_root_tags()
else:
tags = [specific_tag]
tags = etiquette.tag_export.easybake(tags, include_synonyms=False, with_objects=True)
response = flask.render_template( response = flask.render_template(
'tags.html', 'tags.html',
include_synonyms=include_synonyms, include_synonyms=include_synonyms,
@ -81,9 +91,14 @@ def get_tags_json(specific_tag_name=None):
if specific_tag.name != specific_tag_name: if specific_tag.name != specific_tag_name:
new_url = request.url.replace('/tag/' + specific_tag_name, '/tag/' + specific_tag.name) new_url = request.url.replace('/tag/' + specific_tag_name, '/tag/' + specific_tag.name)
return flask.redirect(new_url) return flask.redirect(new_url)
tags = get_tags_core(specific_tag=specific_tag)
include_synonyms = request.args.get('synonyms') include_synonyms = request.args.get('synonyms')
include_synonyms = include_synonyms is None or etiquette.helpers.truthystring(include_synonyms) include_synonyms = include_synonyms is None or etiquette.helpers.truthystring(include_synonyms)
if specific_tag is None:
tags = list(common.P.get_tags())
else:
tags = list(specific_tag.walk_children())
tags = [etiquette.jsonify.tag(tag, include_synonyms=include_synonyms) for tag in tags] tags = [etiquette.jsonify.tag(tag, include_synonyms=include_synonyms) for tag in tags]
return jsonify.make_json_response(tags) return jsonify.make_json_response(tags)
@ -133,9 +148,15 @@ def post_tag_delete():
Delete a tag. Delete a tag.
''' '''
tagname = request.form['tagname'] tagname = request.form['tagname']
tagname = tagname.split('.')[-1].split('+')[0] tagname = tagname.split('+')[0]
tag = common.P.get_tag(name=tagname) if '.' in tagname:
(parentname, tagname) = tagname.rsplit('.', 1)
tag.delete() parent = common.P.get_tag(name=parentname)
response = {'action': 'delete_tag', 'tagname': tag.name} tag = common.P.get_tag(name=tagname)
parent.remove_child(tag)
response = {'action': 'unlink_tag', 'tagname': f'{parent.name}.{tag.name}'}
else:
tag = common.P.get_tag(name=tagname)
tag.delete()
response = {'action': 'delete_tag', 'tagname': tag.name}
return jsonify.make_json_response(response) return jsonify.make_json_response(response)

View File

@ -1,8 +1,11 @@
import etiquette import datetime
import jinja2.filters import jinja2.filters
import etiquette
import voussoirkit.bytestring import voussoirkit.bytestring
def bytestring(x): def bytestring(x):
try: try:
return voussoirkit.bytestring.bytestring(x) return voussoirkit.bytestring.bytestring(x)
@ -15,6 +18,16 @@ def file_link(photo, short=False):
basename = jinja2.filters.do_urlencode(photo.basename) basename = jinja2.filters.do_urlencode(photo.basename)
return f'/file/{photo.id}/{basename}' return f'/file/{photo.id}/{basename}'
def sort_by_qualname(tags): def sort_tags(tags):
tags = sorted(tags, key=lambda x: x.qualified_name()) tags = sorted(tags, key=lambda x: x.name)
return tags return tags
def timestamp_to_8601(timestamp):
return datetime.datetime.utcfromtimestamp(timestamp).isoformat(' ') + ' UTC'
def timestamp_to_string(timestamp, format):
date = datetime.datetime.utcfromtimestamp(timestamp)
return date.strftime(format)
def timestamp_to_naturaldate(timestamp):
return timestamp_to_string(timestamp, '%B %d, %Y')

View File

@ -13,7 +13,6 @@ handler.setFormatter(logging.Formatter(log_format, style='{'))
logging.getLogger().addHandler(handler) logging.getLogger().addHandler(handler)
import gevent.pywsgi import gevent.pywsgi
import gevent.wsgi
import argparse import argparse
import sys import sys

View File

@ -63,9 +63,11 @@ p
<ul> <ul>
{% set viewparam = "?view=list" if view == "list" else "" %} {% set viewparam = "?view=list" if view == "list" else "" %}
{% set parent = album.get_parent() %} {% set parents = album.get_parents() %}
{% if parent %} {% if parents %}
{% for parent in parents %}
<li><a href="/album/{{parent.id}}{{viewparam}}">{{parent.display_name}}</a></li> <li><a href="/album/{{parent.id}}{{viewparam}}">{{parent.display_name}}</a></li>
{% endfor %}
{% else %} {% else %}
<li><a href="/albums">Albums</a></li> <li><a href="/albums">Albums</a></li>
{% endif %} {% endif %}
@ -155,13 +157,13 @@ function unpaste_photo_clipboard()
} }
var paste_photo_clipboard_button = document.createElement("button"); var paste_photo_clipboard_button = document.createElement("button");
paste_photo_clipboard_button.classList.add("green_button"); paste_photo_clipboard_button.classList.add("green_button");
paste_photo_clipboard_button.innerText = "Add to album"; paste_photo_clipboard_button.innerText = "Add to this album";
paste_photo_clipboard_button.onclick = paste_photo_clipboard; paste_photo_clipboard_button.onclick = paste_photo_clipboard;
document.getElementById("clipboard_tray_toolbox").appendChild(paste_photo_clipboard_button); document.getElementById("clipboard_tray_toolbox").appendChild(paste_photo_clipboard_button);
var unpaste_photo_clipboard_button = document.createElement("button"); var unpaste_photo_clipboard_button = document.createElement("button");
unpaste_photo_clipboard_button.classList.add("red_button"); unpaste_photo_clipboard_button.classList.add("red_button");
unpaste_photo_clipboard_button.innerText = "Remove from album"; unpaste_photo_clipboard_button.innerText = "Remove from this album";
unpaste_photo_clipboard_button.onclick = unpaste_photo_clipboard; unpaste_photo_clipboard_button.onclick = unpaste_photo_clipboard;
document.getElementById("clipboard_tray_toolbox").appendChild(unpaste_photo_clipboard_button); document.getElementById("clipboard_tray_toolbox").appendChild(unpaste_photo_clipboard_button);

View File

@ -311,19 +311,29 @@ function searchhidden_callback(response)
} }
create_message_bubble(message_area, message_positivity, message_text, 8000); create_message_bubble(message_area, message_positivity, message_text, 8000);
} }
function submit_set_searchhidden() function submit_set_searchhidden(callback)
{ {
if (photo_clipboard.size == 0)
{return;}
var url = "/batch/photos/set_searchhidden"; var url = "/batch/photos/set_searchhidden";
var data = new FormData(); var data = new FormData();
data.append("photo_ids", Array.from(photo_clipboard).join(",")); var photo_ids = Array.from(photo_clipboard).join(",");
post(url, data, searchhidden_callback);
data.append("photo_ids", photo_ids);
post(url, data, callback);
} }
function submit_unset_searchhidden() function submit_unset_searchhidden(callback)
{ {
if (photo_clipboard.size == 0)
{return;}
var url = "/batch/photos/unset_searchhidden"; var url = "/batch/photos/unset_searchhidden";
var data = new FormData(); var data = new FormData();
data.append("photo_ids", Array.from(photo_clipboard).join(",")); var photo_ids = Array.from(photo_clipboard).join(",");
post(url, data, searchhidden_callback);
data.append("photo_ids", photo_ids);
post(url, data, callback);
} }
</script> </script>
</html> </html>

View File

@ -8,6 +8,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" href="/static/css/common.css"> <link rel="stylesheet" href="/static/css/common.css">
<script src="/static/js/common.js"></script> <script src="/static/js/common.js"></script>
<script src="/static/js/hotkeys.js"></script>
<script src="/static/js/photoclipboard.js"></script>
<style> <style>
#content_body #content_body
@ -163,10 +165,10 @@
<input id="add_tag_textbox" type="text" autofocus> <input id="add_tag_textbox" type="text" autofocus>
<button id="add_tag_button" class="green_button" onclick="submit_tag(receive_callback);">add</button> <button id="add_tag_button" class="green_button" onclick="submit_tag(receive_callback);">add</button>
</li> </li>
{% set tags = photo.get_tags()|sort_by_qualname %} {% set tags = photo.get_tags()|sort_tags %}
{% for tag in tags %} {% for tag in tags %}
<li> <li>
{{tag_object.tag_object(tag, qualified_name=True, max_len=30, with_alt_description=True, with_alt_qualified_name=True)}}<!-- {{tag_object.tag_object(tag, with_alt_description=True)}}<!--
--><button --><button
class="remove_tag_button red_button" class="remove_tag_button red_button"
onclick="remove_photo_tag('{{photo.id}}', '{{tag.name}}', receive_callback);"> onclick="remove_photo_tag('{{photo.id}}', '{{tag.name}}', receive_callback);">
@ -181,24 +183,28 @@
<button id="refresh_metadata_button" class="green_button" onclick="refresh_metadata('{{photo.id}}');">refresh</button> <button id="refresh_metadata_button" class="green_button" onclick="refresh_metadata('{{photo.id}}');">refresh</button>
</h4> </h4>
<ul id="metadata"> <ul id="metadata">
<li>Filename: {{photo.basename}}</li> <li>Filename: {{photo.basename}}</li>
{% set author = photo.get_author() %} {% set author = photo.get_author() %}
{% if author is not none %} {% if author is not none %}
<li>Author: <a href="/user/{{author.username}}">{{author.display_name}}</a></li> <li>Author: <a href="/user/{{author.username}}">{{author.display_name}}</a></li>
{% endif %} {% endif %}
{% if photo.width %} {% if photo.width %}
<li>Dimensions: {{photo.width}}x{{photo.height}} px</li> <li title="{{photo.area}} px">Dimensions: {{photo.width}}x{{photo.height}} px</li>
<li>Aspect ratio: {{photo.ratio}}</li> <li>Aspect ratio: {{photo.ratio}}</li>
{% endif %} {% endif %}
<li>Size: {{photo.bytes|bytestring}}</li> <li>Size: {{photo.bytes|bytestring}}</li>
{% if photo.duration %} {% if photo.duration %}
<li>Duration: {{photo.duration_string}}</li> <li>Duration: {{photo.duration_string}}</li>
<li>Overall Bitrate: {{photo.bitrate|int}} kbps</li> <li>Overall Bitrate: {{photo.bitrate|int}} kbps</li>
{% endif %} {% endif %}
<li><a href="{{photo|file_link}}?download=true&original_filename=true">Download as original filename</a></li> <li><a href="{{photo|file_link}}?download=true&original_filename=true">Download as original filename</a></li>
<li><a href="{{photo|file_link}}?download=true">Download as {{photo.id}}.{{photo.extension}}</a></li> <li><a href="{{photo|file_link}}?download=true">Download as {{photo.id}}.{{photo.extension}}</a></li>
</ul> </ul>
<div>
<label class="photo_card" data-id="{{photo.id}}"><input type="checkbox" class="photo_card_selector_checkbox" onclick="on_photo_select(event)"/>Clipboard</label>
</div>
<!-- CONTAINING ALBUMS --> <!-- CONTAINING ALBUMS -->
{% set albums = photo.get_containing_albums() %} {% set albums = photo.get_containing_albums() %}
{% if albums %} {% if albums %}
@ -210,7 +216,11 @@
</ul> </ul>
{% endif %} {% endif %}
<a href="/search?created=-{{photo.created}}">&larr;Before</a><span> | </span><a href="/search?created={{photo.created}}-&orderby=created-asc">After&rarr;</a> <div>
<a href="/search?created=-{{photo.created}}">&larr;Before</a>
<span> | </span>
<a href="/search?created={{photo.created}}-&orderby=created-asc">After&rarr;</a>
</div>
</div> </div>
<div id="message_area_bg"> <div id="message_area_bg">
<div id="message_area"> <div id="message_area">

View File

@ -291,9 +291,6 @@ form
tag, tag,
extra_classes="tags_on_this_page", extra_classes="tags_on_this_page",
link='void', link='void',
max_len=30,
qualified_name=True,
with_alt_qualified_name=True,
with_alt_description=True, with_alt_description=True,
)-}}</li> )-}}</li>
{% endfor %} {% endfor %}
@ -487,14 +484,6 @@ function submit_search()
// Don't clutter url with default values. // Don't clutter url with default values.
continue; continue;
} }
if (boxname == "has_tags" && has_tag_params && value == "no")
{
/*
The user wants untagged only, but has tags in the search boxes?
Override to "tagged or untagged" and let the tag searcher handle it.
*/
value = "";
}
if (value == "") if (value == "")
{ {
continue; continue;
@ -536,7 +525,7 @@ function tags_on_this_page_hook()
*/ */
var tagname = this.innerHTML.split(/\./); var tagname = this.innerHTML.split(/\./);
tagname = tagname[tagname.length-1]; tagname = tagname[tagname.length-1];
var qualname = QUALNAME_MAP[tagname]; var qualname = ALL_TAGS[tagname];
add_searchtag( add_searchtag(
input_musts, input_musts,
qualname, qualname,
@ -563,18 +552,17 @@ function tag_input_hook(box, inputted_list, li_class)
value = value.split("+")[0]; value = value.split("+")[0];
value = value.replace(new RegExp(" ", 'g'), "_"); value = value.replace(new RegExp(" ", 'g'), "_");
value = value.replace(new RegExp("-", 'g'), "_"); value = value.replace(new RegExp("-", 'g'), "_");
if (!(value in QUALNAME_MAP)) if (ALL_TAGS.indexOf(value) == -1)
{ {
return; return;
} }
value = QUALNAME_MAP[value];
console.log(inputted_list); console.log(inputted_list);
add_searchtag(box, value, inputted_list, li_class) add_searchtag(box, value, inputted_list, li_class)
box.value = ""; box.value = "";
} }
QUALNAME_MAP = {{qualname_map|safe}}; ALL_TAGS = {{all_tags|safe}};
var input_musts = document.getElementById("search_builder_musts_input"); var input_musts = document.getElementById("search_builder_musts_input");
var input_mays = document.getElementById("search_builder_mays_input"); var input_mays = document.getElementById("search_builder_mays_input");
var input_forbids = document.getElementById("search_builder_forbids_input"); var input_forbids = document.getElementById("search_builder_forbids_input");

View File

@ -10,30 +10,17 @@
'search' = link to /search?tag_musts=tagname 'search' = link to /search?tag_musts=tagname
'info' = link to /tags/tagname 'info' = link to /tags/tagname
'void' = javascript:void(0) 'void' = javascript:void(0)
max_len:
None: As long as it needs to be.
integer: Rootmost parents are removed until the text fits under
this limit.
If the tag's own name can't find under the limit, characters are
dropped from the right.
qualified_name:
True: Use the qualified name as the innertext
False: Use the basic name
with_alt_description: with_alt_description:
True: Include the description in the alt text True: Include the description in the alt text
with_alt_qualified_name:
True: Include the qualified name in the alt text
--> -->
{% macro tag_object( {% macro tag_object(
tag, tag,
extra_classes="", extra_classes="",
innertext=None, innertext=None,
link='search', link='search',
max_len=None, with_alt_description=False
qualified_name=True, ) -%}
with_alt_description=True,
with_alt_qualified_name=True
) %}
{%- if link is not none -%} {%- if link is not none -%}
{%- set closing="</a>" -%} {%- set closing="</a>" -%}
<a <a
@ -46,8 +33,6 @@
{%- else -%} {%- else -%}
{{' '}}href="{{search}}" {{' '}}href="{{search}}"
{%- endif -%} {%- endif -%}
{{' '}}target="_blank"
{%- else -%} {%- else -%}
{% set closing="</span>" %} {% set closing="</span>" %}
<span <span
@ -56,7 +41,6 @@
{{' '}}class="tag_object {{extra_classes}}" {{' '}}class="tag_object {{extra_classes}}"
{%- set altlines=[] -%} {%- set altlines=[] -%}
{% if with_alt_qualified_name %}{% do altlines.append(tag.qualified_name()) %}{% endif %}
{% if with_alt_description and tag.description != "" %}{% do altlines.append(tag.description) %}{% endif %} {% if with_alt_description and tag.description != "" %}{% do altlines.append(tag.description) %}{% endif %}
{% set altlines=altlines|join("\n") %} {% set altlines=altlines|join("\n") %}
{%- if altlines -%} {%- if altlines -%}
@ -66,16 +50,8 @@
{%- if innertext is not none -%} {%- if innertext is not none -%}
{{innertext}} {{innertext}}
{%- else -%} {%- else -%}
{%- if qualified_name -%} {{tag.name}}
{{tag.qualified_name(max_len=max_len)}} {%- endif %}
{%- else -%}
{%- if max_len is not none -%}
{{tag.name[:max_len]}}
{%- else -%}
{{tag.name}}
{%- endif -%}
{%- endif -%}
{% endif %}
{{- closing|safe -}} {{- closing|safe -}}
{% endmacro %} {% endmacro %}

View File

@ -100,7 +100,7 @@ body
<body> <body>
{{header.make_header(session=session)}} {{header.make_header(session=session)}}
<div id="left"> <div id="left">
{% if specific_tag is not none %} {% if specific_tag %}
<h2> <h2>
<span <span
id="name_text" id="name_text"
@ -118,23 +118,31 @@ body
> >
{{-specific_tag.description-}} {{-specific_tag.description-}}
</pre> </pre>
<ul>
{% for ancestor in specific_tag.get_parents() %}
<li>
{{tag_object.tag_object(ancestor, innertext='(?)', link='info')}}
{{tag_object.tag_object(ancestor, innertext=ancestor.name + '.' + specific_tag.name, link=none, with_alt_description=True)}}
</li>
{% endfor %}
</ul>
{% endif %} {% endif %}
<ul> <ul>
{% for tag in tags %} {% for (qualified_name, tag) in tags %}
{% set qualified_name = tag.qualified_name() %}
<li> <li>
{{tag_object.tag_object(tag, innertext='(?)', link='info')}} {{tag_object.tag_object(tag, innertext='(?)', link='info')}}
{{tag_object.tag_object(tag, link='search', qualified_name=True, with_alt_qualified_name=False)}}<!-- {{tag_object.tag_object(tag, link='search', innertext=qualified_name, with_alt_description=True)}}<!--
--><button class="remove_tag_button red_button" onclick="delete_tag('{{tag.name}}', receive_callback);"></button> --><button class="remove_tag_button red_button" onclick="delete_tag('{{qualified_name}}', receive_callback);"></button>
</li> </li>
{% if include_synonyms %} {% if include_synonyms %}
{% for synonym in tag.get_synonyms() %} {% for synonym in tag.get_synonyms() %}
<li> <li>
{{tag_object.tag_object(tag, innertext='(+)', link=none)}} {{tag_object.tag_object(tag, innertext='(+)', link=none)}}
{{tag_object.tag_object(tag, innertext=qualified_name + '+' + synonym, link='search')}}<!-- {{tag_object.tag_object(tag, link='search', innertext=qualified_name + '+' + synonym)}}<!--
--><button class="remove_tag_button red_button" onclick="delete_tag_synonym('{{synonym}}', receive_callback);"></button> --><button class="remove_tag_button red_button" onclick="delete_tag_synonym('{{synonym}}', receive_callback);"></button>
</li> </li>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
@ -224,6 +232,9 @@ function receive_callback(response)
else if (action == "delete_synonym") else if (action == "delete_synonym")
{message_text = "Deleted synonym " + response["synonym"];} {message_text = "Deleted synonym " + response["synonym"];}
else if (action == "unlink_tag")
{message_text = "Unlinked tags " + tagname;}
} }
create_message_bubble(message_area, message_positivity, message_text, 8000); create_message_bubble(message_area, message_positivity, message_text, 8000);
} }

View File

@ -22,6 +22,7 @@
<div id="content_body"> <div id="content_body">
<h2>{{user.display_name}}</h2> <h2>{{user.display_name}}</h2>
<p>ID: {{user.id}}</p> <p>ID: {{user.id}}</p>
<p title="{{user.created|int|timestamp_to_8601}}">User since {{user.created|timestamp_to_naturaldate}}.</p>
<p><a href="/search?author={{user.username}}">Photos by {{user.display_name}}</a></p> <p><a href="/search?author={{user.username}}">Photos by {{user.display_name}}</a></p>
</div> </div>
</body> </body>