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.
This commit is contained in:
parent
c2cfa99752
commit
4c65ccaf68
19 changed files with 395 additions and 381 deletions
|
@ -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.
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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.
|
|
||||||
'''
|
|
||||||
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))
|
|
||||||
|
|
||||||
# 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
|
return
|
||||||
that_group = self.group_getter(id=parent_id)
|
|
||||||
raise exceptions.GroupExists(member=member, group=that_group)
|
self.photodb.log.debug(f'Adding child {member} to {self}.')
|
||||||
|
|
||||||
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):
|
||||||
|
|
|
@ -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']
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -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]
|
||||||
|
if '.' in tagname:
|
||||||
|
(parentname, tagname) = tagname.rsplit('.', 1)
|
||||||
|
parent = common.P.get_tag(name=parentname)
|
||||||
|
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 = common.P.get_tag(name=tagname)
|
||||||
|
|
||||||
tag.delete()
|
tag.delete()
|
||||||
response = {'action': 'delete_tag', 'tagname': tag.name}
|
response = {'action': 'delete_tag', 'tagname': tag.name}
|
||||||
return jsonify.make_json_response(response)
|
return jsonify.make_json_response(response)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);">
|
||||||
|
@ -187,7 +189,7 @@
|
||||||
<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>
|
||||||
|
@ -199,6 +201,10 @@
|
||||||
<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}}">←Before</a><span> | </span><a href="/search?created={{photo.created}}-&orderby=created-asc">After→</a>
|
<div>
|
||||||
|
<a href="/search?created=-{{photo.created}}">←Before</a>
|
||||||
|
<span> | </span>
|
||||||
|
<a href="/search?created={{photo.created}}-&orderby=created-asc">After→</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="message_area_bg">
|
<div id="message_area_bg">
|
||||||
<div id="message_area">
|
<div id="message_area">
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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.qualified_name(max_len=max_len)}}
|
|
||||||
{%- else -%}
|
|
||||||
{%- if max_len is not none -%}
|
|
||||||
{{tag.name[:max_len]}}
|
|
||||||
{%- else -%}
|
|
||||||
{{tag.name}}
|
{{tag.name}}
|
||||||
{%- endif -%}
|
{%- endif %}
|
||||||
{%- endif -%}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{{- closing|safe -}}
|
{{- closing|safe -}}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
|
@ -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,20 +118,28 @@ 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 %}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue