Let PhotoTags have timestamps; use more js cards.

Tags on photos can now have timestamps, so that if you are tagging
a video or audio you can reference a specific moment with your tag.
In the interface, this means the tag is clickable and seeks to that
point in the media.

For the user interface, I am finding I need to move away from jinja
for the object cards because it is too much hassle to keep the code
for jinja-based cards for static rendering and the js-based cards
for dynamic rendering in sync. Rather than write the same cards in
two languages I can dump the JSON into the script and render the cards
on load. Which makes the static HTML worse but that's what the JSON
API is for anyway.
This commit is contained in:
voussoir 2023-09-17 14:07:22 -07:00
parent f3c8a8da3d
commit da5c1ee008
20 changed files with 834 additions and 362 deletions

View file

@ -41,7 +41,7 @@ ffmpeg = _load_ffmpeg()
# Database ######################################################################################### # Database #########################################################################################
DATABASE_VERSION = 24 DATABASE_VERSION = 25
DB_INIT = ''' DB_INIT = '''
CREATE TABLE IF NOT EXISTS albums( CREATE TABLE IF NOT EXISTS albums(
@ -165,10 +165,12 @@ CREATE INDEX IF NOT EXISTS index_album_photo_rel_albumid on album_photo_rel(albu
CREATE INDEX IF NOT EXISTS index_album_photo_rel_photoid on album_photo_rel(photoid); CREATE INDEX IF NOT EXISTS index_album_photo_rel_photoid on album_photo_rel(photoid);
---------------------------------------------------------------------------------------------------- ----------------------------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS photo_tag_rel( CREATE TABLE IF NOT EXISTS photo_tag_rel(
id INT PRIMARY KEY NOT NULL,
photoid INT NOT NULL, photoid INT NOT NULL,
tagid INT NOT NULL, tagid INT NOT NULL,
created INT, created INT,
PRIMARY KEY(photoid, tagid), timestamp REAL,
UNIQUE(photoid, tagid, timestamp),
FOREIGN KEY(photoid) REFERENCES photos(id), FOREIGN KEY(photoid) REFERENCES photos(id),
FOREIGN KEY(tagid) REFERENCES tags(id) FOREIGN KEY(tagid) REFERENCES tags(id)
); );

View file

@ -583,7 +583,14 @@ class Album(ObjectBase, GroupableMixin):
) )
return exists is not None return exists is not None
def jsonify(self, include_photos=True, minimal=False) -> dict: def jsonify(
self,
include_photos=True,
include_parents=True,
include_children=True,
count_children=False,
count_photos=False,
) -> dict:
j = { j = {
'type': 'album', 'type': 'album',
'id': self.id, 'id': self.id,
@ -594,13 +601,24 @@ class Album(ObjectBase, GroupableMixin):
'thumbnail_photo': self.thumbnail_photo.id if self._thumbnail_photo else None, 'thumbnail_photo': self.thumbnail_photo.id if self._thumbnail_photo else None,
'author': self.author.jsonify() if self._author_id else None, 'author': self.author.jsonify() if self._author_id else None,
} }
if not minimal: if self.deleted:
j['deleted'] = True
if include_parents:
j['parents'] = [parent.id for parent in self.get_parents()] j['parents'] = [parent.id for parent in self.get_parents()]
if include_children:
j['children'] = [child.id for child in self.get_children()] j['children'] = [child.id for child in self.get_children()]
if include_photos: if include_photos:
j['photos'] = [photo.id for photo in self.get_photos()] j['photos'] = [photo.id for photo in self.get_photos()]
if count_children:
j['children_count'] = self.sum_children(recurse=False)
if count_photos:
j['photos_count'] = self.sum_photos(recurse=False)
return j return j
@decorators.required_feature('album.edit') @decorators.required_feature('album.edit')
@ -884,6 +902,9 @@ class Bookmark(ObjectBase):
'title': self.title, 'title': self.title,
'display_name': self.display_name, 'display_name': self.display_name,
} }
if self.deleted:
j['deleted'] = True
return j return j
class Photo(ObjectBase): class Photo(ObjectBase):
@ -958,7 +979,7 @@ class Photo(ObjectBase):
return cleaned return cleaned
def _assign_mimetype(self): def _assign_mimetype(self):
# This function is defined separately because it is a derivative # This method is defined separately because it is a derivative
# property of the file's basename and needs to be recalculated after # property of the file's basename and needs to be recalculated after
# file renames. However, I decided not to write it as a @property # file renames. However, I decided not to write it as a @property
# because that would require either wasted computation or using private # because that would require either wasted computation or using private
@ -977,23 +998,27 @@ class Photo(ObjectBase):
def _uncache(self): def _uncache(self):
self.photodb.caches[Photo].remove(self.id) self.photodb.caches[Photo].remove(self.id)
# Will add -> Tag when forward references are supported by Python. # Will add -> PhotoTagRel when forward references are supported by Python.
@decorators.required_feature('photo.add_remove_tag') @decorators.required_feature('photo.add_remove_tag')
@worms.atomic @worms.atomic
def add_tag(self, tag): def add_tag(self, tag, timestamp=None):
tag = self.photodb.get_tag(name=tag) tag = self.photodb.get_tag(name=tag)
if self.has_tag(tag, check_children=False): existing = self.has_tag(tag, check_children=False, match_timestamp=timestamp)
return tag if existing:
return existing
log.info('Applying %s to %s.', tag, self) log.info('Applying %s to %s.', tag, self)
data = { data = {
'id': self.photodb.generate_id(PhotoTagRel),
'photoid': self.id, 'photoid': self.id,
'tagid': tag.id, 'tagid': tag.id,
'created': timetools.now().timestamp(), 'created': timetools.now().timestamp(),
'timestamp': PhotoTagRel.normalize_timestamp(timestamp)
} }
self.photodb.insert(table='photo_tag_rel', pairs=data) self.photodb.insert(table=PhotoTagRel, pairs=data)
photo_tag = PhotoTagRel(self.photodb, data)
data = { data = {
'id': self.id, 'id': self.id,
@ -1001,7 +1026,7 @@ class Photo(ObjectBase):
} }
self.photodb.update(table=Photo, pairs=data, where_key='id') self.photodb.update(table=Photo, pairs=data, where_key='id')
return tag return photo_tag
def atomify(self, web_root='') -> bs4.BeautifulSoup: def atomify(self, web_root='') -> bs4.BeautifulSoup:
web_root = web_root.rstrip('/') web_root = web_root.rstrip('/')
@ -1129,6 +1154,7 @@ class Photo(ObjectBase):
self.set_thumbnail(image) self.set_thumbnail(image)
return image return image
@decorators.cache_until_commit
def get_containing_albums(self) -> set[Album]: def get_containing_albums(self) -> set[Album]:
''' '''
Return the albums of which this photo is a member. Return the albums of which this photo is a member.
@ -1137,7 +1163,7 @@ class Photo(ObjectBase):
'SELECT albumid FROM album_photo_rel WHERE photoid == ?', 'SELECT albumid FROM album_photo_rel WHERE photoid == ?',
[self.id] [self.id]
) )
albums = set(self.photodb.get_albums_by_id(album_ids)) albums = frozenset(self.photodb.get_albums_by_id(album_ids))
return albums return albums
@decorators.cache_until_commit @decorators.cache_until_commit
@ -1145,12 +1171,16 @@ class Photo(ObjectBase):
''' '''
Return the tags assigned to this Photo. Return the tags assigned to this Photo.
''' '''
tag_ids = self.photodb.select_column( photo_tags = frozenset(self.photodb.get_objects_by_sql(
'SELECT tagid FROM photo_tag_rel WHERE photoid == ?', PhotoTagRel,
'SELECT * FROM photo_tag_rel WHERE photoid == ?',
[self.id] [self.id]
) ))
tags = set(self.photodb.get_tags_by_id(tag_ids)) return photo_tags
return tags
@decorators.cache_until_commit
def get_tag_names(self) -> set:
return set(photo_tag.tag.name for photo_tag in self.get_tags())
def get_thumbnail(self): def get_thumbnail(self):
query = 'SELECT thumbnail FROM photo_thumbnails WHERE photoid = ?' query = 'SELECT thumbnail FROM photo_thumbnails WHERE photoid = ?'
@ -1158,9 +1188,9 @@ class Photo(ObjectBase):
return blob return blob
# Will add -> Tag/False when forward references are supported. # Will add -> Tag/False when forward references are supported.
def has_tag(self, tag, *, check_children=True): def has_tag(self, tag, *, check_children=True, match_timestamp=False):
''' '''
Return the Tag object if this photo contains that tag. Return the PhotoTagRel object if this photo contains that tag.
Otherwise return False. Otherwise return False.
check_children: check_children:
@ -1176,15 +1206,19 @@ class Photo(ObjectBase):
tag_by_id = {t.id: t for t in tag_options} tag_by_id = {t.id: t for t in tag_options}
tag_option_ids = sqlhelpers.listify(tag_by_id) tag_option_ids = sqlhelpers.listify(tag_by_id)
tag_id = self.photodb.select_one_value(
f'SELECT tagid FROM photo_tag_rel WHERE photoid == ? AND tagid IN {tag_option_ids}',
[self.id]
)
if tag_id is None: if match_timestamp is False or match_timestamp is None:
query = f'SELECT * FROM photo_tag_rel WHERE photoid == ? AND tagid IN {tag_option_ids}'
bindings = [self.id]
else:
query = f'SELECT * FROM photo_tag_rel WHERE photoid == ? AND tagid IN {tag_option_ids} AND timestamp == ?'
bindings = [self.id, match_timestamp]
results = list(self.photodb.get_objects_by_sql(PhotoTagRel, query, bindings))
if not results:
return False return False
return tag_by_id[tag_id] return results[0]
def has_thumbnail(self) -> bool: def has_thumbnail(self) -> bool:
if self._has_thumbnail is not None: if self._has_thumbnail is not None:
@ -1192,7 +1226,7 @@ class Photo(ObjectBase):
self._has_thumbnail = self.photodb.exists('SELECT 1 FROM photo_thumbnails WHERE photoid = ?', [self.id]) self._has_thumbnail = self.photodb.exists('SELECT 1 FROM photo_thumbnails WHERE photoid = ?', [self.id])
return self._has_thumbnail return self._has_thumbnail
def jsonify(self, include_albums=True, include_tags=True, minimal=False) -> dict: def jsonify(self, include_albums=True, include_tags=True) -> dict:
j = { j = {
'type': 'photo', 'type': 'photo',
'id': self.id, 'id': self.id,
@ -1213,13 +1247,14 @@ class Photo(ObjectBase):
'searchhidden': bool(self.searchhidden), 'searchhidden': bool(self.searchhidden),
'simple_mimetype': self.simple_mimetype, 'simple_mimetype': self.simple_mimetype,
} }
if self.deleted:
j['deleted'] = True
if not minimal:
if include_albums: if include_albums:
j['albums'] = [album.id for album in self.get_containing_albums()] j['albums'] = [album.id for album in self.get_containing_albums()]
if include_tags: if include_tags:
j['tags'] = [tag.id for tag in self.get_tags()] j['tags'] = [photo_tag.jsonify() for photo_tag in self.get_tags()]
return j return j
@ -1377,6 +1412,11 @@ class Photo(ObjectBase):
@decorators.required_feature('photo.add_remove_tag') @decorators.required_feature('photo.add_remove_tag')
@worms.atomic @worms.atomic
def remove_tag(self, tag) -> None: def remove_tag(self, tag) -> None:
'''
This method removes all PhotoTagRel between this photo and tag.
If you just want to remove one timestamped instance, get the PhotoTagRel
object and call its delete method.
'''
tag = self.photodb.get_tag(name=tag) tag = self.photodb.get_tag(name=tag)
log.info('Removing %s from %s.', tag, self) log.info('Removing %s from %s.', tag, self)
@ -1384,6 +1424,7 @@ class Photo(ObjectBase):
'photoid': self.id, 'photoid': self.id,
'tagid': tag.id, 'tagid': tag.id,
} }
self.photodb.delete(table='photo_tag_rel', pairs=pairs) self.photodb.delete(table='photo_tag_rel', pairs=pairs)
data = { data = {
@ -1395,6 +1436,10 @@ class Photo(ObjectBase):
@decorators.required_feature('photo.add_remove_tag') @decorators.required_feature('photo.add_remove_tag')
@worms.atomic @worms.atomic
def remove_tags(self, tags) -> None: def remove_tags(self, tags) -> None:
'''
This method removes all PhotoTagRel between this photo and
multiple tags.
'''
tags = [self.photodb.get_tag(name=tag) for tag in tags] tags = [self.photodb.get_tag(name=tag) for tag in tags]
log.info('Removing %s from %s.', tags, self) log.info('Removing %s from %s.', tags, self)
@ -1551,6 +1596,67 @@ class Photo(ObjectBase):
self._tagged_at_dt = helpers.utcfromtimestamp(self.tagged_at_unix) self._tagged_at_dt = helpers.utcfromtimestamp(self.tagged_at_unix)
return self._tagged_at_dt return self._tagged_at_dt
class PhotoTagRel(ObjectBase):
table = 'photo_tag_rel'
def __init__(self, photodb, db_row):
super().__init__(photodb)
self.photodb = photodb
self.id = db_row['id']
self.photo_id = db_row['photoid']
self.photo = photodb.get_photo(self.photo_id)
self.tag_id = db_row['tagid']
self.tag = photodb.get_tag_by_id(self.tag_id)
self.created_unix = db_row['created']
self.timestamp = db_row['timestamp']
def __hash__(self):
return hash(f'{self.photo_id}.{self.tag_id}')
def __lt__(self, other):
my_tuple = (self.photo_id, self.tag.name, (self.timestamp or 0))
other_tuple = (other.photo_id, other.tag.name, (other.timestamp or 0))
return my_tuple < other_tuple
@staticmethod
def normalize_timestamp(timestamp) -> float:
if timestamp is None:
return timestamp
if timestamp == '':
return None
if isinstance(timestamp, str):
return float(timestamp)
if isinstance(timestamp, (int, float)):
return timestamp
else:
raise TypeError(f'timestamp should be {float}, not {type(timestamp)}.')
@decorators.required_feature('photo.add_remove_tag')
@worms.atomic
def delete(self) -> None:
log.info('Removing %s from %s.', self.tag.name, self.photo.id)
self.photodb.delete(table=PhotoTagRel, pairs={'id': self.id})
self.deleted = True
def jsonify(self):
j = {
'type': 'photo_tag_rel',
'id': self.id,
'photo_id': self.photo_id,
'tag_id': self.tag_id,
'tag_name': self.tag.name,
'created': self.created_unix,
'timestamp': self.timestamp,
}
if self.deleted:
j['deleted'] = True
return j
class Search: class Search:
''' '''
FILE METADATA FILE METADATA
@ -1702,11 +1808,16 @@ class Search:
results = [ results = [
result.jsonify(include_albums=False) result.jsonify(include_albums=False)
if isinstance(result, Photo) else if isinstance(result, Photo) else
result.jsonify(minimal=True) result.jsonify(
include_photos=False,
include_parents=False,
include_children=False,
)
for result in self.results for result in self.results
] ]
j = { j = {
'type': 'search',
'kwargs': kwargs, 'kwargs': kwargs,
'results': results, 'results': results,
'more_after_limit': self.more_after_limit, 'more_after_limit': self.more_after_limit,
@ -2060,28 +2171,6 @@ class Tag(ObjectBase, GroupableMixin):
def _uncache(self): def _uncache(self):
self.photodb.caches[Tag].remove(self.id) self.photodb.caches[Tag].remove(self.id)
def _add_child(self, member):
ret = super()._add_child(member)
if ret is BAIL:
return BAIL
# Suppose a photo has tags A and B. Then, B is added as a child of A.
# We should remove A from the photo leaving only the more specific B.
# We must walk all ancestors, not just the immediate parent, because
# the same situation could apply to a photo that has tag A, where A
# already has children B.C.D, and then E is added as a child of D,
# obsoleting A.
# I expect that this method, which calls `search`, will be inefficient
# when used in a large `add_children` loop. I would consider batching
# up all the ancestors and doing it all at once. Just need to make sure
# I don't cause any collateral damage e.g. removing A from a photo that
# only has A because some other photo with A and B thinks A is obsolete.
# This technique is nice and simple to understand for now.
ancestors = list(member.walk_parents())
photos = self.photodb.search(tag_musts=[member], is_searchhidden=None, yield_photos=True, yield_albums=False)
for photo in photos.results:
photo.remove_tags(ancestors)
@decorators.required_feature('tag.edit') @decorators.required_feature('tag.edit')
@worms.atomic @worms.atomic
def add_child(self, member): def add_child(self, member):
@ -2246,17 +2335,22 @@ class Tag(ObjectBase, GroupableMixin):
self._cached_synonyms = synonyms self._cached_synonyms = synonyms
return synonyms return synonyms
def jsonify(self, include_synonyms=False, minimal=False) -> dict: def jsonify(self, include_synonyms=False, include_parents=True, include_children=True) -> dict:
j = { j = {
'type': 'tag', 'type': 'tag',
'id': self.id, 'id': self.id,
'name': self.name, 'name': self.name,
'created': self.created_unix, 'created': self.created_unix,
'author': self.author.jsonify() if self._author_id else None,
'description': self.description,
} }
if not minimal: if self.deleted:
j['author'] = self.author.jsonify() if self._author_id else None j['deleted'] = True
j['description'] = self.description
if include_parents:
j['parents'] = [parent.id for parent in self.get_parents()] j['parents'] = [parent.id for parent in self.get_parents()]
if include_children:
j['children'] = [child.id for child in self.get_children()] j['children'] = [child.id for child in self.get_children()]
if include_synonyms: if include_synonyms:
@ -2532,6 +2626,9 @@ class User(ObjectBase):
'created': self.created_unix, 'created': self.created_unix,
'display_name': self.display_name, 'display_name': self.display_name,
} }
if self.deleted:
j['deleted'] = True
return j return j
@decorators.required_feature('user.edit') @decorators.required_feature('user.edit')
@ -2573,4 +2670,5 @@ class WarningBag:
self.warnings.add(warning) self.warnings.add(warning)
def jsonify(self): def jsonify(self):
return [getattr(w, 'error_message', str(w)) for w in self.warnings] j = [getattr(w, 'error_message', str(w)) for w in self.warnings]
return j

View file

@ -186,7 +186,11 @@ def post_album_edit(album_id):
album = common.P_album(album_id, response_type='json') album = common.P_album(album_id, response_type='json')
album.edit(title=title, description=description) album.edit(title=title, description=description)
response = album.jsonify(minimal=True) response = album.jsonify(
include_parents=False,
include_children=False,
include_photos=False,
)
return flasktools.json_response(response) return flasktools.json_response(response)
@site.route('/album/<album_id>/show_in_folder', methods=['POST']) @site.route('/album/<album_id>/show_in_folder', methods=['POST'])
@ -257,7 +261,11 @@ def post_albums_create():
if parent_id is not None: if parent_id is not None:
parent.add_child(album) parent.add_child(album)
response = album.jsonify(minimal=False) response = album.jsonify(
include_parents=False,
include_children=False,
include_photos=False,
)
return flasktools.json_response(response) return flasktools.json_response(response)
@site.route('/album/<album_id>/delete', methods=['POST']) @site.route('/album/<album_id>/delete', methods=['POST'])

View file

@ -84,11 +84,6 @@ def get_thumbnail(photo_id, basename=None):
headers=outgoing_headers, headers=outgoing_headers,
) )
return response return response
# if photo.thumbnail:
# path = photo.thumbnail
# else:
# flask.abort(404, 'That file doesnt have a thumbnail')
# return common.send_file(path)
# Photo create and delete ########################################################################## # Photo create and delete ##########################################################################
@ -104,21 +99,22 @@ def post_photo_delete(photo_id):
# Photo tag operations ############################################################################# # Photo tag operations #############################################################################
def post_photo_add_remove_tag_core(photo_ids, tagname, add_or_remove): def post_photo_add_remove_tag_core(photo_ids, tagname, add_or_remove, timestamp=None):
if isinstance(photo_ids, str): if isinstance(photo_ids, str):
photo_ids = stringtools.comma_space_split(photo_ids) photo_ids = stringtools.comma_space_split(photo_ids)
photos = list(common.P_photos(photo_ids, response_type='json')) photos = list(common.P_photos(photo_ids, response_type='json'))
tag = common.P_tag(tagname, response_type='json') tag = common.P_tag(tagname, response_type='json')
response = {'action': add_or_remove, 'tagname': tag.name}
with common.P.transaction: with common.P.transaction:
for photo in photos: for photo in photos:
if add_or_remove == 'add': if add_or_remove == 'add':
photo.add_tag(tag) photo_tag = photo.add_tag(tag, timestamp=timestamp)
response["photo_tag_rel_id"] = photo_tag.id
elif add_or_remove == 'remove': elif add_or_remove == 'remove':
photo.remove_tag(tag) photo.remove_tag(tag)
response = {'action': add_or_remove, 'tagname': tag.name}
return flasktools.json_response(response) return flasktools.json_response(response)
@site.route('/photo/<photo_id>/add_tag', methods=['POST']) @site.route('/photo/<photo_id>/add_tag', methods=['POST'])
@ -128,12 +124,13 @@ def post_photo_add_tag(photo_id):
Add a tag to this photo. Add a tag to this photo.
''' '''
common.permission_manager.basic() common.permission_manager.basic()
response = post_photo_add_remove_tag_core( photo = common.P_photo(photo_id, response_type='json')
photo_ids=photo_id, tag = common.P_tag(request.form['tagname'], response_type='json')
tagname=request.form['tagname'],
add_or_remove='add', with common.P.transaction:
) tag_timestamp = request.form.get('timestamp').strip() or None
return response photo_tag = photo.add_tag(tag, timestamp=tag_timestamp)
return flasktools.json_response(photo_tag.jsonify())
@site.route('/photo/<photo_id>/copy_tags', methods=['POST']) @site.route('/photo/<photo_id>/copy_tags', methods=['POST'])
@flasktools.required_fields(['other_photo'], forbid_whitespace=True) @flasktools.required_fields(['other_photo'], forbid_whitespace=True)
@ -146,7 +143,7 @@ def post_photo_copy_tags(photo_id):
photo = common.P_photo(photo_id, response_type='json') photo = common.P_photo(photo_id, response_type='json')
other = common.P_photo(request.form['other_photo'], response_type='json') other = common.P_photo(request.form['other_photo'], response_type='json')
photo.copy_tags(other) photo.copy_tags(other)
return flasktools.json_response([tag.jsonify(minimal=True) for tag in photo.get_tags()]) return flasktools.json_response([tag.jsonify() for tag in photo.get_tags()])
@site.route('/photo/<photo_id>/remove_tag', methods=['POST']) @site.route('/photo/<photo_id>/remove_tag', methods=['POST'])
@flasktools.required_fields(['tagname'], forbid_whitespace=True) @flasktools.required_fields(['tagname'], forbid_whitespace=True)
@ -162,6 +159,17 @@ def post_photo_remove_tag(photo_id):
) )
return response return response
@site.route('/photo_tag_rel/<photo_tag_rel_id>/delete', methods=['POST'])
def post_photo_tag_rel_delete(photo_tag_rel_id):
'''
Remove a tag from a photo.
'''
common.permission_manager.basic()
with common.P.transaction:
photo_tag = common.P.get_object_by_id(etiquette.objects.PhotoTagRel, photo_tag_rel_id)
photo_tag.delete()
return flasktools.json_response(photo_tag.jsonify())
@site.route('/batch/photos/add_tag', methods=['POST']) @site.route('/batch/photos/add_tag', methods=['POST'])
@flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True) @flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True)
def post_batch_photos_add_tag(): def post_batch_photos_add_tag():
@ -499,7 +507,7 @@ def get_search_html():
total_tags = set() total_tags = set()
for result in search.results: for result in search.results:
if isinstance(result, etiquette.objects.Photo): if isinstance(result, etiquette.objects.Photo):
total_tags.update(result.get_tags()) total_tags.update(photo_tag.tag for photo_tag in result.get_tags())
total_tags = sorted(total_tags, key=lambda t: t.name) total_tags = sorted(total_tags, key=lambda t: t.name)
# PREV-NEXT PAGE URLS # PREV-NEXT PAGE URLS

View file

@ -114,7 +114,7 @@ def post_tag_remove_synonym(tagname):
@common.permission_manager.basic_decorator @common.permission_manager.basic_decorator
@flasktools.cached_endpoint(max_age=15) @flasktools.cached_endpoint(max_age=15)
def get_all_tag_names(): def get_all_tag_names():
all_tags = list(common.P.get_all_tag_names()) all_tags = list(sorted(common.P.get_all_tag_names()))
all_synonyms = common.P.get_all_synonyms() all_synonyms = common.P.get_all_synonyms()
response = {'tags': all_tags, 'synonyms': all_synonyms} response = {'tags': all_tags, 'synonyms': all_synonyms}
return flasktools.json_response(response) return flasktools.json_response(response)

View file

@ -8,7 +8,7 @@
} }
.album_card_list .album_card_list
{ {
display: grid; display: inline-grid;
grid-template: grid-template:
"title metadata" "title metadata"
/1fr; /1fr;
@ -122,6 +122,7 @@
.photo_card .photo_card
{ {
background-color: var(--color_secondary); background-color: var(--color_secondary);
width: max-content;
} }
.photo_card:hover .photo_card:hover
{ {
@ -301,8 +302,20 @@
/* ########################################################################## */ /* ########################################################################## */
/* ########################################################################## */ /* ########################################################################## */
.photo_tag_card
{
position: relative;
display: inline;
}
/* ########################################################################## */
/* ########################################################################## */
/* ########################################################################## */
.tag_card .tag_card
{ {
position: relative;
display: inline-block;
border-radius: 2px; border-radius: 2px;
padding-left: 2px; padding-left: 2px;
padding-right: 2px; padding-right: 2px;
@ -311,7 +324,11 @@
color: var(--color_tag_card_fg); color: var(--color_tag_card_fg);
font-size: 0.9em; font-size: 0.9em;
text-decoration: none;
font-family: monospace; font-family: monospace;
line-height: 1.5; }
.tag_card,
.tag_card a
{
color: var(--color_tag_card_fg);
text-decoration: none;
} }

View file

@ -191,6 +191,7 @@ is hovered over.
display: none; display: none;
} }
li:hover .remove_tag_button,
.tag_card:hover ~ * .remove_tag_button, .tag_card:hover ~ * .remove_tag_button,
.tag_card:hover ~ .remove_tag_button, .tag_card:hover ~ .remove_tag_button,
.remove_tag_button:hover, .remove_tag_button:hover,

View file

@ -221,11 +221,11 @@ function edit(bookmark_id, title, b_url, callback)
api.photos = {}; api.photos = {};
api.photos.add_tag = api.photos.add_tag =
function add_tag(photo_id, tagname, callback) function add_tag(photo_id, tagname, timestamp, callback)
{ {
return http.post({ return http.post({
url: `/photo/${photo_id}/add_tag`, url: `/photo/${photo_id}/add_tag`,
data: {"tagname": tagname}, data: {"tagname": tagname, "timestamp": timestamp},
callback: callback, callback: callback,
}); });
} }
@ -416,6 +416,18 @@ function callback_go_to_search(response)
window.location.href = "/search"; window.location.href = "/search";
} }
/**************************************************************************************************/
api.photo_tag_rel = {};
api.photo_tag_rel.delete =
function _delete({id, callback=null})
{
return http.post({
url: `/photo_tag_rel/${id}/delete`,
callback: callback,
});
}
/**************************************************************************************************/ /**************************************************************************************************/
api.tags = {}; api.tags = {};

View file

@ -3,6 +3,127 @@ const cards = {};
/******************************************************************************/ /******************************************************************************/
cards.albums = {}; cards.albums = {};
cards.albums.create =
function create({
album,
view="grid",
unlink_parent=null,
draggable=false,
})
{
const viewparam = view == "list" ? "?view=list" : "";
const album_card = document.createElement("div");
album_card.classList.add("album_card");
album_card.classList.add(`album_card_${view}`);
album_card.dataset.id = album == "root" ? "root" : album.id;
album_card.ondragstart = cards.albums.drag_start
album_card.ondragend = cards.albums.drag_end
album_card.ondragover = cards.albums.drag_over
album_card.ondrop = cards.albums.drag_drop
album_card.draggable = draggable && album != "root";
const thumbnail_link = document.createElement("a");
thumbnail_link.classList.add("album_card_thumbnail");
thumbnail_link.draggable = false;
album_card.append(thumbnail_link);
const thumbnail_img = document.createElement("img");
thumbnail_img.loading = "lazy";
thumbnail_img.draggable = false;
thumbnail_link.append(thumbnail_img);
let href;
if (album == "root")
{
href = `/albums${viewparam}`;
}
else
{
href = `/album/${album.id}${viewparam}`
}
thumbnail_link.href = href;
if (album == "root")
{
thumbnail_img.src = "/static/basic_thumbnails/album.png";
}
else
{
if (album.thumbnail_photo)
{
thumbnail_img.src = `/photo/${album.thumbnail_photo}/thumbnail/${album.thumbnail_photo.id}.jpg`;
}
else
{
thumbnail_img.src = "/static/basic_thumbnails/album.png";
}
}
const album_title = document.createElement("a");
album_card.append(album_title);
album_title.classList.add("album_card_title");
album_title.draggable = false;
album_title.href = href;
if (album == "root")
{
album_title.innerText = "Albums";
}
else
{
album_title.innerText = album.display_name;
}
const album_metadata = document.createElement("div");
album_metadata.classList.add("album_card_metadata");
album_card.append(album_metadata);
if (album != "root")
{
const child_count = document.createElement("span");
child_count.classList.add("album_card_child_count");
child_count.title = `${album.children_count} child albums`;
child_count.innerText = album.children_count;
album_metadata.append(child_count);
album_metadata.append(" | ");
const photo_count = document.createElement("span");
photo_count.classList.add("album_card_photo_count");
photo_count.title = `${album.photos_count} photos`;
photo_count.innerText = album.photos_count;
album_metadata.append(photo_count);
}
const album_tools = document.createElement("div");
album_tools.classList.add("album_card_tools");
album_card.append(album_tools);
if (unlink_parent !== null)
{
const unlink_button = document.createElement("button");
unlink_button.classList.add("remove_child_button");
unlink_button.classList.add("red_button");
unlink_button.classList.add("button_with_confirm");
unlink_button.dataset.prompt = "Remove child?";
unlink_button.dataset.holderClass = "remove_child_button";
unlink_button.dataset.confirmClass = "red_button";
unlink_button.dataset.cancelClass = "gray_button";
unlink_button.innerText = "Unlink";
unlink_button.addEventListener("click", function(event)
{
return api.albums.remove_child(
unlink_parent,
album.id,
common.refresh_or_alert
);
});
album_tools.append(unlink_button);
}
return album_card;
}
cards.albums.drag_start = cards.albums.drag_start =
function drag_start(event) function drag_start(event)
{ {
@ -72,7 +193,12 @@ function drag_drop(event)
cards.bookmarks = {}; cards.bookmarks = {};
cards.bookmarks.create = cards.bookmarks.create =
function create(bookmark, add_author, add_delete_button, add_url_element) function create({
bookmark,
add_author=false,
add_delete_button=false,
add_url_element=false,
})
{ {
const bookmark_card = document.createElement("div"); const bookmark_card = document.createElement("div");
bookmark_card.className = "bookmark_card" bookmark_card.className = "bookmark_card"
@ -119,6 +245,16 @@ function create(bookmark, add_author, add_delete_button, add_url_element)
} }
} }
if (add_author && bookmark.author !== null)
{
const p = document.createElement("p");
const authorlink = document.createElement("a");
authorlink.href = "/userid/" + bookmark.author.id;
authorlink.innerText = bookmark.author.display_name;
p.append(authorlink);
bookmark_card.append(p);
}
return bookmark_card; return bookmark_card;
} }
@ -150,7 +286,7 @@ function file_link(photo, short)
} }
cards.photos.create = cards.photos.create =
function create(photo, view) function create({photo, view="grid"})
{ {
if (view !== "list" && view !== "grid") if (view !== "list" && view !== "grid")
{ {
@ -230,16 +366,16 @@ function create(photo, view)
photo_card.appendChild(photo_card_thumbnail); photo_card.appendChild(photo_card_thumbnail);
} }
let tag_names_title = []; let tag_names_title = new Set();
let tag_names_inner = ""; let tag_names_inner = "";
for (const tag of photo.tags) for (const photo_tag of photo.tags)
{ {
tag_names_title.push(tag.name); tag_names_title.add(photo_tag.tag_name);
tag_names_inner = "T"; tag_names_inner = "T";
} }
const photo_card_tags = document.createElement("span"); const photo_card_tags = document.createElement("span");
photo_card_tags.className = "photo_card_tags"; photo_card_tags.className = "photo_card_tags";
photo_card_tags.title = tag_names_title.join(","); photo_card_tags.title = Array.from(tag_names_title).join(",");
photo_card_tags.innerText = tag_names_inner; photo_card_tags.innerText = tag_names_inner;
photo_card.appendChild(photo_card_tags); photo_card.appendChild(photo_card_tags);
@ -320,5 +456,74 @@ function photo_rightclick(event)
return false; return false;
} }
/******************************************************************************/
cards.photo_tags = {};
cards.photo_tags.create =
function create({photo_tag, timestamp_onclick=null, remove_button_onclick=null})
{
const photo_tag_card = document.createElement("div");
console.log(photo_tag);
photo_tag_card.dataset.id = photo_tag.id;
photo_tag_card.classList.add("photo_tag_card");
const tag = {"id": photo_tag.tag_id, "name": photo_tag.tag_name};
const tag_card = cards.tags.create({tag: tag});
photo_tag_card.append(tag_card);
if (photo_tag.timestamp !== null)
{
const timestamp = document.createElement("a");
timestamp.innerText = " " + common.seconds_to_hms({seconds: photo_tag.timestamp});
timestamp.addEventListener("click", timestamp_onclick);
photo_tag_card.append(timestamp)
}
if (remove_button_onclick !== null)
{
const remove_button = document.createElement("button");
remove_button.classList.add("remove_tag_button");
remove_button.classList.add("red_button");
remove_button.addEventListener("click", remove_button_onclick);
photo_tag_card.append(remove_button);
}
return photo_tag_card;
}
/******************************************************************************/ /******************************************************************************/
cards.tags = {}; cards.tags = {};
cards.tags.create =
function create({tag, extra_classes=[], link="info", innertext=null, add_alt_description=false})
{
const tag_card = document.createElement("div");
tag_card.dataset.id = tag.id;
tag_card.classList.add("tag_card");
for (const cls of extra_classes)
{
tag_card.classList.add(cls);
}
const a_or_span = link === null ? "span" : "a";
const tag_text = document.createElement(a_or_span);
tag_text.innerText = innertext || tag.name;
if (add_alt_description && tag.description != "")
{
tag_text.title = tag.description;
}
tag_card.append(tag_text);
const href_options = {
"search": `/search?tag_musts=${encodeURIComponent(tag.name)}`,
"search_musts":`/search?tag_musts=${encodeURIComponent(tag.name)}`,
"search_mays": `/search?tag_mays=${encodeURIComponent(tag.name)}`,
"search_forbids": `/search?tag_forbids=${encodeURIComponent(tag.name)}`,
"info": `/tag/${encodeURIComponent(tag.name)}`,
};
const href = href_options[link] || null;
if (href !== null)
{
tag_text.href = href;
}
return tag_card;
}

View file

@ -88,13 +88,13 @@ function hms_render_colons(hours, minutes, seconds)
} }
common.seconds_to_hms = common.seconds_to_hms =
function seconds_to_hms(seconds, args) function seconds_to_hms({
seconds,
renderer=common.hms_render_colons,
force_minutes=false,
force_hours=false,
})
{ {
args = args || {};
const renderer = args["renderer"] || common.hms_render_colons;
const force_minutes = args["force_minutes"] || false;
const force_hours = args["force_hours"] || false;
if (seconds > 0 && seconds < 1) if (seconds > 0 && seconds < 1)
{ {
seconds = 1; seconds = 1;

View file

@ -18,6 +18,7 @@
<script src="/static/js/editor.js"></script> <script src="/static/js/editor.js"></script>
<style> <style>
#bookmark_panel,
#bookmark_list #bookmark_list
{ {
display: flex; display: flex;
@ -35,11 +36,10 @@
<body> <body>
{{header.make_header(session=request.session)}} {{header.make_header(session=request.session)}}
<div id="content_body"> <div id="content_body">
<div id="bookmark_list" class="panel"> <div id="bookmark_panel" class="panel">
<h1>{{bookmarks|length}} Bookmarks</h1> <h1><span class="dynamic_bookmark_count">{{bookmarks|length}}</span> Bookmarks</h1>
{% for bookmark in bookmarks %} <div id="bookmark_list">
{{cards.create_bookmark_card(bookmark, add_delete_button=True, add_url_element=True)}} </div>
{% endfor %}
<div id="new_bookmark_card" class="bookmark_card"> <div id="new_bookmark_card" class="bookmark_card">
<input id="new_bookmark_title" type="text" placeholder="title (optional)"> <input id="new_bookmark_title" type="text" placeholder="title (optional)">
@ -53,6 +53,12 @@
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
const BOOKMARKS = [
{% for bookmark in bookmarks %}
{{bookmark.jsonify()|tojson|safe}},
{% endfor %}
];
function create_bookmark_form() function create_bookmark_form()
{ {
const url = document.getElementById("new_bookmark_url").value.trim(); const url = document.getElementById("new_bookmark_url").value.trim();
@ -72,20 +78,17 @@ function create_bookmark_callback(response)
return; return;
} }
const bookmark = response.data; const bookmark = response.data;
const add_author = true; const bookmark_card = cards.bookmarks.create({
const add_delete_button = true; bookmark: bookmark,
const add_url_element = true; add_author: true,
const bookmark_card = cards.bookmarks.create( add_delete_button: true,
bookmark, add_url_element: true,
add_author, });
add_delete_button,
add_url_element
);
create_editor(bookmark_card); create_editor(bookmark_card);
const bookmark_list = document.getElementById("bookmark_list"); const bookmark_panel = document.getElementById("bookmark_panel");
const new_bookmark_card = document.getElementById("new_bookmark_card"); const new_bookmark_card = document.getElementById("new_bookmark_card");
bookmark_list.insertBefore(bookmark_card, new_bookmark_card); bookmark_panel.insertBefore(bookmark_card, new_bookmark_card);
document.getElementById("new_bookmark_url").value = ""; document.getElementById("new_bookmark_url").value = "";
document.getElementById("new_bookmark_title").value = ""; document.getElementById("new_bookmark_title").value = "";
@ -182,6 +185,17 @@ function create_editors()
function on_pageload() function on_pageload()
{ {
common.update_dynamic_elements("dynamic_bookmark_count", BOOKMARKS.length);
for (const bookmark of BOOKMARKS)
{
const bookmark_card = cards.bookmarks.create({
bookmark: bookmark,
add_author: true,
add_delete_button: true,
add_url_element: true,
});
document.getElementById("bookmark_list").append(bookmark_card);
}
create_editors(); create_editors();
} }
document.addEventListener("DOMContentLoaded", on_pageload); document.addEventListener("DOMContentLoaded", on_pageload);

View file

@ -63,46 +63,6 @@ draggable=true
</div> </div>
{% endmacro %} {% endmacro %}
{# BOOKMARK ###################################################################}
{% macro create_bookmark_card(bookmark, add_author=True, add_delete_button=False, add_url_element=False) %}
<div class="bookmark_card" data-id="{{bookmark.id}}">
<h2><a href="{{bookmark.url}}" class="bookmark_title" title="{{bookmark.url}}">{{bookmark.display_name}}</a></h2>
{# The URL element is always display:none, but its presence is useful in #}
{# facilitating the Editor object. If this bookmark will not be editable, #}
{# there is no need for it. #}
{% if add_url_element %}
<a href="{{bookmark.url}}" class="bookmark_url">{{bookmark.url}}</a>
{% endif %}
{# if more tools are added, this will become an `or` #}
{% if add_delete_button %}
<div class="bookmark_toolbox">
{% if add_delete_button %}
<button
class="red_button button_with_confirm"
data-onclick="return delete_bookmark_form(event);"
data-prompt="Delete Bookmark?"
data-cancel-class="gray_button"
>
Delete
</button>
{% endif %}
</div>
{% endif %}
{% if add_author %}
{% set author = bookmark.author %}
{% if author is not none %}
<p>
Author: <a href="/userid/{{author.id}}">{{author.display_name}}</a>
</p>
{% endif %}
{% endif %}
</div>
{% endmacro %}
{# PHOTO ######################################################################} {# PHOTO ######################################################################}
{# Priority: specific extensions > specific mimetypes > general mimtypes #} {# Priority: specific extensions > specific mimetypes > general mimtypes #}
@ -132,11 +92,7 @@ draggable=true
{% do metadatas.append("{d}".format(d=photo.duration_string)) %} {% do metadatas.append("{d}".format(d=photo.duration_string)) %}
{% endif -%} {% endif -%}
{% set tag_names_title = [] %} {% set tag_names_title = photo.get_tag_names()|sort|comma_join %}
{% for tag in photo.get_tags() %}
{% do tag_names_title.append(tag.name) %}
{% endfor -%}
{% set tag_names_title = tag_names_title|comma_join %}
{% set tag_names_inner = "T" if tag_names_title else "" %} {% set tag_names_inner = "T" if tag_names_title else "" %}
<div <div
@ -203,6 +159,7 @@ draggable="true"
--> -->
{%- macro create_tag_card( {%- macro create_tag_card(
tag, tag,
photo_tag_rel_id=None,
extra_classes="", extra_classes="",
innertext=None, innertext=None,
innertext_safe=None, innertext_safe=None,
@ -225,6 +182,6 @@ draggable="true"
{%- set innertext = innertext_safe or (innertext or tag.name)|e -%} {%- set innertext = innertext_safe or (innertext or tag.name)|e -%}
{%- set element = "a" if (link or onclick) else "span" -%} {%- set element = "a" if (link or onclick) else "span" -%}
<{{element}} {{make_attributes(class=class, title=title, href=href, onclick=onclick, **kwargs)|safe}}>{{innertext|safe}}</{{element}}> <{{element}} {{make_attributes(data_id=photo_tag_rel_id, class=class, title=title, href=href, onclick=onclick, **kwargs)|safe}}>{{innertext|safe}}</{{element}}>
{{-''-}} {{-''-}}
{%- endmacro -%} {%- endmacro -%}

View file

@ -233,7 +233,7 @@ function request_more_divs()
} }
for (const photo of response.data) for (const photo of response.data)
{ {
photo_div = cards.photos.create(photo); photo_div = cards.photos.create({photo: photo});
divs[photo.id] = photo_div; divs[photo.id] = photo_div;
needed.delete(photo.id) needed.delete(photo.id)
} }

View file

@ -12,6 +12,7 @@
<link rel="stylesheet" href="/static/css/cards.css"> <link rel="stylesheet" href="/static/css/cards.css">
<script src="/static/js/common.js"></script> <script src="/static/js/common.js"></script>
<script src="/static/js/api.js"></script> <script src="/static/js/api.js"></script>
<script src="/static/js/cards.js"></script>
<script src="/static/js/hotkeys.js"></script> <script src="/static/js/hotkeys.js"></script>
<script src="/static/js/http.js"></script> <script src="/static/js/http.js"></script>
<script src="/static/js/photo_clipboard.js"></script> <script src="/static/js/photo_clipboard.js"></script>
@ -165,21 +166,14 @@
<!-- TAG INFO --> <!-- TAG INFO -->
<h4>Tags</h4> <h4>Tags</h4>
{% if photo.simple_mimetype == "audio" or photo.simple_mimetype == "video" %}
<label><input type="checkbox" id="use_photo_tag_timestamps"/>Add tags with timestamps</label>
{% endif %}
<ul id="this_tags"> <ul id="this_tags">
<li> <li id="add_tag_li">
<input type="text" id="add_tag_textbox" class="entry_with_history entry_with_tagname_replacements" list="tag_autocomplete_datalist"> <input type="text" id="add_tag_textbox" class="entry_with_history entry_with_tagname_replacements" list="tag_autocomplete_datalist">
<button id="add_tag_button" class="green_button" onclick="return add_photo_tag_form();">add</button> <button id="add_tag_button" class="green_button" onclick="return add_photo_tag_form();">add</button>
</li> </li>
{% set tags = photo.get_tags()|sort(attribute='name') %}
{% for tag in tags %}
<li>
{{cards.create_tag_card(tag, link="info", with_alt_description=True)}}<!--
--><button
class="remove_tag_button red_button"
onclick="return remove_photo_tag_form('{{photo.id}}', '{{tag.name}}');">
</button>
</li>
{% endfor %}
</ul> </ul>
<!-- METADATA & DOWNLOAD --> <!-- METADATA & DOWNLOAD -->
@ -313,12 +307,23 @@
<script type="text/javascript"> <script type="text/javascript">
const PHOTO_ID = "{{photo.id}}"; const PHOTO_ID = "{{photo.id}}";
const PHOTO_TAGS = [
{% for photo_tag in photo.get_tags()|sort %}
{{photo_tag.jsonify()|tojson|safe}},
{% endfor %}
];
const add_tag_box = document.getElementById('add_tag_textbox'); const add_tag_box = document.getElementById('add_tag_textbox');
const add_tag_button = document.getElementById('add_tag_button'); const add_tag_button = document.getElementById('add_tag_button');
common.bind_box_to_button(add_tag_box, add_tag_button, false); common.bind_box_to_button(add_tag_box, add_tag_button, false);
const message_area = document.getElementById('message_area'); const message_area = document.getElementById('message_area');
const PHOTO_MEDIA = (
document.querySelector(".photo_viewer_video video")
|| document.querySelector(".photo_viewer_audio audio")
|| null
);
// API ///////////////////////////////////////////////////////////////////////////////////////////// // API /////////////////////////////////////////////////////////////////////////////////////////////
function add_photo_tag_form() function add_photo_tag_form()
@ -328,7 +333,8 @@ function add_photo_tag_form()
{ {
return; return;
} }
api.photos.add_tag(PHOTO_ID, tagname, add_photo_tag_callback); const timestamp = get_media_timestamp();
api.photos.add_tag(PHOTO_ID, tagname, timestamp, add_photo_tag_callback);
add_tag_box.value = ""; add_tag_box.value = "";
} }
@ -339,27 +345,29 @@ function add_photo_tag_callback(response)
{ {
return; return;
} }
const photo_tag = response.data;
const this_tags = document.getElementById("this_tags"); const this_tags = document.getElementById("this_tags");
const tag_cards = this_tags.getElementsByClassName("tag_card"); const photo_tag_cards = this_tags.getElementsByClassName("photo_tag_card");
for (const tag_card of tag_cards) for (const photo_tag_card of photo_tag_cards)
{ {
if (tag_card.innerText === response.data.tagname) if (photo_tag_card.dataset.id == photo_tag.id)
{ {
return; return;
} }
} }
const li = document.createElement("li"); const li = document.createElement("li");
const tag_card = document.createElement("a"); const card = cards.photo_tags.create({
tag_card.className = "tag_card" photo_tag: photo_tag,
tag_card.href = "/tag/" + response.data.tagname; "timestamp_onclick": ()=>{seek_media(photo_tag.timestamp)},
tag_card.innerText = response.data.tagname; "remove_button_onclick": ()=>{remove_photo_tag_form(photo_tag.id)},
const remove_button = document.createElement("button"); });
remove_button.className = "remove_tag_button red_button" li.append(card);
remove_button.onclick = () => remove_photo_tag_form(PHOTO_ID, response.data.tagname); this_tags.append(li);
li.appendChild(tag_card);
li.appendChild(remove_button);
this_tags.appendChild(li);
sort_tag_cards(); sort_tag_cards();
common.create_message_bubble(message_area, "message_positive", `Added tag ${response.data.tag_name}`, 8000);
} }
function copy_other_photo_tags_form(event) function copy_other_photo_tags_form(event)
@ -372,9 +380,9 @@ function copy_other_photo_tags_form(event)
api.photos.copy_tags(PHOTO_ID, other_photo, common.refresh_or_alert); api.photos.copy_tags(PHOTO_ID, other_photo, common.refresh_or_alert);
} }
function remove_photo_tag_form(photo_id, tagname) function remove_photo_tag_form(photo_tag_rel_id)
{ {
api.photos.remove_tag(photo_id, tagname, remove_photo_tag_callback); api.photo_tag_rel.delete({id: photo_tag_rel_id, callback: remove_photo_tag_callback});
add_tag_box.focus(); add_tag_box.focus();
} }
@ -385,15 +393,17 @@ function remove_photo_tag_callback(response)
{ {
return; return;
} }
const tag_cards = document.getElementById("this_tags").getElementsByClassName("tag_card"); const photo_tag_cards = document.getElementById("this_tags").getElementsByClassName("photo_tag_card");
for (const tag_card of tag_cards) for (const photo_tag_card of photo_tag_cards)
{ {
if (tag_card.innerText === response.data.tagname) console.log(photo_tag_card);
if (photo_tag_card.dataset.id == response.data.id)
{ {
const li = tag_card.parentElement; const li = photo_tag_card.parentElement;
li.parentElement.removeChild(li); li.parentElement.removeChild(li);
} }
} }
common.create_message_bubble(message_area, "message_positive", `Removed tag ${response.data.tag_name}`, 8000);
} }
function add_remove_photo_tag_callback(response) function add_remove_photo_tag_callback(response)
@ -403,30 +413,12 @@ function add_remove_photo_tag_callback(response)
alert(JSON.stringify(response)); alert(JSON.stringify(response));
return; return;
} }
let message_text; let abort = false;
let message_positivity;
let abort;
if ("error_type" in response.data) if ("error_type" in response.data)
{ {
message_positivity = "message_negative";
message_text = response.data.error_message;
abort = true; abort = true;
common.create_message_bubble(message_area, "message_negative", response.data.error_message, 8000);
} }
else
{
const tagname = response.data.tagname;
message_positivity = "message_positive";
if (response.meta.kwargs.url.includes("add_tag"))
{
message_text = "Added tag " + tagname;
}
else if (response.meta.kwargs.url.includes("remove_tag"))
{
message_text = "Removed tag " + tagname;
}
abort = false;
}
common.create_message_bubble(message_area, message_positivity, message_text, 8000);
return abort; return abort;
} }
@ -520,8 +512,16 @@ function sort_tag_cards()
const lis = Array.from(tag_list.children).filter(el => el.getElementsByClassName("tag_card").length); const lis = Array.from(tag_list.children).filter(el => el.getElementsByClassName("tag_card").length);
function compare(li1, li2) function compare(li1, li2)
{ {
const tag1 = li1.querySelector(".tag_card:last-of-type").innerText; if (li1.id == "add_tag_li")
const tag2 = li2.querySelector(".tag_card:last-of-type").innerText; {
return -1;
}
if (li2.id == "add_tag_li")
{
return 1;
}
const tag1 = li1.querySelector(".tag_card").innerText;
const tag2 = li2.querySelector(".tag_card").innerText;
return tag1 < tag2 ? -1 : 1; return tag1 < tag2 ? -1 : 1;
} }
lis.sort(compare); lis.sort(compare);
@ -531,6 +531,58 @@ function sort_tag_cards()
} }
} }
function seek_media(timestamp)
{
if (PHOTO_MEDIA === null)
{
return null;
}
const paused = PHOTO_MEDIA.paused;
if (PHOTO_MEDIA !== 4)
{
PHOTO_MEDIA.play();
}
PHOTO_MEDIA.currentTime = timestamp;
if (paused)
{
PHOTO_MEDIA.pause()
}
}
function get_media_timestamp()
{
if (PHOTO_MEDIA === null)
{
return null;
}
if (! document.getElementById("use_photo_tag_timestamps").checked)
{
return null;
}
if (PHOTO_MEDIA.currentTime === 0)
{
return null;
}
return PHOTO_MEDIA.currentTime;
}
function media_frame_by_frame_left(event)
{
if (PHOTO_MEDIA === null)
{
return null;
}
seek_media(PHOTO_MEDIA.currentTime - (1/30));
}
function media_frame_by_frame_right(event)
{
seek_media(PHOTO_MEDIA.currentTime + (1/30));
}
// UI - HOVERZOOM ////////////////////////////////////////////////////////////////////////////////// // UI - HOVERZOOM //////////////////////////////////////////////////////////////////////////////////
const ZOOM_BG_URL = "url('{{photo|file_link}}')"; const ZOOM_BG_URL = "url('{{photo|file_link}}')";
@ -664,8 +716,23 @@ function autofocus_add_tag_box()
function on_pageload() function on_pageload()
{ {
for (const photo_tag of PHOTO_TAGS)
{
const li = document.createElement("li");
console.log(photo_tag);
const card = cards.photo_tags.create({
photo_tag: photo_tag,
timestamp_onclick: ()=>{seek_media(photo_tag.timestamp)},
remove_button_onclick: ()=>{remove_photo_tag_form(photo_tag.id)},
});
li.append(card);
document.getElementById("this_tags").append(li);
}
autofocus_add_tag_box(); autofocus_add_tag_box();
photo_clipboard.apply_check(document.getElementById("clipboard_checkbox")); photo_clipboard.apply_check(document.getElementById("clipboard_checkbox"));
hotkeys.register_hotkey(",", media_frame_by_frame_left, "Frame-by-frame seek left.");
hotkeys.register_hotkey(".", media_frame_by_frame_right, "Frame-by-frame seek right.");
photo_clipboard.register_hotkeys(); photo_clipboard.register_hotkeys();
} }
document.addEventListener("DOMContentLoaded", on_pageload); document.addEventListener("DOMContentLoaded", on_pageload);

View file

@ -19,7 +19,6 @@ body
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100%; height: 100%;
margin: 0;
} }
body > #motd, body > #motd,
body > .link_group, body > .link_group,
@ -41,7 +40,6 @@ body > .nice_link
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
margin: 8px 0;
height: 40px; height: 40px;
background-color: var(--color_transparency); background-color: var(--color_transparency);
} }

View file

@ -60,6 +60,11 @@
word-wrap: break-word; word-wrap: break-word;
} }
#search_go_button,
#swipe_go_button
{
width: 100%;
}
#tags_on_this_page_holder #tags_on_this_page_holder
{ {
/* /*
@ -362,6 +367,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div id="right" class="panel"> <div id="right" class="panel">
<div id="error_message_area"> <div id="error_message_area">
{% for warning in warnings %} {% for warning in warnings %}

View file

@ -375,7 +375,7 @@ function process_current_photo(action, action_tag)
{ {
return; return;
} }
api.photos.add_tag(current_photo.id, action_tag, add_remove_tag_callback); api.photos.add_tag(current_photo.id, action_tag, null, add_remove_tag_callback);
} }
if (action === "remove_tag") if (action === "remove_tag")
{ {

View file

@ -29,11 +29,11 @@
grid-row-gap: 8px; grid-row-gap: 8px;
grid-auto-rows: max-content; grid-auto-rows: max-content;
} }
#left ul .panel
{ {
list-style-type: none; display: grid;
margin: 0; grid-auto-rows: auto;
padding: 0; grid-gap: 8px;
} }
#hierarchy_self h1 .editor_input #hierarchy_self h1 .editor_input
{ {
@ -111,7 +111,6 @@
> >
{{-specific_tag.description-}} {{-specific_tag.description-}}
</pre> </pre>
<button class="green_button editor_toolbox_placeholder">Edit</button>
<button <button
class="red_button button_with_confirm" class="red_button button_with_confirm"
@ -134,15 +133,15 @@
{% if parents %} {% if parents %}
<div id="hierarchy_parents" class="panel"> <div id="hierarchy_parents" class="panel">
<h2>{{parents|length}} Parents</h2> <h2>{{parents|length}} Parents</h2>
<ul id="parent_list"> <div id="parent_list">
{% for ancestor in specific_tag.get_parents() %} {% for ancestor in specific_tag.get_parents() %}
<li> <div>
{{cards.create_tag_card(ancestor, link="search_musts", innertext="(+)")}} {{cards.create_tag_card(ancestor, link="search_musts", innertext="(+)")}}
{{cards.create_tag_card(ancestor, link="search_forbids", innertext="(x)")}} {{cards.create_tag_card(ancestor, link="search_forbids", innertext="(x)")}}
{{cards.create_tag_card(ancestor, link="info", innertext=ancestor.name, with_alt_description=True)}} {{cards.create_tag_card(ancestor, link="info", innertext=ancestor.name, with_alt_description=True)}}
</li> </div>
{% endfor %} {% endfor %}
</ul> </div>
</div> <!-- hierarchy_parents --> </div> <!-- hierarchy_parents -->
{% endif %} <!-- if parents --> {% endif %} <!-- if parents -->
{% endif %} <!-- if specific tag --> {% endif %} <!-- if specific tag -->
@ -158,9 +157,9 @@
<input disabled class="enable_on_pageload entry_with_tagname_replacements" type="text" id="search_filter" placeholder="filter"/> <input disabled class="enable_on_pageload entry_with_tagname_replacements" type="text" id="search_filter" placeholder="filter"/>
</div> </div>
<ul id="tag_list"> <div id="tag_list">
{% for (qualified_name, tag) in tags %} {% for (qualified_name, tag) in tags %}
<li> <div>
{{cards.create_tag_card(tag, link="info", extra_classes="main_card", innertext=qualified_name, with_alt_description=True)-}} {{cards.create_tag_card(tag, link="info", extra_classes="main_card", innertext=qualified_name, with_alt_description=True)-}}
{% if specific_tag or '.' in qualified_name -%} {% if specific_tag or '.' in qualified_name -%}
<button <button
@ -187,11 +186,11 @@
Delete Delete
</button> </button>
{% endif %} {% endif %}
</li> </div>
{% if include_synonyms %} {% if include_synonyms %}
{% for synonym in tag.get_synonyms()|sort %} {% for synonym in tag.get_synonyms()|sort %}
<li> <div>
{# -cards.create_tag_card(tag, link="search_musts", extra_classes="must_shortcut", innertext="(+)") #} {# -cards.create_tag_card(tag, link="search_musts", extra_classes="must_shortcut", innertext="(+)") #}
{# cards.create_tag_card(tag, link="search_forbids", extra_classes="forbid_shortcut", innertext="(x)") #} {# cards.create_tag_card(tag, link="search_forbids", extra_classes="forbid_shortcut", innertext="(x)") #}
{{cards.create_tag_card(tag, link='info', extra_classes="main_card", innertext=qualified_name + '+' + synonym)-}} {{cards.create_tag_card(tag, link='info', extra_classes="main_card", innertext=qualified_name + '+' + synonym)-}}
@ -206,27 +205,27 @@
> >
Remove Remove
</button> </button>
</li> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if specific_tag %} {% if specific_tag %}
<li> <div>
<input id="add_child_input" type="text" class="entry_with_tagname_replacements" list="tag_autocomplete_datalist"></input><!-- <input id="add_child_input" type="text" class="entry_with_tagname_replacements" list="tag_autocomplete_datalist"></input><!--
--><button id="add_child_button" class="green_button" onclick="return add_child_form(event);">Add child</button> --><button id="add_child_button" class="green_button" onclick="return add_child_form(event);">Add child</button>
</li> </div>
{% endif %} <!-- if specific_tag --> {% endif %} <!-- if specific_tag -->
</ul> </div> <!-- tag_list -->
</div> <!-- hierarchy_tags --> </div> <!-- hierarchy_tags -->
{% if specific_tag and include_synonyms %} {% if specific_tag and include_synonyms %}
{% set synonyms = specific_tag.get_synonyms() %} {% set synonyms = specific_tag.get_synonyms() %}
<div id="hierarchy_synonyms" class="panel"> <div id="hierarchy_synonyms" class="panel">
<h2>{{synonyms|length}} Synonyms</h2> <h2>{{synonyms|length}} Synonyms</h2>
<ul> <div>
{% for synonym in synonyms %} {% for synonym in synonyms %}
<li> <div>
{{cards.create_tag_card(specific_tag, link="search_musts", innertext="(+)")}} {{cards.create_tag_card(specific_tag, link="search_musts", innertext="(+)")}}
{{cards.create_tag_card(specific_tag, link="search_forbids", innertext="(x)")}} {{cards.create_tag_card(specific_tag, link="search_forbids", innertext="(x)")}}
@ -242,14 +241,14 @@
> >
Remove Remove
</button> </button>
</li> </div>
{% endfor %} {% endfor %}
<li> <div>
<input id="add_synonym_input" type="text" class="entry_with_tagname_replacements"></input><!-- <input id="add_synonym_input" type="text" class="entry_with_tagname_replacements"></input><!--
--><button id="add_synonym_button" class="green_button" onclick="return add_synonym_form(event);">Add synonym</button> --><button id="add_synonym_button" class="green_button" onclick="return add_synonym_form(event);">Add synonym</button>
</li> </div>
</ul> </div>
</div> <!-- hierarchy_synonyms --> </div> <!-- hierarchy_synonyms -->
{% endif %} <!-- if specific tag and include synonyms --> {% endif %} <!-- if specific tag and include synonyms -->
@ -257,6 +256,8 @@
<div id="hierarchy_recentphotos" class="panel"> <div id="hierarchy_recentphotos" class="panel">
<h2><a href="/search?tag_musts={{specific_tag.name}}&orderby=tagged_at-desc">Recent photos</a></h2> <h2><a href="/search?tag_musts={{specific_tag.name}}&orderby=tagged_at-desc">Recent photos</a></h2>
<span class="spinner hidden"></span> <span class="spinner hidden"></span>
<div id="recentphotos_list" class="photos_holder">
</div>
</div> <!-- hierarchy_recentphotos --> </div> <!-- hierarchy_recentphotos -->
{% endif %} <!-- if specific tag --> {% endif %} <!-- if specific tag -->
</div> </div>
@ -275,17 +276,18 @@ common.bind_box_to_button(easybake_input, easybake_button, false);
function search_recent_photos() function search_recent_photos()
{ {
const div = document.getElementById("hierarchy_recentphotos"); const hierarchy_recentphotos = document.getElementById("hierarchy_recentphotos");
const spinner = div.querySelector(".spinner"); const recentphotos_list = document.getElementById("recentphotos_list");
const spinner = hierarchy_recentphotos.querySelector(".spinner");
spinner.classList.remove("hidden"); spinner.classList.remove("hidden");
function callback(response) function callback(response)
{ {
spinner.classList.add("hidden"); spinner.classList.add("hidden");
const count = response.data.results.length + (response.data.more_after_limit ? "+" : ""); const count = response.data.results.length + (response.data.more_after_limit ? "+" : "");
div.querySelector("h2").prepend(count + " "); hierarchy_recentphotos.querySelector("h2").prepend(count + " ");
for (const photo of response.data.results) for (const photo of response.data.results)
{ {
div.append(cards.photos.create(photo, "grid")); recentphotos_list.append(cards.photos.create({photo: photo, view: "grid"}));
} }
} }
const parameters = new URLSearchParams(); const parameters = new URLSearchParams();

View file

@ -12,6 +12,7 @@
<link rel="stylesheet" href="/static/css/cards.css"> <link rel="stylesheet" href="/static/css/cards.css">
<script src="/static/js/common.js"></script> <script src="/static/js/common.js"></script>
<script src="/static/js/api.js"></script> <script src="/static/js/api.js"></script>
<script src="/static/js/cards.js"></script>
<script src="/static/js/editor.js"></script> <script src="/static/js/editor.js"></script>
<script src="/static/js/http.js"></script> <script src="/static/js/http.js"></script>
<script src="/static/js/spinners.js"></script> <script src="/static/js/spinners.js"></script>
@ -22,6 +23,17 @@
grid-row-gap: 8px; grid-row-gap: 8px;
grid-auto-rows: max-content; grid-auto-rows: max-content;
} }
#hierarchy_photos:not(:has(.photo_card)),
#hierarchy_albums:not(:has(.album_card)),
#hierarchy_tags:not(:has(.tag_card)),
#hierarchy_bookmarks:not(:has(.bookmark_card))
{
display: none;
}
#tags_list .tag_card
{
margin: 4px;
}
</style> </style>
</head> </head>
@ -37,53 +49,90 @@
<p>User since <span title="{{user.created|timestamp_to_8601}}">{{user.created|timestamp_to_naturaldate}}.</span></p> <p>User since <span title="{{user.created|timestamp_to_8601}}">{{user.created|timestamp_to_naturaldate}}.</span></p>
</div> </div>
{% set photos = user.get_photos(direction='desc')|islice(0, 15)|list %}
{% if photos %}
<div id="hierarchy_photos" class="panel"> <div id="hierarchy_photos" class="panel">
<h2><a href="/search?author={{user.id}}">Photos by <span class="dynamic_user_display_name">{{user.display_name}}</span></a></h2> <h2><a href="/search?author={{user.id}}">Photos by <span class="dynamic_user_display_name">{{user.display_name}}</span></a></h2>
{% for photo in photos %} <div id="photos_list">
{{cards.create_photo_card(photo)}} </div>
{% endfor %}
</div> </div>
{% endif %}
{% set tags = user.get_tags(direction='desc')|islice(0, 100)|list %}
{% if tags %}
<div id="hierarchy_tags" class="panel"> <div id="hierarchy_tags" class="panel">
<h2>Tags by <span class="dynamic_user_display_name">{{user.display_name}}</span></h2> <h2>Tags by <span class="dynamic_user_display_name">{{user.display_name}}</span></h2>
{% for tag in tags %} <div id="tags_list">
{{cards.create_tag_card(tag, with_alt_description=True)}} </div>
{% endfor %}
</div> </div>
{% endif %}
{% set albums = user.get_albums()|islice(0, 20)|list %}
{% if albums %}
<div id="hierarchy_albums" class="panel"> <div id="hierarchy_albums" class="panel">
<h2>Albums by <span class="dynamic_user_display_name">{{user.display_name}}</span></h2> <h2>Albums by <span class="dynamic_user_display_name">{{user.display_name}}</span></h2>
{% for album in albums %} <div id="albums_list">
{{cards.create_album_card(album)}} </div>
{% endfor %}
</div> </div>
{% endif %}
{% set bookmarks = user.get_bookmarks()|islice(0, 50)|list %}
{% if bookmarks %}
<div id="hierarchy_bookmarks" class="panel"> <div id="hierarchy_bookmarks" class="panel">
<h2>Bookmarks by <span class="dynamic_user_display_name">{{user.display_name}}</span></h2> <h2>Bookmarks by <span class="dynamic_user_display_name">{{user.display_name}}</span></h2>
{% for bookmark in bookmarks %} <div id="bookmarks_list">
{{cards.create_bookmark_card(bookmark, add_author=False)}} </div>
{% endfor %}
</div> </div>
{% endif %}
</div> </div>
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
const PHOTOS = [
{% for photo in user.get_photos(direction='desc')|islice(0, 15) %}
{{photo.jsonify(include_albums=False)|tojson|safe}},
{% endfor %}
];
const ALBUMS = [
{% for album in user.get_albums()|islice(0, 20) %}
{{album.jsonify(include_photos=False, include_children=False, include_parents=False, count_children=True, count_photos=True)|tojson|safe}},
{% endfor %}
];
const TAGS = [
{% for tag in user.get_tags(direction='desc')|islice(0, 100) %}
{{tag.jsonify()|tojson|safe}},
{% endfor %}
];
const BOOKMARKS = [
{% for bookmark in user.get_bookmarks()|islice(0, 50) %}
{{bookmark.jsonify()|tojson|safe}},
{% endfor %}
];
function on_pageload()
{
for (const photo of PHOTOS)
{
const photo_card = cards.photos.create({photo: photo});
document.getElementById("photos_list").append(photo_card);
}
for (const album of ALBUMS)
{
const album_card = cards.albums.create({album: album});
document.getElementById("albums_list").append(album_card);
}
for (const tag of TAGS)
{
const tag_card = cards.tags.create({tag: tag});
document.getElementById("tags_list").append(tag_card);
}
for (const bookmark of BOOKMARKS)
{
const bookmark_card = cards.bookmarks.create({
bookmark: bookmark,
add_author: false,
add_delete_button: false,
add_url_element: false,
});
document.getElementById("bookmarks_list").append(bookmark_card);
}
}
document.addEventListener("DOMContentLoaded", on_pageload);
{% if user.id == request.session.user.id %} {% if user.id == request.session.user.id %}
const USERNAME = "{{user.username}}"; const USERNAME = "{{user.username}}";
profile_ed_on_open = undefined; profile_ed_on_open = undefined;
function profile_ed_on_save(ed) function profile_ed_on_save(ed)
@ -116,7 +165,7 @@ function profile_ed_on_save(ed)
api.users.edit(USERNAME, ed.elements["display_name"].edit.value, callback); api.users.edit(USERNAME, ed.elements["display_name"].edit.value, callback);
} }
profile_ed_on_cancel = undefined; const profile_ed_on_cancel = undefined;
const profile_ed_elements = [ const profile_ed_elements = [
{ {
@ -127,7 +176,6 @@ const profile_ed_elements = [
"autofocus": true, "autofocus": true,
}, },
]; ];
const profile_ed = new editor.Editor( const profile_ed = new editor.Editor(
profile_ed_elements, profile_ed_elements,
profile_ed_on_open, profile_ed_on_open,

View file

@ -1030,6 +1030,35 @@ def upgrade_23_to_24(photodb):
photodb.execute('ALTER TABLE photos DROP COLUMN thumbnail') photodb.execute('ALTER TABLE photos DROP COLUMN thumbnail')
def upgrade_24_to_25(photodb):
'''
In this version, the photo_tag_rel table got a new column `timestamp` for
tagging individual moments of a video or audio.
'''
m = Migrator(photodb)
m.tables['photo_tag_rel']['create'] = '''
CREATE TABLE IF NOT EXISTS photo_tag_rel(
id INT PRIMARY KEY NOT NULL,
photoid INT NOT NULL,
tagid INT NOT NULL,
created INT,
timestamp REAL,
UNIQUE(photoid, tagid, timestamp),
FOREIGN KEY(photoid) REFERENCES photos(id),
FOREIGN KEY(tagid) REFERENCES tags(id)
);
'''
m.tables['photo_tag_rel']['transfer'] = '''
INSERT INTO photo_tag_rel SELECT
abs(random()),
photoid,
tagid,
created,
null
FROM photo_tag_rel_old;
'''
m.go()
def upgrade_all(data_directory): def upgrade_all(data_directory):
''' '''
Given the directory containing a phototagger database, apply all of the Given the directory containing a phototagger database, apply all of the