checkpoint

master
voussoir 2016-11-05 21:24:43 -07:00
parent 7ad6160d38
commit 5de1736347
10 changed files with 424 additions and 337 deletions

48
etiquette/constants.py Normal file
View File

@ -0,0 +1,48 @@
import string
# Errors and warnings
ERROR_DATABASE_OUTOFDATE = 'Database is out-of-date. {current} should be {new}'
ERROR_INVALID_ACTION = 'Invalid action'
ERROR_NO_SUCH_TAG = 'Doesn\'t exist'
ERROR_NO_TAG_GIVEN = 'No tag name supplied'
ERROR_SYNONYM_ITSELF = 'Cant apply synonym to itself'
ERROR_TAG_TOO_SHORT = 'Not enough valid chars'
WARNING_MINMAX_INVALID = 'Field "{field}": "{value}" is not a valid request. Ignored.'
WARNING_MINMAX_OOO = 'Field "{field}": minimum "{min}" maximum "{max}" are out of order. Ignored.'
WARNING_NO_SUCH_TAG = 'Tag "{tag}" does not exist. Ignored.'
WARNING_ORDERBY_BADCOL = '"{column}" is not a sorting option. Ignored.'
WARNING_ORDERBY_BADSORTER = 'You can\'t order "{column}" by "{sorter}". Defaulting to descending.'
# Default settings
MIN_TAG_NAME_LENGTH = 1
MAX_TAG_NAME_LENGTH = 32
VALID_TAG_CHARS = string.ascii_lowercase + string.digits + '_'
DEFAULT_ID_LENGTH = 12
DEFAULT_DBNAME = 'phototagger.db'
DEFAULT_THUMBDIR = '_etiquette\\site_thumbnails'
DEFAULT_DIGEST_EXCLUDE_FILES = [
DEFAULT_DBNAME,
'desktop.ini',
'thumbs.db'
]
DEFAULT_DIGEST_EXCLUDE_DIRS = [
'_site_thumbnails',
]
FILE_READ_CHUNK = 2 ** 20
THUMBNAIL_WIDTH = 400
THUMBNAIL_HEIGHT = 400
# Operational info
ADDITIONAL_MIMETYPES = {
'srt': 'text',
'mkv': 'video',
}
EXPRESSION_OPERATORS = {'(', ')', 'OR', 'AND', 'NOT'}
MOTD_STRINGS = [
'Good morning, Paul. What will your first sequence of the day be?',
#'Buckle up, it\'s time to:',
]

View File

@ -1,11 +1,14 @@
import flask
from flask import request
import functools
import time
import uuid
def _generate_session_token():
token = str(uuid.uuid4())
#print('MAKE SESSION', token)
return token
def give_session_token(function):
@functools.wraps(function)
def wrapped(*args, **kwargs):
@ -25,3 +28,23 @@ def give_session_token(function):
return ret
return wrapped
def not_implemented(function):
'''
Decorator to remember what needs doing.
'''
warnings.warn('%s is not implemented' % function.__name__)
return function
def time_me(function):
'''
Decorator. After the function is run, print the elapsed time.
'''
@functools.wraps(function)
def timed_function(*args, **kwargs):
start = time.time()
result = function(*args, **kwargs)
end = time.time()
print('%s: %0.8f' % (function.__name__, end-start))
return result
return timed_function

View File

@ -1,9 +1,6 @@
import distutils.util
import flask
from flask import request
import functools
import json
import math
import mimetypes
import os
import random
@ -13,6 +10,10 @@ import sys
import time
import warnings
import constants
import decorators
import helpers
import jsonify
import phototagger
try:
@ -32,23 +33,10 @@ site.config.update(
TEMPLATES_AUTO_RELOAD=True,
)
site.jinja_env.add_extension('jinja2.ext.do')
#site.debug = True
site.debug = True
P = phototagger.PhotoDB()
FILE_READ_CHUNK = 2 ** 20
MOTD_STRINGS = [
'Good morning, Paul. What will your first sequence of the day be?',
#'Buckle up, it\'s time to:',
]
THUMBDIR = phototagger.DEFAULT_THUMBDIR
ERROR_INVALID_ACTION = 'Invalid action'
ERROR_NO_TAG_GIVEN = 'No tag name supplied'
ERROR_TAG_TOO_SHORT = 'Not enough valid chars'
ERROR_SYNONYM_ITSELF = 'Cant apply synonym to itself'
ERROR_NO_SUCH_TAG = 'Doesn\'t exist'
####################################################################################################
####################################################################################################
@ -56,15 +44,6 @@ ERROR_NO_SUCH_TAG = 'Doesn\'t exist'
####################################################################################################
def _helper_comma_split(s):
if s is None:
return s
s = s.replace(' ', ',')
s = [x.strip() for x in s.split(',')]
s = [x for x in s if x]
return s
def create_tag(easybake_string):
notes = P.easybake(easybake_string)
notes = [{'action': action, 'tagname': tagname} for (action, tagname) in notes]
@ -91,16 +70,6 @@ def delete_synonym(synonym):
master_tag.remove_synonym(synonym)
return {'action':'delete_synonym', 'synonym': synonym}
def edit_params(original, modifications):
new_params = original.to_dict()
new_params.update(modifications)
if not new_params:
return ''
new_params = ['%s=%s' % (k, v) for (k, v) in new_params.items() if v]
new_params = '&'.join(new_params)
new_params = '?' + new_params
return new_params
def make_json_response(j, *args, **kwargs):
dumped = json.dumps(j)
response = flask.Response(dumped, *args, **kwargs)
@ -125,34 +94,6 @@ def P_tag(tagname):
except phototagger.NoSuchTag as e:
flask.abort(404, 'That tag doesnt exist: %s' % e)
def read_filebytes(filepath, range_min, range_max):
range_span = range_max - range_min
#print('read span', range_min, range_max, range_span)
f = open(filepath, 'rb')
f.seek(range_min)
sent_amount = 0
with f:
while sent_amount < range_span:
#print(sent_amount)
chunk = f.read(FILE_READ_CHUNK)
if len(chunk) == 0:
break
yield chunk
sent_amount += len(chunk)
def seconds_to_hms(seconds):
seconds = math.ceil(seconds)
(minutes, seconds) = divmod(seconds, 60)
(hours, minutes) = divmod(minutes, 60)
parts = []
if hours: parts.append(hours)
if minutes: parts.append(minutes)
parts.append(seconds)
hms = ':'.join('%02d' % part for part in parts)
return hms
def send_file(filepath):
'''
Range-enabled file sending.
@ -208,7 +149,7 @@ def send_file(filepath):
if request.method == 'HEAD':
outgoing_data = bytes()
else:
outgoing_data = read_filebytes(filepath, range_min=range_min, range_max=range_max)
outgoing_data = helpers.read_filebytes(filepath, range_min=range_min, range_max=range_max)
response = flask.Response(
outgoing_data,
@ -217,64 +158,6 @@ def send_file(filepath):
)
return response
def truthystring(s):
if isinstance(s, (bool, int)) or s is None:
return s
s = s.lower()
if s in {'1', 'true', 't', 'yes', 'y', 'on'}:
return True
if s in {'null', 'none'}:
return None
return False
####################################################################################################
####################################################################################################
####################################################################################################
####################################################################################################
def jsonify_album(album, minimal=False):
j = {
'id': album.id,
'description': album.description,
'title': album.title,
}
if minimal is False:
j['photos'] = [jsonify_photo(photo) for photo in album.photos()]
j['parent'] = album.parent()
j['sub_albums'] = [child.id for child in album.children()]
return j
def jsonify_photo(photo):
tags = photo.tags()
tags.sort(key=lambda x: x.name)
j = {
'id': photo.id,
'extension': photo.extension,
'width': photo.width,
'height': photo.height,
'ratio': photo.ratio,
'area': photo.area,
'bytes': photo.bytes,
'duration': seconds_to_hms(photo.duration) if photo.duration is not None else None,
'duration_int': photo.duration,
'bytestring': photo.bytestring(),
'has_thumbnail': bool(photo.thumbnail),
'created': photo.created,
'filename': photo.basename,
'mimetype': photo.mimetype(),
'albums': [jsonify_album(album, minimal=True) for album in photo.albums()],
'tags': [jsonify_tag(tag) for tag in tags],
}
return j
def jsonify_tag(tag):
j = {
'id': tag.id,
'name': tag.name,
'qualified_name': tag.qualified_name(),
}
return j
####################################################################################################
####################################################################################################
@ -282,40 +165,45 @@ def jsonify_tag(tag):
####################################################################################################
@site.route('/')
@give_session_token
@decorators.give_session_token
def root():
motd = random.choice(MOTD_STRINGS)
motd = random.choice(constants.MOTD_STRINGS)
return flask.render_template('root.html', motd=motd)
@site.route('/favicon.ico')
@site.route('/favicon.png')
def favicon():
filename = os.path.join('static', 'favicon.png')
return flask.send_file(filename)
def get_album_core(albumid):
album = P_album(albumid)
album = jsonify_album(album)
album = jsonify.album(album)
album['sub_albums'] = [P_album(x) for x in album['sub_albums']]
album['sub_albums'].sort(key=lambda x: (x.title or x.id).lower())
album['sub_albums'] = [jsonify.album(x, minimal=True) for x in album['sub_albums']]
return album
@site.route('/album/<albumid>')
@give_session_token
@decorators.give_session_token
def get_album_html(albumid):
album = get_album_core(albumid)
response = flask.render_template(
'album.html',
album=album,
child_albums=[jsonify_album(P_album(x)) for x in album['sub_albums']],
photos=album['photos'],
)
return response
@site.route('/album/<albumid>.json')
@give_session_token
@decorators.give_session_token
def get_album_json(albumid):
album = get_album_core(albumid)
return make_json_response(album)
@site.route('/album/<albumid>.tar')
def get_album_tar(albumid):
album = P_album(albumid)
@ -326,21 +214,26 @@ def get_album_tar(albumid):
outgoing_headers = {'Content-Type': 'application/octet-stream'}
return flask.Response(streamed_zip, headers=outgoing_headers)
@site.route('/albums')
@give_session_token
def get_albums_html():
def get_albums_core():
albums = P.get_albums()
albums = [a for a in albums if a.parent() is None]
albums = [jsonify.album(album, minimal=True) for album in albums]
return albums
@site.route('/albums')
@decorators.give_session_token
def get_albums_html():
albums = get_albums_core()
return flask.render_template('albums.html', albums=albums)
@site.route('/albums.json')
@give_session_token
@decorators.give_session_token
def get_albums_json():
albums = P.get_albums()
albums = [a for a in albums if a.parent() is None]
albums = [jsonify_album(album, minimal=True) for album in albums]
albums = get_albums_core()
return make_json_response(albums)
@site.route('/file/<photoid>')
def get_file(photoid):
requested_photoid = photoid
@ -348,10 +241,10 @@ def get_file(photoid):
photo = P.get_photo(photoid)
do_download = request.args.get('download', False)
do_download = truthystring(do_download)
do_download = helpers.truthystring(do_download)
use_original_filename = request.args.get('original_filename', False)
use_original_filename = truthystring(use_original_filename)
use_original_filename = helpers.truthystring(use_original_filename)
if do_download:
if use_original_filename:
@ -368,25 +261,27 @@ def get_file(photoid):
else:
return send_file(photo.real_filepath)
def get_photo_core(photoid):
photo = P_photo(photoid)
photo = jsonify_photo(photo)
photo = jsonify.photo(photo)
return photo
@site.route('/photo/<photoid>', methods=['GET'])
@give_session_token
@decorators.give_session_token
def get_photo_html(photoid):
photo = get_photo_core(photoid)
photo['tags'].sort(key=lambda x: x['qualified_name'])
return flask.render_template('photo.html', photo=photo)
@site.route('/photo/<photoid>.json', methods=['GET'])
@give_session_token
@decorators.give_session_token
def get_photo_json(photoid):
photo = get_photo_core(photoid)
photo = make_json_response(photo)
return photo
def get_search_core():
#print(request.args)
@ -396,9 +291,9 @@ def get_search_core():
extension_not_string = request.args.get('extension_not', None)
mimetype_string = request.args.get('mimetype', None)
extension_list = _helper_comma_split(extension_string)
extension_not_list = _helper_comma_split(extension_not_string)
mimetype_list = _helper_comma_split(mimetype_string)
extension_list = helpers.comma_split(extension_string)
extension_not_list = helpers.comma_split(extension_not_string)
mimetype_list = helpers.comma_split(mimetype_string)
# LIMIT
limit = request.args.get('limit', '')
@ -440,7 +335,7 @@ def get_search_core():
if has_tags == '':
has_tags = None
else:
has_tags = truthystring(has_tags)
has_tags = helpers.truthystring(has_tags)
# MINMAXERS
area = request.args.get('area', None)
@ -480,7 +375,7 @@ def get_search_core():
#print(search_kwargs)
with warnings.catch_warnings(record=True) as catcher:
photos = list(P.search(**search_kwargs))
photos = [jsonify_photo(photo) for photo in photos]
photos = [jsonify.photo(photo, include_albums=False) for photo in photos]
warns = [str(warning.message) for warning in catcher]
#print(warns)
@ -493,13 +388,14 @@ def get_search_core():
# PREV-NEXT PAGE URLS
offset = offset or 0
original_params = request.args.to_dict()
if len(photos) == limit:
next_params = edit_params(request.args, {'offset': offset + limit})
next_params = helpers.edit_params(original_params, {'offset': offset + limit})
next_page_url = '/search' + next_params
else:
next_page_url = None
if offset > 0:
prev_params = edit_params(request.args, {'offset': max(0, offset - limit)})
prev_params = helpers.edit_params(original_params, {'offset': max(0, offset - limit)})
prev_page_url = '/search' + prev_params
else:
prev_page_url = None
@ -520,7 +416,7 @@ def get_search_core():
return final_results
@site.route('/search')
@give_session_token
@decorators.give_session_token
def get_search_html():
search_results = get_search_core()
search_kwargs = search_results['search_kwargs']
@ -538,16 +434,17 @@ def get_search_html():
return response
@site.route('/search.json')
@give_session_token
@decorators.give_session_token
def get_search_json():
search_results = get_search_core()
search_kwargs = search_results['search_kwargs']
qualname_map = search_results['qualname_map']
include_qualname_map = request.args.get('include_map', False)
include_qualname_map = truthystring(include_qualname_map)
include_qualname_map = helpers.truthystring(include_qualname_map)
if not include_qualname_map:
search_results.pop('qualname_map')
return make_json_response(j)
return make_json_response(search_results)
@site.route('/static/<filename>')
def get_static(filename):
@ -556,20 +453,33 @@ def get_static(filename):
filename = os.path.join('static', filename)
return flask.send_file(filename)
@site.route('/tags')
@site.route('/tags/<specific_tag>')
@give_session_token
def get_tags(specific_tag=None):
def get_tags_core(specific_tag=None):
try:
tags = P.export_tags(phototagger.tag_export_easybake, specific_tag=specific_tag)
except phototagger.NoSuchTag:
flask.abort(404, 'That tag doesnt exist')
tags = tags.split('\n')
tags = [t for t in tags if t != '']
tags = [(t, t.split('.')[-1].split('+')[0]) for t in tags]
return tags
@site.route('/tags')
@site.route('/tags/<specific_tag>')
@decorators.give_session_token
def get_tags_html(specific_tag=None):
tags = get_tags_core(specific_tag)
return flask.render_template('tags.html', tags=tags)
@site.route('/tags.json')
@site.route('/tags/<specific_tag>.json')
@decorators.give_session_token
def get_tags_json(specific_tag=None):
tags = get_tags_core(specific_tag)
tags = [t[0] for t in tags]
return make_json_response(tags)
@site.route('/thumbnail/<photoid>')
def get_thumbnail(photoid):
photoid = photoid.split('.')[0]
@ -580,9 +490,10 @@ def get_thumbnail(photoid):
flask.abort(404, 'That file doesnt have a thumbnail')
return send_file(path)
@site.route('/album/<albumid>', methods=['POST'])
@site.route('/album/<albumid>.json', methods=['POST'])
@give_session_token
@decorators.give_session_token
def post_edit_album(albumid):
'''
Edit the album's title and description.
@ -601,15 +512,16 @@ def post_edit_album(albumid):
response = {'error': 'That tag doesnt exist', 'tagname': tag}
return make_json_response(response, status=404)
recursive = request.form.get('recursive', False)
recursive = truthystring(recursive)
recursive = helpers.truthystring(recursive)
album.add_tag_to_all(tag, nested_children=recursive)
response['action'] = action
response['tagname'] = tag.name
return make_json_response(response)
@site.route('/photo/<photoid>', methods=['POST'])
@site.route('/photo/<photoid>.json', methods=['POST'])
@give_session_token
@decorators.give_session_token
def post_edit_photo(photoid):
'''
Add and remove tags from photos.
@ -642,8 +554,9 @@ def post_edit_photo(photoid):
response['tagname'] = tag.name
return make_json_response(response)
@site.route('/tags', methods=['POST'])
@give_session_token
@decorators.give_session_token
def post_edit_tags():
'''
Create and delete tags and synonyms.
@ -661,12 +574,12 @@ def post_edit_tags():
method = delete_tag
else:
status = 400
response = {'error': ERROR_INVALID_ACTION}
response = {'error': constants.ERROR_INVALID_ACTION}
if status == 200:
tag = request.form[action].strip()
if tag == '':
response = {'error': ERROR_NO_TAG_GIVEN}
response = {'error': constants.ERROR_NO_TAG_GIVEN}
status = 400
if status == 200:
@ -675,11 +588,11 @@ def post_edit_tags():
try:
response = method(tag)
except phototagger.TagTooShort:
response = {'error': ERROR_TAG_TOO_SHORT, 'tagname': tag}
response = {'error': constants.ERROR_TAG_TOO_SHORT, 'tagname': tag}
except phototagger.CantSynonymSelf:
response = {'error': ERROR_SYNONYM_ITSELF, 'tagname': tag}
response = {'error': constants.ERROR_SYNONYM_ITSELF, 'tagname': tag}
except phototagger.NoSuchTag as e:
response = {'error': ERROR_NO_SUCH_TAG, 'tagname': tag}
response = {'error': constants.ERROR_NO_SUCH_TAG, 'tagname': tag}
except ValueError as e:
response = {'error': e.args[0], 'tagname': tag}
else:

134
etiquette/helpers.py Normal file
View File

@ -0,0 +1,134 @@
import math
import constants
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
'''
Given a sequence, divide it into sequences of length `chunk_length`.
allow_incomplete:
If True, allow the final chunk to be shorter if the
given sequence is not an exact multiple of `chunk_length`.
If False, the incomplete chunk will be discarded.
'''
(complete, leftover) = divmod(len(sequence), chunk_length)
if not allow_incomplete:
leftover = 0
chunk_count = complete + min(leftover, 1)
chunks = []
for x in range(chunk_count):
left = chunk_length * x
right = left + chunk_length
chunks.append(sequence[left:right])
return chunks
def comma_split(s):
'''
Split the string apart by commas, discarding all extra whitespace and
blank phrases.
'''
if s is None:
return s
s = s.replace(' ', ',')
s = [x.strip() for x in s.split(',')]
s = [x for x in s if x]
return s
def edit_params(original, modifications):
'''
Given a dictionary representing URL parameters,
apply the modifications and return a URL parameter string.
{'a':1, 'b':2}, {'b':3} => ?a=1&b=3
'''
new_params = original.copy()
new_params.update(modifications)
if not new_params:
return ''
new_params = ['%s=%s' % (k, v) for (k, v) in new_params.items() if v]
new_params = '&'.join(new_params)
new_params = '?' + new_params
return new_params
def fit_into_bounds(image_width, image_height, frame_width, frame_height):
'''
Given the w+h of the image and the w+h of the frame,
return new w+h that fits the image into the frame
while maintaining the aspect ratio.
'''
ratio = min(frame_width/image_width, frame_height/image_height)
new_width = int(image_width * ratio)
new_height = int(image_height * ratio)
return (new_width, new_height)
def hms_to_seconds(hms):
'''
Convert hh:mm:ss string to an integer seconds.
'''
hms = hms.split(':')
seconds = 0
if len(hms) == 3:
seconds += int(hms[0])*3600
hms.pop(0)
if len(hms) == 2:
seconds += int(hms[0])*60
hms.pop(0)
if len(hms) == 1:
seconds += int(hms[0])
return seconds
def is_xor(*args):
'''
Return True if and only if one arg is truthy.
'''
return [bool(a) for a in args].count(True) == 1
def read_filebytes(filepath, range_min, range_max):
'''
Yield chunks of bytes from the file between the endpoints.
'''
range_span = range_max - range_min
#print('read span', range_min, range_max, range_span)
f = open(filepath, 'rb')
f.seek(range_min)
sent_amount = 0
with f:
while sent_amount < range_span:
#print(sent_amount)
chunk = f.read(constants.FILE_READ_CHUNK)
if len(chunk) == 0:
break
yield chunk
sent_amount += len(chunk)
def seconds_to_hms(seconds):
'''
Convert integer number of seconds to an hh:mm:ss string.
Only the necessary fields are used.
'''
seconds = math.ceil(seconds)
(minutes, seconds) = divmod(seconds, 60)
(hours, minutes) = divmod(minutes, 60)
parts = []
if hours: parts.append(hours)
if minutes: parts.append(minutes)
parts.append(seconds)
hms = ':'.join('%02d' % part for part in parts)
return hms
def truthystring(s):
if isinstance(s, (bool, int)) or s is None:
return s
s = s.lower()
if s in {'1', 'true', 't', 'yes', 'y', 'on'}:
return True
if s in {'null', 'none'}:
return None
return False

49
etiquette/jsonify.py Normal file
View File

@ -0,0 +1,49 @@
import helpers
def album(a, minimal=False):
j = {
'id': a.id,
'description': a.description,
'title': a.title,
}
if not minimal:
j['photos'] = [photo(p) for p in a.photos()]
j['parent'] = a.parent()
j['sub_albums'] = [child.id for child in a.children()]
return j
def photo(p, include_albums=True, include_tags=True):
tags = p.tags()
tags.sort(key=lambda x: x.name)
j = {
'id': p.id,
'extension': p.extension,
'width': p.width,
'height': p.height,
'ratio': p.ratio,
'area': p.area,
'bytes': p.bytes,
'duration_str': helpers.seconds_to_hms(p.duration) if p.duration is not None else None,
'duration': p.duration,
'bytestring': p.bytestring(),
'has_thumbnail': bool(p.thumbnail),
'created': p.created,
'filename': p.basename,
'mimetype': p.mimetype(),
}
if include_albums:
j['albums'] = [album(a, minimal=True) for a in p.albums()]
if include_tags:
j['tags'] = [tag(t) for t in tags]
return j
def tag(t):
j = {
'id': t.id,
'name': t.name,
'qualified_name': t.qualified_name(),
}
return j

View File

@ -15,17 +15,20 @@ import time
import traceback
import warnings
sys.path.append('C:\\git\\else\\Bytestring'); import bytestring
sys.path.append('C:\\git\\else\\SpinalTap'); import spinal
import constants
import decorators
import helpers
VALID_TAG_CHARS = string.ascii_lowercase + string.digits + '_'
MIN_TAG_NAME_LENGTH = 1
MAX_TAG_NAME_LENGTH = 32
DEFAULT_ID_LENGTH = 12
DEFAULT_DBNAME = 'phototagger.db'
DEFAULT_THUMBDIR = '_etiquette\\site_thumbnails'
THUMBNAIL_WIDTH = 400
THUMBNAIL_HEIGHT = 400
try:
sys.path.append('C:\\git\\else\\Bytestring')
sys.path.append('C:\\git\\else\\SpinalTap')
import bytestring
import spinal
except ImportError:
# pip install
# https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip
from vousoirkit import bytestring
from vousoirkit import spinal
try:
ffmpeg = converter.Converter(
@ -40,18 +43,6 @@ logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger(__name__)
logging.getLogger("PIL.PngImagePlugin").setLevel(logging.WARNING)
ADDITIONAL_MIMETYPES = {
'srt': 'text',
'mkv': 'video',
}
WARNING_MINMAX_INVALID = 'Field "{field}": "{value}" is not a valid request. Ignored.'
WARNING_MINMAX_OOO = 'Field "{field}": minimum "{min}" maximum "{max}" are out of order. Ignored.'
WARNING_NO_SUCH_TAG = 'Tag "{tag}" does not exist. Ignored.'
WARNING_ORDERBY_BADCOL = '"{column}" is not a sorting option. Ignored.'
WARNING_ORDERBY_BADSORTER = 'You can\'t order "{column}" by "{sorter}". Defaulting to descending.'
OPERATORS = {'(', ')', 'OR', 'AND', 'NOT'}
SQL_LASTID_COLUMNS = [
'table',
'last_id',
@ -107,10 +98,11 @@ SQL_SYN = {key:index for (index, key) in enumerate(SQL_SYN_COLUMNS)}
SQL_TAG = {key:index for (index, key) in enumerate(SQL_TAG_COLUMNS)}
SQL_TAGGROUP = {key:index for (index, key) in enumerate(SQL_TAGGROUP_COLUMNS)}
DATABASE_VERSION = 1
DB_INIT = '''
PRAGMA count_changes = OFF;
PRAGMA cache_size = 10000;
PRAGMA user_version = {user_version};
CREATE TABLE IF NOT EXISTS albums(
id TEXT,
title TEXT,
@ -183,27 +175,8 @@ CREATE INDEX IF NOT EXISTS index_tagsyn_name on tag_synonyms(name);
-- Tag-group relation
CREATE INDEX IF NOT EXISTS index_grouprel_parentid on tag_group_rel(parentid);
CREATE INDEX IF NOT EXISTS index_grouprel_memberid on tag_group_rel(memberid);
'''
'''.format(user_version=DATABASE_VERSION)
def not_implemented(function):
'''
Decorator to remember what needs doing.
'''
warnings.warn('%s is not implemented' % function.__name__)
return function
def time_me(function):
'''
Decorator. After the function is run, print the elapsed time.
'''
@functools.wraps(function)
def timed_function(*args, **kwargs):
start = time.time()
result = function(*args, **kwargs)
end = time.time()
print('%s: %0.8f' % (function.__name__, end-start))
return result
return timed_function
def _helper_extension(ext):
'''
@ -220,8 +193,6 @@ def _helper_extension(ext):
def _helper_filenamefilter(subject, terms):
basename = subject.lower()
#print(basename)
#print(terms)
return all(term in basename for term in terms)
def _helper_minmax(key, value, minimums, maximums):
@ -237,10 +208,10 @@ def _helper_minmax(key, value, minimums, maximums):
try:
(low, high) = hyphen_range(value)
except ValueError:
warnings.warn(WARNING_MINMAX_INVALID.format(field=key, value=value))
warnings.warn(constants.WARNING_MINMAX_INVALID.format(field=key, value=value))
return
except OutOfOrder as e:
warnings.warn(WARNING_MINMAX_OOO.format(field=key, min=e.args[1], max=e.args[2]))
warnings.warn(constants.WARNING_MINMAX_OOO.format(field=key, min=e.args[1], max=e.args[2]))
return
if low is not None:
minimums[key] = low
@ -278,13 +249,13 @@ def _helper_orderby(orderby):
'random',
]
if not sortable:
warnings.warn(WARNING_ORDERBY_BADCOL.format(column=column))
warnings.warn(constants.WARNING_ORDERBY_BADCOL.format(column=column))
return None
if column == 'random':
column = 'RANDOM()'
if sorter not in ['desc', 'asc']:
warnings.warn(WARNING_ORDERBY_BADSORTER.format(column=column, sorter=sorter))
warnings.warn(constants.WARNING_ORDERBY_BADSORTER.format(column=column, sorter=sorter))
sorter = 'desc'
return (column, sorter)
@ -307,7 +278,7 @@ def _helper_setify(photodb, l, warn_bad_tags=False):
except NoSuchTag:
if not warn_bad_tags:
raise
warnings.warn(WARNING_NO_SUCH_TAG.format(tag=tag))
warnings.warn(constants.WARNING_NO_SUCH_TAG.format(tag=tag))
continue
else:
s.add(tag)
@ -321,51 +292,12 @@ def _helper_unitconvert(value):
if value is None:
return None
if ':' in value:
return hms_to_seconds(value)
return helpers.hms_to_seconds(value)
elif all(c in '0123456789.' for c in value):
return float(value)
else:
return bytestring.parsebytes(value)
def chunk_sequence(sequence, chunk_length, allow_incomplete=True):
'''
Given a sequence, divide it into sequences of length `chunk_length`.
allow_incomplete:
If True, allow the final chunk to be shorter if the
given sequence is not an exact multiple of `chunk_length`.
If False, the incomplete chunk will be discarded.
'''
(complete, leftover) = divmod(len(sequence), chunk_length)
if not allow_incomplete:
leftover = 0
chunk_count = complete + min(leftover, 1)
chunks = []
for x in range(chunk_count):
left = chunk_length * x
right = left + chunk_length
chunks.append(sequence[left:right])
return chunks
def hms_to_seconds(hms):
'''
Convert hh:mm:ss string to an integer seconds.
'''
hms = hms.split(':')
seconds = 0
if len(hms) == 3:
seconds += int(hms[0])*3600
hms.pop(0)
if len(hms) == 2:
seconds += int(hms[0])*60
hms.pop(0)
if len(hms) == 1:
seconds += int(hms[0])
return seconds
def hyphen_range(s):
'''
Given a string like '1-3', return ints (1, 3) representing lower
@ -393,23 +325,10 @@ def hyphen_range(s):
raise OutOfOrder(s, low, high)
return low, high
def fit_into_bounds(image_width, image_height, frame_width, frame_height):
'''
Given the w+h of the image and the w+h of the frame,
return new w+h that fits the image into the frame
while maintaining the aspect ratio.
'''
ratio = min(frame_width/image_width, frame_height/image_height)
new_width = int(image_width * ratio)
new_height = int(image_height * ratio)
return (new_width, new_height)
def get_mimetype(filepath):
extension = os.path.splitext(filepath)[1].replace('.', '')
if extension in ADDITIONAL_MIMETYPES:
return ADDITIONAL_MIMETYPES[extension]
if extension in constants.ADDITIONAL_MIMETYPES:
return constants.ADDITIONAL_MIMETYPES[extension]
mimetype = mimetypes.guess_type(filepath)[0]
if mimetype is not None:
mimetype = mimetype.split('/')[0]
@ -424,12 +343,6 @@ def getnow(timestamp=True):
return now.timestamp()
return now
def is_xor(*args):
'''
Return True if and only if one arg is truthy.
'''
return [bool(a) for a in args].count(True) == 1
def normalize_filepath(filepath):
'''
Remove some bad characters.
@ -450,12 +363,12 @@ def normalize_tagname(tagname):
tagname = tagname.lower()
tagname = tagname.replace('-', '_')
tagname = tagname.replace(' ', '_')
tagname = (c for c in tagname if c in VALID_TAG_CHARS)
tagname = (c for c in tagname if c in constants.VALID_TAG_CHARS)
tagname = ''.join(tagname)
if len(tagname) < MIN_TAG_NAME_LENGTH:
if len(tagname) < constants.MIN_TAG_NAME_LENGTH:
raise TagTooShort(tagname)
if len(tagname) > MAX_TAG_NAME_LENGTH:
if len(tagname) > constants.MAX_TAG_NAME_LENGTH:
raise TagTooLong(tagname)
return tagname
@ -509,13 +422,13 @@ def searchfilter_expression(photo_tags, expression, frozen_children, warn_bad_ta
if can_shortcircuit and token != ')':
continue
if token not in OPERATORS:
if token not in constants.EXPRESSION_OPERATORS:
try:
token = normalize_tagname(token)
value = any(option in photo_tags for option in frozen_children[token])
except KeyError:
if warn_bad_tags:
warnings.warn(WARNING_NO_SUCH_TAG.format(tag=token))
warnings.warn(constants.NO_SUCH_TAG.format(tag=token))
else:
raise NoSuchTag(token)
return False
@ -536,13 +449,17 @@ def searchfilter_expression(photo_tags, expression, frozen_children, warn_bad_ta
has_operand = True
continue
if has_operand and ((operand_stack[-1] == 0 and token == 'AND') or (operand_stack[-1] == 1 and token == 'OR')):
can_shortcircuit = True
can_shortcircuit = (
has_operand and
(
(operand_stack[-1] == 0 and token == 'AND') or
(operand_stack[-1] == 1 and token == 'OR')
)
)
if can_shortcircuit:
if operator_stack and operator_stack[-1] == '(':
operator_stack.pop()
continue
else:
can_shortcircuit = False
operator_stack.append(token)
#time.sleep(.3)
@ -636,7 +553,7 @@ def tag_export_stdout(tags, depth=0):
if tag.parent() is None:
print()
@time_me
@decorators.time_me
def tag_export_totally_flat(tags):
result = {}
for tag in tags:
@ -1097,7 +1014,7 @@ class PDBTagMixin:
'''
Redirect to get_tag_by_id or get_tag_by_name after xor-checking the parameters.
'''
if not is_xor(id, name):
if not helpers.is_xor(id, name):
raise XORException('One and only one of `id`, `name` can be passed.')
if id is not None:
@ -1192,19 +1109,35 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
The `rename` method of Tag objects includes a parameter
`apply_to_synonyms` if you do want them to follow.
'''
def __init__(self, databasename=DEFAULT_DBNAME, thumbnail_folder=DEFAULT_THUMBDIR, id_length=None):
if id_length is None:
self.id_length = DEFAULT_ID_LENGTH
def __init__(
self,
databasename=constants.DEFAULT_DBNAME,
thumbnail_folder=constants.DEFAULT_THUMBDIR,
id_length=constants.DEFAULT_ID_LENGTH,
):
self.databasename = databasename
self.database_abspath = os.path.abspath(databasename)
self.thumbnail_folder = os.path.abspath(thumbnail_folder)
os.makedirs(thumbnail_folder, exist_ok=True)
existing_database = os.path.exists(databasename)
self.sql = sqlite3.connect(databasename)
self.cur = self.sql.cursor()
if existing_database:
self.cur.execute('PRAGMA user_version')
existing_version = self.cur.fetchone()[0]
if existing_version != DATABASE_VERSION:
message = constants.ERROR_DATABASE_OUTOFDATE
message = message.format(current=existing_version, new=DATABASE_VERSION)
log.critical(message)
raise SystemExit
statements = DB_INIT.split(';')
for statement in statements:
self.cur.execute(statement)
self.thumbnail_folder = os.path.abspath(thumbnail_folder)
os.makedirs(thumbnail_folder, exist_ok=True)
self.id_length = id_length
self.on_commit_queue = []
self._cached_frozen_children = None
@ -1232,15 +1165,9 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
if not os.path.isdir(directory):
raise ValueError('Not a directory: %s' % directory)
if exclude_directories is None:
exclude_directories = [
'_site_thumbnails',
]
exclude_directories = constants.DEFAULT_DIGEST_EXCLUDE_DIRS
if exclude_filenames is None:
exclude_filenames = [
DEFAULT_DBNAME,
'desktop.ini',
'thumbs.db'
]
exclude_filenames = constants.DEFAULT_DIGEST_EXCLUDE_FILES
directory = spinal.str_to_fp(directory)
directory.correct_case()
@ -1306,15 +1233,9 @@ class PhotoDB(PDBAlbumMixin, PDBPhotoMixin, PDBTagMixin):
if not os.path.isdir(directory):
raise ValueError('Not a directory: %s' % directory)
if exclude_directories is None:
exclude_directories = [
'_site_thumbnails',
]
exclude_directories = constants.DEFAULT_DIGEST_EXCLUDE_DIRS
if exclude_filenames is None:
exclude_filenames = [
DEFAULT_DBNAME,
'desktop.ini',
'thumbs.db'
]
exclude_filenames = constants.DEFAULT_DIGEST_EXCLUDE_FILES
directory = spinal.str_to_fp(directory)
generator = spinal.walk_generator(
@ -1806,7 +1727,7 @@ class Photo(ObjectBase):
log.debug('Committing - delete photo')
self.photodb.commit()
@time_me
@decorators.time_me
def generate_thumbnail(self, commit=True, **special):
'''
special:
@ -1825,11 +1746,11 @@ class Photo(ObjectBase):
pass
else:
(width, height) = image.size
(new_width, new_height) = fit_into_bounds(
(new_width, new_height) = helpers.fit_into_bounds(
image_width=width,
image_height=height,
frame_width=THUMBNAIL_WIDTH,
frame_height=THUMBNAIL_HEIGHT,
frame_width=constants.THUMBNAIL_WIDTH,
frame_height=constants.THUMBNAIL_HEIGHT,
)
if new_width < width:
image = image.resize((new_width, new_height))
@ -1841,11 +1762,11 @@ class Photo(ObjectBase):
probe = ffmpeg.probe(self.real_filepath)
try:
if probe.video:
size = fit_into_bounds(
size = helpers.fit_into_bounds(
image_width=probe.video.video_width,
image_height=probe.video.video_height,
frame_width=THUMBNAIL_WIDTH,
frame_height=THUMBNAIL_HEIGHT,
frame_width=constants.THUMBNAIL_WIDTH,
frame_height=constants.THUMBNAIL_HEIGHT,
)
size = '%dx%d' % size
duration = probe.video.duration
@ -1898,7 +1819,7 @@ class Photo(ObjectBase):
return False
def make_thumbnail_filepath(self):
chunked_id = chunk_sequence(self.id, 3)
chunked_id = helpers.chunk_sequence(self.id, 3)
basename = chunked_id[-1]
folder = chunked_id[:-1]
folder = os.sep.join(folder)
@ -1911,7 +1832,7 @@ class Photo(ObjectBase):
def mimetype(self):
return get_mimetype(self.real_filepath)
@time_me
@decorators.time_me
def reload_metadata(self, commit=True):
'''
Load the file's height, width, etc as appropriate for this type of file.

View File

@ -25,15 +25,15 @@
{% else %}
<h3>Parent: <a href="/albums">Albums</a></h3>
{% endif %}
{% if child_albums %}
{% if album["sub_albums"] %}
<h3>Sub-albums</h3>
<ul>
{% for album in child_albums %}
<li><a href="/album/{{album["id"]}}">
{% if album["title"] %}
{{album["title"]}}
{% for sub_album in album["sub_albums"] %}
<li><a href="/album/{{sub_album["id"]}}">
{% if sub_album["title"] %}
{{sub_album["title"]}}
{% else %}
{{album["id"]}}
{{sub_album["id"]}}
{% endif %}</a>
</li>
{% endfor %}

View File

@ -17,12 +17,12 @@
{{header.make_header()}}
<div id="content_body">
{% for album in albums %}
{% if album.title %}
{% set title=album.id + " " + album.title %}
{% if album["title"] %}
{% set title=album["id"] + " " + album["title"] %}
{% else %}
{% set title=album.id %}
{% set title=album["id"] %}
{% endif %}
<a href="/album/{{album.id}}">{{title}}</a>
<a href="/album/{{album["id"]}}">{{title}}</a>
{% endfor %}
</div>
</body>

View File

@ -115,7 +115,7 @@
<li>Size: {{photo["bytestring"]}}</li>
{% endif %}
{% if photo["duration"] %}
<li>Duration: {{photo["duration"]}}</li>
<li>Duration: {{photo["duration_str"]}}</li>
{% endif %}
<li><a href="/file/{{photo["id"]}}.{{photo["extension"]}}?download=1">Download as {{photo["id"]}}.{{photo["extension"]}}</a></li>
<li><a href="/file/{{photo["id"]}}.{{photo["extension"]}}?download=1&original_filename=1">Download as "{{photo["filename"]}}"</a></li>

View File

@ -1,4 +1,4 @@
{% set basics =
{% set thumbnails =
{
"audio": "audio",
"txt": "txt",
@ -15,12 +15,11 @@
src="/thumbnail/{{photo["id"]}}.jpg"
{% else %}
{% set choice =
photo['extension'] if photo['extension'] in basics else
photo['mimetype'] if photo['mimetype'] in basics else
'other'
thumbnails.get(photo["extension"],
thumbnails.get(photo["mimetype"],
'other'))
%}
src="/static/basic_thumbnails/{{choice}}.png"
{% endif %}
</a>
</div>
@ -31,7 +30,7 @@
{{photo["width"]}}x{{photo["height"]}},
{% endif %}
{% if photo["duration"] %}
{{photo["duration"]}},
{{photo["duration_str"]}},
{% endif %}
{{photo["bytestring"]}}
</span>