Create objects.py
Move Album, Photo, Tag, User to objects.py; Move SQL_COLUMNS variables to constants.py so they can be shared; Move more shared helpers to helpers.py
This commit is contained in:
parent
0160af57dd
commit
91fcbb7101
6 changed files with 957 additions and 953 deletions
|
@ -10,7 +10,6 @@ This is the readme file.
|
||||||
- Improve the "tags on this page" list. Maybe add separate buttons for must/may/forbid on each.
|
- Improve the "tags on this page" list. Maybe add separate buttons for must/may/forbid on each.
|
||||||
- Some way for the database to re-identify a file that was moved / renamed (lost & found). Maybe file hash of the first few mb is good enough.
|
- Some way for the database to re-identify a file that was moved / renamed (lost & found). Maybe file hash of the first few mb is good enough.
|
||||||
- Move out more helpers
|
- Move out more helpers
|
||||||
- Create objects.py
|
|
||||||
- Debate whether the `UserMixin.login` method should accept usernames or I should standardize the usage of IDs only internally.
|
- Debate whether the `UserMixin.login` method should accept usernames or I should standardize the usage of IDs only internally.
|
||||||
|
|
||||||
### Changelog
|
### Changelog
|
||||||
|
|
75
constants.py
75
constants.py
|
@ -1,4 +1,15 @@
|
||||||
|
import converter
|
||||||
import string
|
import string
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
ffmpeg = converter.Converter(
|
||||||
|
ffmpeg_path='C:\\software\\ffmpeg\\bin\\ffmpeg.exe',
|
||||||
|
ffprobe_path='C:\\software\\ffmpeg\\bin\\ffprobe.exe',
|
||||||
|
)
|
||||||
|
except converter.ffmpeg.FFMpegError:
|
||||||
|
traceback.print_exc()
|
||||||
|
ffmpeg = None
|
||||||
|
|
||||||
ALLOWED_ORDERBY_COLUMNS = [
|
ALLOWED_ORDERBY_COLUMNS = [
|
||||||
'extension',
|
'extension',
|
||||||
|
@ -13,6 +24,70 @@ ALLOWED_ORDERBY_COLUMNS = [
|
||||||
'random',
|
'random',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SQL_LASTID_COLUMNS = [
|
||||||
|
'table',
|
||||||
|
'last_id',
|
||||||
|
]
|
||||||
|
SQL_ALBUM_COLUMNS = [
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'associated_directory',
|
||||||
|
]
|
||||||
|
SQL_PHOTO_COLUMNS = [
|
||||||
|
'id',
|
||||||
|
'filepath',
|
||||||
|
'override_filename',
|
||||||
|
'extension',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'ratio',
|
||||||
|
'area',
|
||||||
|
'duration',
|
||||||
|
'bytes',
|
||||||
|
'created',
|
||||||
|
'thumbnail',
|
||||||
|
'tagged_at',
|
||||||
|
]
|
||||||
|
SQL_TAG_COLUMNS = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
]
|
||||||
|
SQL_SYN_COLUMNS = [
|
||||||
|
'name',
|
||||||
|
'master',
|
||||||
|
]
|
||||||
|
SQL_ALBUMPHOTO_COLUMNS = [
|
||||||
|
'albumid',
|
||||||
|
'photoid',
|
||||||
|
]
|
||||||
|
SQL_PHOTOTAG_COLUMNS = [
|
||||||
|
'photoid',
|
||||||
|
'tagid',
|
||||||
|
]
|
||||||
|
SQL_TAGGROUP_COLUMNS = [
|
||||||
|
'parentid',
|
||||||
|
'memberid',
|
||||||
|
]
|
||||||
|
SQL_USER_COLUMNS = [
|
||||||
|
'id',
|
||||||
|
'username',
|
||||||
|
'password',
|
||||||
|
'created',
|
||||||
|
]
|
||||||
|
|
||||||
|
_sql_dictify = lambda columns: {key:index for (index, key) in enumerate(columns)}
|
||||||
|
SQL_ALBUM = _sql_dictify(SQL_ALBUM_COLUMNS)
|
||||||
|
SQL_ALBUMPHOTO = _sql_dictify(SQL_ALBUMPHOTO_COLUMNS)
|
||||||
|
SQL_LASTID = _sql_dictify(SQL_LASTID_COLUMNS)
|
||||||
|
SQL_PHOTO = _sql_dictify(SQL_PHOTO_COLUMNS)
|
||||||
|
SQL_PHOTOTAG = _sql_dictify(SQL_PHOTOTAG_COLUMNS)
|
||||||
|
SQL_SYN = _sql_dictify(SQL_SYN_COLUMNS)
|
||||||
|
SQL_TAG = _sql_dictify(SQL_TAG_COLUMNS)
|
||||||
|
SQL_TAGGROUP = _sql_dictify(SQL_TAGGROUP_COLUMNS)
|
||||||
|
SQL_USER = _sql_dictify(SQL_USER_COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
# Errors and warnings
|
# Errors and warnings
|
||||||
ERROR_DATABASE_OUTOFDATE = 'Database is out-of-date. {current} should be {new}. Please use etiquette_upgrader.py'
|
ERROR_DATABASE_OUTOFDATE = 'Database is out-of-date. {current} should be {new}. Please use etiquette_upgrader.py'
|
||||||
ERROR_INVALID_ACTION = 'Invalid action'
|
ERROR_INVALID_ACTION = 'Invalid action'
|
||||||
|
|
12
etiquette.py
12
etiquette.py
|
@ -4,10 +4,6 @@ import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
|
||||||
import requests
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import constants
|
import constants
|
||||||
|
@ -19,7 +15,6 @@ import phototagger
|
||||||
|
|
||||||
# pip install
|
# pip install
|
||||||
# https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
|
# https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
|
||||||
from voussoirkit import bytestring
|
|
||||||
from voussoirkit import webstreamzip
|
from voussoirkit import webstreamzip
|
||||||
|
|
||||||
site = flask.Flask(__name__)
|
site = flask.Flask(__name__)
|
||||||
|
@ -211,7 +206,7 @@ def get_album_tar(albumid):
|
||||||
photos = list(album.walk_photos())
|
photos = list(album.walk_photos())
|
||||||
zipname_map = {p.real_filepath: '%s - %s' % (p.id, p.basename) for p in photos}
|
zipname_map = {p.real_filepath: '%s - %s' % (p.id, p.basename) for p in photos}
|
||||||
streamed_zip = webstreamzip.stream_tar(zipname_map)
|
streamed_zip = webstreamzip.stream_tar(zipname_map)
|
||||||
content_length = sum(p.bytes for p in photos)
|
#content_length = sum(p.bytes for p in photos)
|
||||||
outgoing_headers = {'Content-Type': 'application/octet-stream'}
|
outgoing_headers = {'Content-Type': 'application/octet-stream'}
|
||||||
return flask.Response(streamed_zip, headers=outgoing_headers)
|
return flask.Response(streamed_zip, headers=outgoing_headers)
|
||||||
|
|
||||||
|
@ -242,7 +237,6 @@ def get_bookmarks():
|
||||||
|
|
||||||
@site.route('/file/<photoid>')
|
@site.route('/file/<photoid>')
|
||||||
def get_file(photoid):
|
def get_file(photoid):
|
||||||
requested_photoid = photoid
|
|
||||||
photoid = photoid.split('.')[0]
|
photoid = photoid.split('.')[0]
|
||||||
photo = P.get_photo(photoid)
|
photo = P.get_photo(photoid)
|
||||||
|
|
||||||
|
@ -445,8 +439,8 @@ def get_search_html():
|
||||||
@decorators.give_session_token
|
@decorators.give_session_token
|
||||||
def get_search_json():
|
def get_search_json():
|
||||||
search_results = get_search_core()
|
search_results = get_search_core()
|
||||||
search_kwargs = search_results['search_kwargs']
|
#search_kwargs = search_results['search_kwargs']
|
||||||
qualname_map = search_results['qualname_map']
|
#qualname_map = search_results['qualname_map']
|
||||||
include_qualname_map = request.args.get('include_map', False)
|
include_qualname_map = request.args.get('include_map', False)
|
||||||
include_qualname_map = helpers.truthystring(include_qualname_map)
|
include_qualname_map = helpers.truthystring(include_qualname_map)
|
||||||
if not include_qualname_map:
|
if not include_qualname_map:
|
||||||
|
|
42
helpers.py
42
helpers.py
|
@ -1,11 +1,14 @@
|
||||||
|
import datetime
|
||||||
import math
|
import math
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import exceptions
|
|
||||||
import constants
|
import constants
|
||||||
|
import exceptions
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
from voussoirkit import bytestring
|
||||||
|
|
||||||
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
|
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
|
||||||
'''
|
'''
|
||||||
Given a sequence, divide it into sequences of length `chunk_length`.
|
Given a sequence, divide it into sequences of length `chunk_length`.
|
||||||
|
@ -129,6 +132,25 @@ def is_xor(*args):
|
||||||
'''
|
'''
|
||||||
return [bool(a) for a in args].count(True) == 1
|
return [bool(a) for a in args].count(True) == 1
|
||||||
|
|
||||||
|
def normalize_filepath(filepath):
|
||||||
|
'''
|
||||||
|
Remove some bad characters.
|
||||||
|
'''
|
||||||
|
filepath = filepath.replace('/', os.sep)
|
||||||
|
filepath = filepath.replace('\\', os.sep)
|
||||||
|
filepath = filepath.replace('<', '')
|
||||||
|
filepath = filepath.replace('>', '')
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
def now(timestamp=True):
|
||||||
|
'''
|
||||||
|
Return the current UTC timestamp or datetime object.
|
||||||
|
'''
|
||||||
|
n = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
if timestamp:
|
||||||
|
return n.timestamp()
|
||||||
|
return n
|
||||||
|
|
||||||
def read_filebytes(filepath, range_min, range_max, chunk_size=2 ** 20):
|
def read_filebytes(filepath, range_min, range_max, chunk_size=2 ** 20):
|
||||||
'''
|
'''
|
||||||
Yield chunks of bytes from the file between the endpoints.
|
Yield chunks of bytes from the file between the endpoints.
|
||||||
|
@ -158,12 +180,24 @@ def seconds_to_hms(seconds):
|
||||||
(minutes, seconds) = divmod(seconds, 60)
|
(minutes, seconds) = divmod(seconds, 60)
|
||||||
(hours, minutes) = divmod(minutes, 60)
|
(hours, minutes) = divmod(minutes, 60)
|
||||||
parts = []
|
parts = []
|
||||||
if hours: parts.append(hours)
|
if hours:
|
||||||
if minutes: parts.append(minutes)
|
parts.append(hours)
|
||||||
|
if minutes:
|
||||||
|
parts.append(minutes)
|
||||||
parts.append(seconds)
|
parts.append(seconds)
|
||||||
hms = ':'.join('%02d' % part for part in parts)
|
hms = ':'.join('%02d' % part for part in parts)
|
||||||
return hms
|
return hms
|
||||||
|
|
||||||
|
def select_generator(sql, query, bindings=None):
|
||||||
|
bindings = bindings or []
|
||||||
|
cursor = sql.cursor()
|
||||||
|
cursor.execute(query, bindings)
|
||||||
|
while True:
|
||||||
|
fetch = cursor.fetchone()
|
||||||
|
if fetch is None:
|
||||||
|
break
|
||||||
|
yield fetch
|
||||||
|
|
||||||
def truthystring(s):
|
def truthystring(s):
|
||||||
if isinstance(s, (bool, int)) or s is None:
|
if isinstance(s, (bool, int)) or s is None:
|
||||||
return s
|
return s
|
||||||
|
@ -279,7 +313,7 @@ def _unitconvert(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if ':' in value:
|
if ':' in value:
|
||||||
return helpers.hms_to_seconds(value)
|
return hms_to_seconds(value)
|
||||||
elif all(c in '0123456789.' for c in value):
|
elif all(c in '0123456789.' for c in value):
|
||||||
return float(value)
|
return float(value)
|
||||||
else:
|
else:
|
||||||
|
|
793
objects.py
Normal file
793
objects.py
Normal file
|
@ -0,0 +1,793 @@
|
||||||
|
import os
|
||||||
|
import PIL.Image
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import constants
|
||||||
|
import decorators
|
||||||
|
import exceptions
|
||||||
|
import helpers
|
||||||
|
|
||||||
|
# pip install
|
||||||
|
# https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
|
||||||
|
from voussoirkit import bytestring
|
||||||
|
from voussoirkit import pathclass
|
||||||
|
from voussoirkit import spinal
|
||||||
|
|
||||||
|
class ObjectBase:
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (
|
||||||
|
isinstance(other, type(self)) and
|
||||||
|
self.photodb == other.photodb and
|
||||||
|
self.id == other.id
|
||||||
|
)
|
||||||
|
|
||||||
|
def __format__(self, formcode):
|
||||||
|
if formcode == 'r':
|
||||||
|
return repr(self)
|
||||||
|
else:
|
||||||
|
return str(self)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.id)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupableMixin:
|
||||||
|
def add(self, member, *, commit=True):
|
||||||
|
'''
|
||||||
|
Add a child object to this group.
|
||||||
|
Child must be of the same type as the calling object.
|
||||||
|
|
||||||
|
If that object is already a member of another group, an
|
||||||
|
exceptions.GroupExists is raised.
|
||||||
|
'''
|
||||||
|
if not isinstance(member, type(self)):
|
||||||
|
raise TypeError('Member must be of type %s' % type(self))
|
||||||
|
|
||||||
|
self.photodb.cur.execute('SELECT * FROM tag_group_rel WHERE memberid == ?', [member.id])
|
||||||
|
fetch = self.photodb.cur.fetchone()
|
||||||
|
if fetch is not None:
|
||||||
|
if fetch[constants.SQL_TAGGROUP['parentid']] == self.id:
|
||||||
|
that_group = self
|
||||||
|
else:
|
||||||
|
that_group = self.group_getter(id=fetch[constants.SQL_TAGGROUP['parentid']])
|
||||||
|
raise exceptions.GroupExists('%s already in group %s' % (member.name, that_group.name))
|
||||||
|
|
||||||
|
self.photodb._cached_frozen_children = None
|
||||||
|
self.photodb.cur.execute('INSERT INTO tag_group_rel VALUES(?, ?)', [self.id, member.id])
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Commiting - add to group')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def children(self):
|
||||||
|
self.photodb.cur.execute('SELECT * FROM tag_group_rel WHERE parentid == ?', [self.id])
|
||||||
|
fetch = self.photodb.cur.fetchall()
|
||||||
|
results = []
|
||||||
|
for f in fetch:
|
||||||
|
memberid = f[constants.SQL_TAGGROUP['memberid']]
|
||||||
|
child = self.group_getter(id=memberid)
|
||||||
|
results.append(child)
|
||||||
|
if isinstance(self, Tag):
|
||||||
|
results.sort(key=lambda x: x.name)
|
||||||
|
else:
|
||||||
|
results.sort(key=lambda x: x.id)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def delete(self, *, delete_children=False, commit=True):
|
||||||
|
'''
|
||||||
|
Delete this object's relationships to other groupables.
|
||||||
|
Any unique / specific deletion methods should be written within the
|
||||||
|
inheriting class.
|
||||||
|
|
||||||
|
For example, Tag.delete calls here to remove the group links, but then
|
||||||
|
does the rest of the tag deletion process on its own.
|
||||||
|
|
||||||
|
delete_children:
|
||||||
|
If True, all children will be deleted.
|
||||||
|
Otherwise they'll just be raised up one level.
|
||||||
|
'''
|
||||||
|
self.photodb._cached_frozen_children = None
|
||||||
|
if delete_children:
|
||||||
|
for child in self.children():
|
||||||
|
child.delete(delete_children=delete_children, commit=False)
|
||||||
|
else:
|
||||||
|
# Lift children
|
||||||
|
parent = self.parent()
|
||||||
|
if parent is None:
|
||||||
|
# Since this group was a root, children become roots by removing the row.
|
||||||
|
self.photodb.cur.execute('DELETE FROM tag_group_rel WHERE parentid == ?', [self.id])
|
||||||
|
else:
|
||||||
|
# Since this group was a child, its parent adopts all its children.
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'UPDATE tag_group_rel SET parentid == ? WHERE parentid == ?',
|
||||||
|
[parent.id, self.id]
|
||||||
|
)
|
||||||
|
# Note that this part comes after the deletion of children to prevent issues of recursion.
|
||||||
|
self.photodb.cur.execute('DELETE FROM tag_group_rel WHERE memberid == ?', [self.id])
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - delete tag')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def parent(self):
|
||||||
|
'''
|
||||||
|
Return the group of which this is a member, or None.
|
||||||
|
Returned object will be of the same type as calling object.
|
||||||
|
'''
|
||||||
|
self.photodb.cur.execute('SELECT * FROM tag_group_rel WHERE memberid == ?', [self.id])
|
||||||
|
fetch = self.photodb.cur.fetchone()
|
||||||
|
if fetch is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parentid = fetch[constants.SQL_TAGGROUP['parentid']]
|
||||||
|
return self.group_getter(id=parentid)
|
||||||
|
|
||||||
|
def join_group(self, group, *, commit=True):
|
||||||
|
'''
|
||||||
|
Leave the current group, then call `group.add(self)`.
|
||||||
|
'''
|
||||||
|
if isinstance(group, str):
|
||||||
|
group = self.photodb.get_tag(group)
|
||||||
|
if not isinstance(group, type(self)):
|
||||||
|
raise TypeError('Group must also be %s' % type(self))
|
||||||
|
|
||||||
|
if self == group:
|
||||||
|
raise ValueError('Cant join self')
|
||||||
|
|
||||||
|
self.leave_group(commit=commit)
|
||||||
|
group.add(self, commit=commit)
|
||||||
|
|
||||||
|
def leave_group(self, *, commit=True):
|
||||||
|
'''
|
||||||
|
Leave the current group and become independent.
|
||||||
|
'''
|
||||||
|
self.photodb._cached_frozen_children = None
|
||||||
|
self.photodb.cur.execute('DELETE FROM tag_group_rel WHERE memberid == ?', [self.id])
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - leave group')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def walk_children(self):
|
||||||
|
yield self
|
||||||
|
for child in self.children():
|
||||||
|
yield from child.walk_children()
|
||||||
|
|
||||||
|
def walk_parents(self):
|
||||||
|
parent = self.parent()
|
||||||
|
while parent is not None:
|
||||||
|
yield parent
|
||||||
|
parent = parent.parent()
|
||||||
|
|
||||||
|
|
||||||
|
class Album(ObjectBase, GroupableMixin):
|
||||||
|
def __init__(self, photodb, row_tuple):
|
||||||
|
self.photodb = photodb
|
||||||
|
if isinstance(row_tuple, (list, tuple)):
|
||||||
|
row_tuple = {constants.SQL_ALBUM_COLUMNS[index]: value for (index, value) in enumerate(row_tuple)}
|
||||||
|
self.id = row_tuple['id']
|
||||||
|
self.title = row_tuple['title']
|
||||||
|
self.description = row_tuple['description']
|
||||||
|
self.name = 'Album %s' % self.id
|
||||||
|
self.group_getter = self.photodb.get_album
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'Album:{id}'.format(id=self.id)
|
||||||
|
|
||||||
|
def add_photo(self, photo, *, commit=True):
|
||||||
|
if self.photodb != photo.photodb:
|
||||||
|
raise ValueError('Not the same PhotoDB')
|
||||||
|
if self.has_photo(photo):
|
||||||
|
return
|
||||||
|
self.photodb.cur.execute('INSERT INTO album_photo_rel VALUES(?, ?)', [self.id, photo.id])
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - add photo to album')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def add_tag_to_all(self, tag, *, nested_children=True, commit=True):
|
||||||
|
tag = self.photodb.get_tag(tag)
|
||||||
|
if nested_children:
|
||||||
|
photos = self.walk_photos()
|
||||||
|
else:
|
||||||
|
photos = self.photos()
|
||||||
|
for photo in photos:
|
||||||
|
photo.add_tag(tag, commit=False)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - add tag to all')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def delete(self, *, delete_children=False, commit=True):
|
||||||
|
self.photodb.log.debug('Deleting album {album:r}'.format(album=self))
|
||||||
|
GroupableMixin.delete(self, delete_children=delete_children, commit=False)
|
||||||
|
self.photodb.cur.execute('DELETE FROM albums WHERE id == ?', [self.id])
|
||||||
|
self.photodb.cur.execute('DELETE FROM album_photo_rel WHERE albumid == ?', [self.id])
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - delete album')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def edit(self, title=None, description=None, *, commit=True):
|
||||||
|
if title is None:
|
||||||
|
title = self.title
|
||||||
|
if description is None:
|
||||||
|
description = self.description
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'UPDATE albums SET title=?, description=? WHERE id == ?',
|
||||||
|
[title, description, self.id]
|
||||||
|
)
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - edit album')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def has_photo(self, photo):
|
||||||
|
if not isinstance(photo, Photo):
|
||||||
|
raise TypeError('Must be a %s' % Photo)
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'SELECT * FROM album_photo_rel WHERE albumid == ? AND photoid == ?',
|
||||||
|
[self.id, photo.id]
|
||||||
|
)
|
||||||
|
return self.photodb.cur.fetchone() is not None
|
||||||
|
|
||||||
|
def photos(self):
|
||||||
|
photos = []
|
||||||
|
generator = helpers.select_generator(
|
||||||
|
self.photodb.sql,
|
||||||
|
'SELECT * FROM album_photo_rel WHERE albumid == ?',
|
||||||
|
[self.id]
|
||||||
|
)
|
||||||
|
for photo in generator:
|
||||||
|
photoid = photo[constants.SQL_ALBUMPHOTO['photoid']]
|
||||||
|
photo = self.photodb.get_photo(photoid)
|
||||||
|
photos.append(photo)
|
||||||
|
photos.sort(key=lambda x: x.basename.lower())
|
||||||
|
return photos
|
||||||
|
|
||||||
|
def remove_photo(self, photo, *, commit=True):
|
||||||
|
if not self.has_photo(photo):
|
||||||
|
return
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'DELETE FROM album_photo_rel WHERE albumid == ? AND photoid == ?',
|
||||||
|
[self.id, photo.id]
|
||||||
|
)
|
||||||
|
if commit:
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def walk_photos(self):
|
||||||
|
yield from self.photos()
|
||||||
|
children = self.walk_children()
|
||||||
|
# The first yield is itself
|
||||||
|
next(children)
|
||||||
|
for child in children:
|
||||||
|
print(child)
|
||||||
|
yield from child.walk_photos()
|
||||||
|
|
||||||
|
class Photo(ObjectBase):
|
||||||
|
'''
|
||||||
|
A PhotoDB entry containing information about an image file.
|
||||||
|
Photo objects cannot exist without a corresponding PhotoDB object, because
|
||||||
|
Photos are not the actual image data, just the database entry.
|
||||||
|
'''
|
||||||
|
def __init__(self, photodb, row_tuple):
|
||||||
|
self.photodb = photodb
|
||||||
|
if isinstance(row_tuple, (list, tuple)):
|
||||||
|
row_tuple = {constants.SQL_PHOTO_COLUMNS[index]: value for (index, value) in enumerate(row_tuple)}
|
||||||
|
|
||||||
|
self.id = row_tuple['id']
|
||||||
|
self.real_filepath = row_tuple['filepath']
|
||||||
|
self.real_filepath = helpers.normalize_filepath(self.real_filepath)
|
||||||
|
self.real_path = pathclass.Path(self.real_filepath)
|
||||||
|
self.filepath = row_tuple['override_filename'] or self.real_filepath
|
||||||
|
self.basename = row_tuple['override_filename'] or os.path.basename(self.real_filepath)
|
||||||
|
self.extension = row_tuple['extension']
|
||||||
|
self.width = row_tuple['width']
|
||||||
|
self.height = row_tuple['height']
|
||||||
|
self.ratio = row_tuple['ratio']
|
||||||
|
self.area = row_tuple['area']
|
||||||
|
self.bytes = row_tuple['bytes']
|
||||||
|
self.duration = row_tuple['duration']
|
||||||
|
self.created = row_tuple['created']
|
||||||
|
self.thumbnail = row_tuple['thumbnail']
|
||||||
|
self.tagged_at = row_tuple['tagged_at']
|
||||||
|
|
||||||
|
def __reinit__(self):
|
||||||
|
'''
|
||||||
|
Reload the row from the database and do __init__ with them.
|
||||||
|
'''
|
||||||
|
self.photodb.cur.execute('SELECT * FROM photos WHERE id == ?', [self.id])
|
||||||
|
row = self.photodb.cur.fetchone()
|
||||||
|
self.__init__(self.photodb, row)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return 'Photo:{id}'.format(id=self.id)
|
||||||
|
|
||||||
|
def add_tag(self, tag, *, commit=True):
|
||||||
|
tag = self.photodb.get_tag(tag)
|
||||||
|
|
||||||
|
if self.has_tag(tag, check_children=False):
|
||||||
|
return
|
||||||
|
|
||||||
|
# If the tag is above one we already have, keep our current one.
|
||||||
|
existing = self.has_tag(tag, check_children=True)
|
||||||
|
if existing:
|
||||||
|
message = 'Preferring existing {exi:s} over {tag:s}'.format(exi=existing, tag=tag)
|
||||||
|
self.photodb.log.debug(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
# If the tag is beneath one we already have, remove our current one
|
||||||
|
# in favor of the new, more specific tag.
|
||||||
|
for parent in tag.walk_parents():
|
||||||
|
if self.has_tag(parent, check_children=False):
|
||||||
|
self.photodb.log.debug('Preferring new {tag:s} over {par:s}'.format(tag=tag, par=parent))
|
||||||
|
self.remove_tag(parent)
|
||||||
|
|
||||||
|
self.photodb.log.debug('Applying tag {tag:s} to photo {pho:s}'.format(tag=tag, pho=self))
|
||||||
|
now = int(helpers.now())
|
||||||
|
self.photodb.cur.execute('INSERT INTO photo_tag_rel VALUES(?, ?)', [self.id, tag.id])
|
||||||
|
self.photodb.cur.execute('UPDATE photos SET tagged_at = ? WHERE id == ?', [now, self.id])
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - add photo tag')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def albums(self):
|
||||||
|
'''
|
||||||
|
Return the albums of which this photo is a member.
|
||||||
|
'''
|
||||||
|
self.photodb.cur.execute('SELECT albumid FROM album_photo_rel WHERE photoid == ?', [self.id])
|
||||||
|
fetch = self.photodb.cur.fetchall()
|
||||||
|
albums = [self.photodb.get_album(f[0]) for f in fetch]
|
||||||
|
return albums
|
||||||
|
|
||||||
|
def bytestring(self):
|
||||||
|
return bytestring.bytestring(self.bytes)
|
||||||
|
|
||||||
|
def copy_tags(self, other_photo):
|
||||||
|
for tag in other_photo.tags():
|
||||||
|
self.add_tag(tag)
|
||||||
|
|
||||||
|
def delete(self, *, delete_file=False, commit=True):
|
||||||
|
'''
|
||||||
|
Delete the Photo and its relation to any tags and albums.
|
||||||
|
'''
|
||||||
|
self.photodb.log.debug('Deleting photo {photo:r}'.format(photo=self))
|
||||||
|
self.photodb.cur.execute('DELETE FROM photos WHERE id == ?', [self.id])
|
||||||
|
self.photodb.cur.execute('DELETE FROM photo_tag_rel WHERE photoid == ?', [self.id])
|
||||||
|
self.photodb.cur.execute('DELETE FROM album_photo_rel WHERE photoid == ?', [self.id])
|
||||||
|
|
||||||
|
if delete_file:
|
||||||
|
path = self.real_path.absolute_path
|
||||||
|
if commit:
|
||||||
|
os.remove(path)
|
||||||
|
else:
|
||||||
|
queue_action = {'action': os.remove, 'args': [path]}
|
||||||
|
self.photodb.on_commit_queue.append(queue_action)
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - delete photo')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
@decorators.time_me
|
||||||
|
def generate_thumbnail(self, *, commit=True, **special):
|
||||||
|
'''
|
||||||
|
special:
|
||||||
|
For videos, you can provide a `timestamp` to take the thumbnail from.
|
||||||
|
'''
|
||||||
|
hopeful_filepath = self.make_thumbnail_filepath()
|
||||||
|
return_filepath = None
|
||||||
|
|
||||||
|
mime = self.mimetype()
|
||||||
|
if mime == 'image':
|
||||||
|
self.photodb.log.debug('Thumbnailing %s' % self.real_filepath)
|
||||||
|
try:
|
||||||
|
image = PIL.Image.open(self.real_filepath)
|
||||||
|
image = image.convert('RGB')
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
(width, height) = image.size
|
||||||
|
(new_width, new_height) = helpers.fit_into_bounds(
|
||||||
|
image_width=width,
|
||||||
|
image_height=height,
|
||||||
|
frame_width=self.photodb.config['thumbnail_width'],
|
||||||
|
frame_height=self.photodb.config['thumbnail_height'],
|
||||||
|
)
|
||||||
|
if new_width < width:
|
||||||
|
image = image.resize((new_width, new_height))
|
||||||
|
image.save(hopeful_filepath, quality=50)
|
||||||
|
return_filepath = hopeful_filepath
|
||||||
|
|
||||||
|
elif mime == 'video' and constants.ffmpeg:
|
||||||
|
#print('video')
|
||||||
|
probe = constants.ffmpeg.probe(self.real_filepath)
|
||||||
|
try:
|
||||||
|
if probe.video:
|
||||||
|
size = helpers.fit_into_bounds(
|
||||||
|
image_width=probe.video.video_width,
|
||||||
|
image_height=probe.video.video_height,
|
||||||
|
frame_width=self.photodb.config['thumbnail_width'],
|
||||||
|
frame_height=self.photodb.config['thumbnail_height'],
|
||||||
|
)
|
||||||
|
size = '%dx%d' % size
|
||||||
|
duration = probe.video.duration
|
||||||
|
if 'timestamp' in special:
|
||||||
|
timestamp = special['timestamp']
|
||||||
|
else:
|
||||||
|
if duration < 3:
|
||||||
|
timestamp = 0
|
||||||
|
else:
|
||||||
|
timestamp = 2
|
||||||
|
constants.ffmpeg.thumbnail(self.real_filepath, time=timestamp, quality=2, size=size, outfile=hopeful_filepath)
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
else:
|
||||||
|
return_filepath = hopeful_filepath
|
||||||
|
|
||||||
|
|
||||||
|
if return_filepath != self.thumbnail:
|
||||||
|
self.photodb.cur.execute('UPDATE photos SET thumbnail = ? WHERE id == ?', [return_filepath, self.id])
|
||||||
|
self.thumbnail = return_filepath
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - generate thumbnail')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
self.__reinit__()
|
||||||
|
return self.thumbnail
|
||||||
|
|
||||||
|
def has_tag(self, tag, *, check_children=True):
|
||||||
|
'''
|
||||||
|
Return the Tag object if this photo contains that tag. Otherwise return False.
|
||||||
|
|
||||||
|
check_children:
|
||||||
|
If True, children of the requested tag are counted
|
||||||
|
'''
|
||||||
|
tag = self.photodb.get_tag(tag)
|
||||||
|
|
||||||
|
if check_children:
|
||||||
|
tags = tag.walk_children()
|
||||||
|
else:
|
||||||
|
tags = [tag]
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'SELECT * FROM photo_tag_rel WHERE photoid == ? AND tagid == ?',
|
||||||
|
[self.id, tag.id]
|
||||||
|
)
|
||||||
|
if self.photodb.cur.fetchone() is not None:
|
||||||
|
return tag
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def make_thumbnail_filepath(self):
|
||||||
|
chunked_id = helpers.chunk_sequence(self.id, 3)
|
||||||
|
basename = chunked_id[-1]
|
||||||
|
folder = chunked_id[:-1]
|
||||||
|
folder = os.sep.join(folder)
|
||||||
|
folder = os.path.join(self.photodb.thumbnail_directory, folder)
|
||||||
|
if folder:
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
hopeful_filepath = os.path.join(folder, basename) + '.jpg'
|
||||||
|
return hopeful_filepath
|
||||||
|
|
||||||
|
def mimetype(self):
|
||||||
|
return helpers.get_mimetype(self.real_filepath)
|
||||||
|
|
||||||
|
@decorators.time_me
|
||||||
|
def reload_metadata(self, *, commit=True):
|
||||||
|
'''
|
||||||
|
Load the file's height, width, etc as appropriate for this type of file.
|
||||||
|
'''
|
||||||
|
self.bytes = os.path.getsize(self.real_filepath)
|
||||||
|
self.width = None
|
||||||
|
self.height = None
|
||||||
|
self.area = None
|
||||||
|
self.ratio = None
|
||||||
|
self.duration = None
|
||||||
|
|
||||||
|
mime = self.mimetype()
|
||||||
|
if mime == 'image':
|
||||||
|
try:
|
||||||
|
image = PIL.Image.open(self.real_filepath)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
self.photodb.log.debug('Failed to read image data for {photo:r}'.format(photo=self))
|
||||||
|
else:
|
||||||
|
(self.width, self.height) = image.size
|
||||||
|
image.close()
|
||||||
|
self.photodb.log.debug('Loaded image data for {photo:r}'.format(photo=self))
|
||||||
|
|
||||||
|
elif mime == 'video' and constants.ffmpeg:
|
||||||
|
try:
|
||||||
|
probe = constants.ffmpeg.probe(self.real_filepath)
|
||||||
|
if probe and probe.video:
|
||||||
|
self.duration = probe.format.duration or probe.video.duration
|
||||||
|
self.width = probe.video.video_width
|
||||||
|
self.height = probe.video.video_height
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
elif mime == 'audio':
|
||||||
|
try:
|
||||||
|
probe = constants.ffmpeg.probe(self.real_filepath)
|
||||||
|
if probe and probe.audio:
|
||||||
|
self.duration = probe.audio.duration
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
if self.width and self.height:
|
||||||
|
self.area = self.width * self.height
|
||||||
|
self.ratio = round(self.width / self.height, 2)
|
||||||
|
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'UPDATE photos SET width=?, height=?, area=?, ratio=?, duration=?, bytes=? WHERE id==?',
|
||||||
|
[self.width, self.height, self.area, self.ratio, self.duration, self.bytes, self.id],
|
||||||
|
)
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - reload metadata')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def remove_tag(self, tag, *, commit=True):
|
||||||
|
tag = self.photodb.get_tag(tag)
|
||||||
|
|
||||||
|
self.photodb.log.debug('Removing tag {t} from photo {p}'.format(t=repr(tag), p=repr(self)))
|
||||||
|
tags = list(tag.walk_children())
|
||||||
|
for tag in tags:
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'DELETE FROM photo_tag_rel WHERE photoid == ? AND tagid == ?',
|
||||||
|
[self.id, tag.id]
|
||||||
|
)
|
||||||
|
now = int(helpers.now())
|
||||||
|
self.photodb.cur.execute('UPDATE photos SET tagged_at = ? WHERE id == ?', [now, self.id])
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - remove photo tag')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def rename_file(self, new_filename, *, move=False, commit=True):
|
||||||
|
'''
|
||||||
|
Rename the file on the disk as well as in the database.
|
||||||
|
If `move` is True, allow this operation to move the file.
|
||||||
|
Otherwise, slashes will be considered an error.
|
||||||
|
'''
|
||||||
|
old_path = self.real_path
|
||||||
|
old_path.correct_case()
|
||||||
|
|
||||||
|
new_filename = helpers.normalize_filepath(new_filename)
|
||||||
|
if os.path.dirname(new_filename) == '':
|
||||||
|
new_path = old_path.parent.with_child(new_filename)
|
||||||
|
else:
|
||||||
|
new_path = pathclass.Path(new_filename)
|
||||||
|
new_path.correct_case()
|
||||||
|
|
||||||
|
self.photodb.log.debug(old_path)
|
||||||
|
self.photodb.log.debug(new_path)
|
||||||
|
if (new_path.parent != old_path.parent) and not move:
|
||||||
|
raise ValueError('Cannot move the file without param move=True')
|
||||||
|
|
||||||
|
if new_path.absolute_path == old_path.absolute_path:
|
||||||
|
raise ValueError('The new and old names are the same')
|
||||||
|
|
||||||
|
os.makedirs(new_path.parent.absolute_path, exist_ok=True)
|
||||||
|
|
||||||
|
if new_path != old_path:
|
||||||
|
# This is different than the absolute == absolute check above, because this normalizes
|
||||||
|
# the paths. It's possible on case-insensitive systems to have the paths point to the
|
||||||
|
# same place while being differently cased, thus we couldn't make the intermediate link.
|
||||||
|
try:
|
||||||
|
os.link(old_path.absolute_path, new_path.absolute_path)
|
||||||
|
except OSError:
|
||||||
|
spinal.copy_file(old_path, new_path)
|
||||||
|
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'UPDATE photos SET filepath = ? WHERE filepath == ?',
|
||||||
|
[new_path.absolute_path, old_path.absolute_path]
|
||||||
|
)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
if new_path == old_path:
|
||||||
|
# If they are equivalent but differently cased paths, just rename.
|
||||||
|
os.rename(old_path.absolute_path, new_path.absolute_path)
|
||||||
|
else:
|
||||||
|
# Delete the original hardlink or copy.
|
||||||
|
os.remove(old_path.absolute_path)
|
||||||
|
self.photodb.log.debug('Committing - rename file')
|
||||||
|
self.photodb.commit()
|
||||||
|
else:
|
||||||
|
queue_action = {'action': os.remove, 'args': [old_path.absolute_path]}
|
||||||
|
self.photodb.on_commit_queue.append(queue_action)
|
||||||
|
|
||||||
|
self.__reinit__()
|
||||||
|
|
||||||
|
def tags(self):
|
||||||
|
'''
|
||||||
|
Return the tags assigned to this Photo.
|
||||||
|
'''
|
||||||
|
tags = []
|
||||||
|
generator = helpers.select_generator(
|
||||||
|
self.photodb.sql,
|
||||||
|
'SELECT * FROM photo_tag_rel WHERE photoid == ?',
|
||||||
|
[self.id]
|
||||||
|
)
|
||||||
|
for tag in generator:
|
||||||
|
tagid = tag[constants.SQL_PHOTOTAG['tagid']]
|
||||||
|
tag = self.photodb.get_tag(id=tagid)
|
||||||
|
tags.append(tag)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(ObjectBase, GroupableMixin):
|
||||||
|
'''
|
||||||
|
A Tag, which can be applied to Photos for organization.
|
||||||
|
'''
|
||||||
|
def __init__(self, photodb, row_tuple):
|
||||||
|
self.photodb = photodb
|
||||||
|
if isinstance(row_tuple, (list, tuple)):
|
||||||
|
row_tuple = {constants.SQL_TAG_COLUMNS[index]: value for (index, value) in enumerate(row_tuple)}
|
||||||
|
self.id = row_tuple['id']
|
||||||
|
self.name = row_tuple['name']
|
||||||
|
self.group_getter = self.photodb.get_tag
|
||||||
|
self._cached_qualified_name = None
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.name == other or ObjectBase.__eq__(self, other)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.name)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
rep = 'Tag:{id}:{name}'.format(name=self.name, id=self.id)
|
||||||
|
return rep
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
rep = 'Tag:{name}'.format(name=self.name)
|
||||||
|
return rep
|
||||||
|
|
||||||
|
def add_synonym(self, synname, *, commit=True):
|
||||||
|
synname = self.photodb.normalize_tagname(synname)
|
||||||
|
|
||||||
|
if synname == self.name:
|
||||||
|
raise ValueError('Cannot assign synonym to itself.')
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.photodb.get_tag_by_name(synname)
|
||||||
|
except exceptions.NoSuchTag:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise exceptions.TagExists(synname)
|
||||||
|
|
||||||
|
self.photodb._cached_frozen_children = None
|
||||||
|
self.photodb.cur.execute('INSERT INTO tag_synonyms VALUES(?, ?)', [synname, self.name])
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - add synonym')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def convert_to_synonym(self, mastertag, *, commit=True):
|
||||||
|
'''
|
||||||
|
Convert an independent tag into a synonym for a different independent tag.
|
||||||
|
All photos which possess the current tag will have it replaced
|
||||||
|
with the new master tag.
|
||||||
|
All synonyms of the old tag will point to the new tag.
|
||||||
|
|
||||||
|
Good for when two tags need to be merged under a single name.
|
||||||
|
'''
|
||||||
|
mastertag = self.photodb.get_tag(mastertag)
|
||||||
|
|
||||||
|
# Migrate the old tag's synonyms to the new one
|
||||||
|
# UPDATE is safe for this operation because there is no chance of duplicates.
|
||||||
|
self.photodb._cached_frozen_children = None
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'UPDATE tag_synonyms SET mastername = ? WHERE mastername == ?',
|
||||||
|
[mastertag.name, self.name]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Iterate over all photos with the old tag, and swap them to the new tag
|
||||||
|
# if they don't already have it.
|
||||||
|
generator = helpers.select_generator(self.photodb.sql, 'SELECT * FROM photo_tag_rel WHERE tagid == ?', [self.id])
|
||||||
|
for relationship in generator:
|
||||||
|
photoid = relationship[constants.SQL_PHOTOTAG['photoid']]
|
||||||
|
self.photodb.cur.execute('SELECT * FROM photo_tag_rel WHERE photoid == ? AND tagid == ?', [photoid, mastertag.id])
|
||||||
|
if self.photodb.cur.fetchone() is None:
|
||||||
|
self.photodb.cur.execute('INSERT INTO photo_tag_rel VALUES(?, ?)', [photoid, mastertag.id])
|
||||||
|
|
||||||
|
# Then delete the relationships with the old tag
|
||||||
|
self.delete()
|
||||||
|
|
||||||
|
# Enjoy your new life as a monk.
|
||||||
|
mastertag.add_synonym(self.name, commit=False)
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - convert to synonym')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def delete(self, *, delete_children=False, commit=True):
|
||||||
|
self.photodb.log.debug('Deleting tag {tag:r}'.format(tag=self))
|
||||||
|
self.photodb._cached_frozen_children = None
|
||||||
|
GroupableMixin.delete(self, delete_children=delete_children, commit=False)
|
||||||
|
self.photodb.cur.execute('DELETE FROM tags WHERE id == ?', [self.id])
|
||||||
|
self.photodb.cur.execute('DELETE FROM photo_tag_rel WHERE tagid == ?', [self.id])
|
||||||
|
self.photodb.cur.execute('DELETE FROM tag_synonyms WHERE mastername == ?', [self.name])
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - delete tag')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def qualified_name(self):
|
||||||
|
'''
|
||||||
|
Return the 'group1.group2.tag' string for this tag.
|
||||||
|
'''
|
||||||
|
if self._cached_qualified_name:
|
||||||
|
return self._cached_qualified_name
|
||||||
|
qualname = self.name
|
||||||
|
for parent in self.walk_parents():
|
||||||
|
qualname = parent.name + '.' + qualname
|
||||||
|
self._cached_qualified_name = qualname
|
||||||
|
return qualname
|
||||||
|
|
||||||
|
def remove_synonym(self, synname, *, commit=True):
|
||||||
|
'''
|
||||||
|
Delete a synonym.
|
||||||
|
This will have no effect on photos or other synonyms because
|
||||||
|
they always resolve to the master tag before application.
|
||||||
|
'''
|
||||||
|
synname = self.photodb.normalize_tagname(synname)
|
||||||
|
self.photodb.cur.execute('SELECT * FROM tag_synonyms WHERE name == ?', [synname])
|
||||||
|
fetch = self.photodb.cur.fetchone()
|
||||||
|
if fetch is None:
|
||||||
|
raise exceptions.NoSuchSynonym(synname)
|
||||||
|
|
||||||
|
self.photodb._cached_frozen_children = None
|
||||||
|
self.photodb.cur.execute('DELETE FROM tag_synonyms WHERE name == ?', [synname])
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - remove synonym')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def rename(self, new_name, *, apply_to_synonyms=True, commit=True):
|
||||||
|
'''
|
||||||
|
Rename the tag. Does not affect its relation to Photos or tag groups.
|
||||||
|
'''
|
||||||
|
new_name = self.photodb.normalize_tagname(new_name)
|
||||||
|
if new_name == self.name:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.photodb.get_tag(new_name)
|
||||||
|
except exceptions.NoSuchTag:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise exceptions.TagExists(new_name)
|
||||||
|
|
||||||
|
self._cached_qualified_name = None
|
||||||
|
self.photodb._cached_frozen_children = None
|
||||||
|
self.photodb.cur.execute('UPDATE tags SET name = ? WHERE id == ?', [new_name, self.id])
|
||||||
|
if apply_to_synonyms:
|
||||||
|
self.photodb.cur.execute(
|
||||||
|
'UPDATE tag_synonyms SET mastername = ? WHERE mastername = ?',
|
||||||
|
[new_name, self.name]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.name = new_name
|
||||||
|
if commit:
|
||||||
|
self.photodb.log.debug('Committing - rename tag')
|
||||||
|
self.photodb.commit()
|
||||||
|
|
||||||
|
def synonyms(self):
|
||||||
|
self.photodb.cur.execute('SELECT name FROM tag_synonyms WHERE mastername == ?', [self.name])
|
||||||
|
fetch = self.photodb.cur.fetchall()
|
||||||
|
fetch = [f[0] for f in fetch]
|
||||||
|
fetch.sort()
|
||||||
|
return fetch
|
||||||
|
|
||||||
|
|
||||||
|
class User(ObjectBase):
|
||||||
|
'''
|
||||||
|
A dear friend of ours.
|
||||||
|
'''
|
||||||
|
def __init__(self, photodb, row_tuple):
|
||||||
|
self.photodb = photodb
|
||||||
|
if isinstance(row_tuple, (list, tuple)):
|
||||||
|
row_tuple = {constants.SQL_USER_COLUMNS[index]: value for (index, value) in enumerate(row_tuple)}
|
||||||
|
self.id = row_tuple['id']
|
||||||
|
self.username = row_tuple['username']
|
||||||
|
self.created = row_tuple['created']
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
rep = 'User:{id}:{username}'.format(id=self.id, username=self.username)
|
||||||
|
return rep
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
rep = 'User:{username}'.format(username=self.username)
|
||||||
|
return rep
|
985
phototagger.py
985
phototagger.py
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue