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.
master
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_VERSION = 24
DATABASE_VERSION = 25
DB_INIT = '''
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 TABLE IF NOT EXISTS photo_tag_rel(
id INT PRIMARY KEY NOT NULL,
photoid INT NOT NULL,
tagid INT NOT NULL,
created INT,
PRIMARY KEY(photoid, tagid),
timestamp REAL,
UNIQUE(photoid, tagid, timestamp),
FOREIGN KEY(photoid) REFERENCES photos(id),
FOREIGN KEY(tagid) REFERENCES tags(id)
);

View File

@ -583,7 +583,14 @@ class Album(ObjectBase, GroupableMixin):
)
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 = {
'type': 'album',
'id': self.id,
@ -594,12 +601,23 @@ class Album(ObjectBase, GroupableMixin):
'thumbnail_photo': self.thumbnail_photo.id if self._thumbnail_photo 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()]
if include_children:
j['children'] = [child.id for child in self.get_children()]
if include_photos:
j['photos'] = [photo.id for photo in self.get_photos()]
if include_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
@ -884,6 +902,9 @@ class Bookmark(ObjectBase):
'title': self.title,
'display_name': self.display_name,
}
if self.deleted:
j['deleted'] = True
return j
class Photo(ObjectBase):
@ -958,7 +979,7 @@ class Photo(ObjectBase):
return cleaned
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
# file renames. However, I decided not to write it as a @property
# because that would require either wasted computation or using private
@ -977,23 +998,27 @@ class Photo(ObjectBase):
def _uncache(self):
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')
@worms.atomic
def add_tag(self, tag):
def add_tag(self, tag, timestamp=None):
tag = self.photodb.get_tag(name=tag)
if self.has_tag(tag, check_children=False):
return tag
existing = self.has_tag(tag, check_children=False, match_timestamp=timestamp)
if existing:
return existing
log.info('Applying %s to %s.', tag, self)
data = {
'id': self.photodb.generate_id(PhotoTagRel),
'photoid': self.id,
'tagid': tag.id,
'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 = {
'id': self.id,
@ -1001,7 +1026,7 @@ class Photo(ObjectBase):
}
self.photodb.update(table=Photo, pairs=data, where_key='id')
return tag
return photo_tag
def atomify(self, web_root='') -> bs4.BeautifulSoup:
web_root = web_root.rstrip('/')
@ -1129,6 +1154,7 @@ class Photo(ObjectBase):
self.set_thumbnail(image)
return image
@decorators.cache_until_commit
def get_containing_albums(self) -> set[Album]:
'''
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 == ?',
[self.id]
)
albums = set(self.photodb.get_albums_by_id(album_ids))
albums = frozenset(self.photodb.get_albums_by_id(album_ids))
return albums
@decorators.cache_until_commit
@ -1145,12 +1171,16 @@ class Photo(ObjectBase):
'''
Return the tags assigned to this Photo.
'''
tag_ids = self.photodb.select_column(
'SELECT tagid FROM photo_tag_rel WHERE photoid == ?',
photo_tags = frozenset(self.photodb.get_objects_by_sql(
PhotoTagRel,
'SELECT * FROM photo_tag_rel WHERE photoid == ?',
[self.id]
)
tags = set(self.photodb.get_tags_by_id(tag_ids))
return tags
))
return photo_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):
query = 'SELECT thumbnail FROM photo_thumbnails WHERE photoid = ?'
@ -1158,9 +1188,9 @@ class Photo(ObjectBase):
return blob
# 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.
check_children:
@ -1176,15 +1206,19 @@ class Photo(ObjectBase):
tag_by_id = {t.id: t for t in tag_options}
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 tag_by_id[tag_id]
return results[0]
def has_thumbnail(self) -> bool:
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])
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 = {
'type': 'photo',
'id': self.id,
@ -1213,13 +1247,14 @@ class Photo(ObjectBase):
'searchhidden': bool(self.searchhidden),
'simple_mimetype': self.simple_mimetype,
}
if self.deleted:
j['deleted'] = True
if not minimal:
if include_albums:
j['albums'] = [album.id for album in self.get_containing_albums()]
if include_albums:
j['albums'] = [album.id for album in self.get_containing_albums()]
if include_tags:
j['tags'] = [tag.id for tag in self.get_tags()]
if include_tags:
j['tags'] = [photo_tag.jsonify() for photo_tag in self.get_tags()]
return j
@ -1377,6 +1412,11 @@ class Photo(ObjectBase):
@decorators.required_feature('photo.add_remove_tag')
@worms.atomic
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)
log.info('Removing %s from %s.', tag, self)
@ -1384,6 +1424,7 @@ class Photo(ObjectBase):
'photoid': self.id,
'tagid': tag.id,
}
self.photodb.delete(table='photo_tag_rel', pairs=pairs)
data = {
@ -1395,6 +1436,10 @@ class Photo(ObjectBase):
@decorators.required_feature('photo.add_remove_tag')
@worms.atomic
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]
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)
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:
'''
FILE METADATA
@ -1702,11 +1808,16 @@ class Search:
results = [
result.jsonify(include_albums=False)
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
]
j = {
'type': 'search',
'kwargs': kwargs,
'results': results,
'more_after_limit': self.more_after_limit,
@ -2060,28 +2171,6 @@ class Tag(ObjectBase, GroupableMixin):
def _uncache(self):
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')
@worms.atomic
def add_child(self, member):
@ -2246,21 +2335,26 @@ class Tag(ObjectBase, GroupableMixin):
self._cached_synonyms = 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 = {
'type': 'tag',
'id': self.id,
'name': self.name,
'created': self.created_unix,
'author': self.author.jsonify() if self._author_id else None,
'description': self.description,
}
if not minimal:
j['author'] = self.author.jsonify() if self._author_id else None
j['description'] = self.description
if self.deleted:
j['deleted'] = True
if include_parents:
j['parents'] = [parent.id for parent in self.get_parents()]
if include_children:
j['children'] = [child.id for child in self.get_children()]
if include_synonyms:
j['synonyms'] = list(self.get_synonyms())
if include_synonyms:
j['synonyms'] = list(self.get_synonyms())
return j
@ -2532,6 +2626,9 @@ class User(ObjectBase):
'created': self.created_unix,
'display_name': self.display_name,
}
if self.deleted:
j['deleted'] = True
return j
@decorators.required_feature('user.edit')
@ -2573,4 +2670,5 @@ class WarningBag:
self.warnings.add(warning)
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.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)
@site.route('/album/<album_id>/show_in_folder', methods=['POST'])
@ -257,7 +261,11 @@ def post_albums_create():
if parent_id is not None:
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)
@site.route('/album/<album_id>/delete', methods=['POST'])

View File

@ -84,11 +84,6 @@ def get_thumbnail(photo_id, basename=None):
headers=outgoing_headers,
)
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 ##########################################################################
@ -104,21 +99,22 @@ def post_photo_delete(photo_id):
# 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):
photo_ids = stringtools.comma_space_split(photo_ids)
photos = list(common.P_photos(photo_ids, response_type='json'))
tag = common.P_tag(tagname, response_type='json')
response = {'action': add_or_remove, 'tagname': tag.name}
with common.P.transaction:
for photo in photos:
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':
photo.remove_tag(tag)
response = {'action': add_or_remove, 'tagname': tag.name}
return flasktools.json_response(response)
@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.
'''
common.permission_manager.basic()
response = post_photo_add_remove_tag_core(
photo_ids=photo_id,
tagname=request.form['tagname'],
add_or_remove='add',
)
return response
photo = common.P_photo(photo_id, response_type='json')
tag = common.P_tag(request.form['tagname'], response_type='json')
with common.P.transaction:
tag_timestamp = request.form.get('timestamp').strip() or None
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'])
@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')
other = common.P_photo(request.form['other_photo'], response_type='json')
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'])
@flasktools.required_fields(['tagname'], forbid_whitespace=True)
@ -162,6 +159,17 @@ def post_photo_remove_tag(photo_id):
)
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'])
@flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True)
def post_batch_photos_add_tag():
@ -499,7 +507,7 @@ def get_search_html():
total_tags = set()
for result in search.results:
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)
# PREV-NEXT PAGE URLS

View File

@ -114,7 +114,7 @@ def post_tag_remove_synonym(tagname):
@common.permission_manager.basic_decorator
@flasktools.cached_endpoint(max_age=15)
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()
response = {'tags': all_tags, 'synonyms': all_synonyms}
return flasktools.json_response(response)

View File

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

View File

@ -191,12 +191,13 @@ is hovered over.
display: none;
}
li:hover .remove_tag_button,
.tag_card:hover ~ * .remove_tag_button,
.tag_card:hover ~ .remove_tag_button,
.remove_tag_button:hover,
.remove_tag_button_perm:hover
{
display:inline;
display: inline;
}
#message_area

View File

@ -221,11 +221,11 @@ function edit(bookmark_id, title, b_url, callback)
api.photos = {};
api.photos.add_tag =
function add_tag(photo_id, tagname, callback)
function add_tag(photo_id, tagname, timestamp, callback)
{
return http.post({
url: `/photo/${photo_id}/add_tag`,
data: {"tagname": tagname},
data: {"tagname": tagname, "timestamp": timestamp},
callback: callback,
});
}
@ -416,6 +416,18 @@ function callback_go_to_search(response)
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 = {};

View File

@ -3,6 +3,127 @@ const cards = {};
/******************************************************************************/
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 =
function drag_start(event)
{
@ -72,7 +193,12 @@ function drag_drop(event)
cards.bookmarks = {};
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");
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;
}
@ -150,7 +286,7 @@ function file_link(photo, short)
}
cards.photos.create =
function create(photo, view)
function create({photo, view="grid"})
{
if (view !== "list" && view !== "grid")
{
@ -230,16 +366,16 @@ function create(photo, view)
photo_card.appendChild(photo_card_thumbnail);
}
let tag_names_title = [];
let tag_names_title = new Set();
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";
}
const photo_card_tags = document.createElement("span");
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.appendChild(photo_card_tags);
@ -320,5 +456,74 @@ function photo_rightclick(event)
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.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 =
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)
{
seconds = 1;

View File

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

View File

@ -63,46 +63,6 @@ draggable=true
</div>
{% 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 ######################################################################}
{# Priority: specific extensions > specific mimetypes > general mimtypes #}
@ -132,11 +92,7 @@ draggable=true
{% do metadatas.append("{d}".format(d=photo.duration_string)) %}
{% endif -%}
{% set tag_names_title = [] %}
{% 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_title = photo.get_tag_names()|sort|comma_join %}
{% set tag_names_inner = "T" if tag_names_title else "" %}
<div
@ -203,6 +159,7 @@ draggable="true"
-->
{%- macro create_tag_card(
tag,
photo_tag_rel_id=None,
extra_classes="",
innertext=None,
innertext_safe=None,
@ -225,6 +182,6 @@ draggable="true"
{%- set innertext = innertext_safe or (innertext or tag.name)|e -%}
{%- 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 -%}

View File

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

View File

@ -12,6 +12,7 @@
<link rel="stylesheet" href="/static/css/cards.css">
<script src="/static/js/common.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/http.js"></script>
<script src="/static/js/photo_clipboard.js"></script>
@ -165,21 +166,14 @@
<!-- TAG INFO -->
<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">
<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">
<button id="add_tag_button" class="green_button" onclick="return add_photo_tag_form();">add</button>
</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>
<!-- METADATA & DOWNLOAD -->
@ -313,12 +307,23 @@
<script type="text/javascript">
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_button = document.getElementById('add_tag_button');
common.bind_box_to_button(add_tag_box, add_tag_button, false);
const message_area = document.getElementById('message_area');
const PHOTO_MEDIA = (
document.querySelector(".photo_viewer_video video")
|| document.querySelector(".photo_viewer_audio audio")
|| null
);
// API /////////////////////////////////////////////////////////////////////////////////////////////
function add_photo_tag_form()
@ -328,7 +333,8 @@ function add_photo_tag_form()
{
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 = "";
}
@ -339,27 +345,29 @@ function add_photo_tag_callback(response)
{
return;
}
const photo_tag = response.data;
const this_tags = document.getElementById("this_tags");
const tag_cards = this_tags.getElementsByClassName("tag_card");
for (const tag_card of tag_cards)
const photo_tag_cards = this_tags.getElementsByClassName("photo_tag_card");
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;
}
}
const li = document.createElement("li");
const tag_card = document.createElement("a");
tag_card.className = "tag_card"
tag_card.href = "/tag/" + response.data.tagname;
tag_card.innerText = response.data.tagname;
const remove_button = document.createElement("button");
remove_button.className = "remove_tag_button red_button"
remove_button.onclick = () => remove_photo_tag_form(PHOTO_ID, response.data.tagname);
li.appendChild(tag_card);
li.appendChild(remove_button);
this_tags.appendChild(li);
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);
this_tags.append(li);
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)
@ -372,9 +380,9 @@ function copy_other_photo_tags_form(event)
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();
}
@ -385,15 +393,17 @@ function remove_photo_tag_callback(response)
{
return;
}
const tag_cards = document.getElementById("this_tags").getElementsByClassName("tag_card");
for (const tag_card of tag_cards)
const photo_tag_cards = document.getElementById("this_tags").getElementsByClassName("photo_tag_card");
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);
}
}
common.create_message_bubble(message_area, "message_positive", `Removed tag ${response.data.tag_name}`, 8000);
}
function add_remove_photo_tag_callback(response)
@ -403,30 +413,12 @@ function add_remove_photo_tag_callback(response)
alert(JSON.stringify(response));
return;
}
let message_text;
let message_positivity;
let abort;
let abort = false;
if ("error_type" in response.data)
{
message_positivity = "message_negative";
message_text = response.data.error_message;
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;
}
@ -520,8 +512,16 @@ function sort_tag_cards()
const lis = Array.from(tag_list.children).filter(el => el.getElementsByClassName("tag_card").length);
function compare(li1, li2)
{
const tag1 = li1.querySelector(".tag_card:last-of-type").innerText;
const tag2 = li2.querySelector(".tag_card:last-of-type").innerText;
if (li1.id == "add_tag_li")
{
return -1;
}
if (li2.id == "add_tag_li")
{
return 1;
}