checkpoint

add photo and search css for narrow screens; incorporate new expressionmatch kit; entry_with_history cursor moves to end; albums indicate total filesize; etc
master
voussoir 2017-02-24 22:07:59 -08:00
parent 80cb66b825
commit c80e2003ff
16 changed files with 266 additions and 743 deletions

View File

@ -4,8 +4,8 @@ Etiquette
This is the readme file.
### To do list
- At the moment I don't like the way that warnings and exceptions are so far apart, and need to be updated individually. Consider moving the warning strings to be class properties of the matching exceptions.
- User account system, permission levels, private pages.
- Bookmark system. Maybe the ability to submit URLs as photo objects.
- Generalize the filename expression filter so it can work with any strings.
- 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.
@ -21,10 +21,3 @@ This is the readme file.
- **[removal]** An old feature was removed.
 
- 2016 11 28
- **[addition]** Added `etiquette_upgrader.py`. When an update causes the anatomy of the etiquette database to change, I will increment the `phototagger.DATABASE_VERSION` variable, and add a new function to this script that should automatically make all the necessary changes. Until the database is upgraded, phototagger will not start. Don't forget to make backups just in case.
- 2016 11 05
- **[addition]** Added the ability to download an album as a `.tar` file. No compression is used. I still need to do more experiments to make sure this is working perfectly.

View File

@ -105,7 +105,8 @@ 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'
ERROR_TAG_TOO_LONG = '{tag} is too long'
ERROR_TAG_TOO_SHORT = '{tag} has too few valid chars'
ERROR_RECURSIVE_GROUPING = 'Recursive grouping'
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.'

View File

@ -11,6 +11,7 @@ from voussoirkit import bytestring
from voussoirkit import pathclass
from voussoirkit import spinal
class ObjectBase:
def __eq__(self, other):
return (
@ -101,7 +102,8 @@ class GroupableMixin:
# Lift children
parent = self.parent()
if parent is None:
# Since this group was a root, children become roots by removing the row.
# Since this group was a root, children become roots by removing
# the row.
cur.execute('DELETE FROM tag_group_rel WHERE parentid == ?', [self.id])
else:
# Since this group was a child, its parent adopts all its children.
@ -109,7 +111,8 @@ class GroupableMixin:
'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.
# Note that this part comes after the deletion of children to prevent
# issues of recursion.
cur.execute('DELETE FROM tag_group_rel WHERE memberid == ?', [self.id])
if commit:
self.photodb.log.debug('Committing - delete tag')
@ -269,6 +272,20 @@ class Album(ObjectBase, GroupableMixin):
self.photodb.log.debug('Committing - remove photo from album')
self.photodb.commit()
def sum_bytes(self, recurse=True, string=False):
if recurse:
photos = self.walk_photos()
else:
photos = self.photos()
total = sum(photo.bytes for photo in photos)
if string:
return bytestring.bytestring(total)
else:
return total
def walk_photos(self):
yield from self.photos()
children = self.walk_children()
@ -642,7 +659,7 @@ class Photo(ObjectBase):
new_path = old_path.parent.with_child(new_filename)
else:
new_path = pathclass.Path(new_filename)
new_path.correct_case()
#new_path.correct_case()
self.photodb.log.debug(old_path)
self.photodb.log.debug(new_path)
@ -655,9 +672,11 @@ class Photo(ObjectBase):
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.
# 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:
@ -671,7 +690,8 @@ class Photo(ObjectBase):
if commit:
if new_path == old_path:
# If they are equivalent but differently cased paths, just rename.
# 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.
@ -900,6 +920,7 @@ class User(ObjectBase):
rep = 'User:{username}'.format(username=self.username)
return rep
class WarningBag:
def __init__(self):
self.warnings = set()

View File

@ -16,6 +16,7 @@ from . import helpers
from . import objects
from . import searchhelpers
from voussoirkit import expressionmatch
from voussoirkit import pathclass
from voussoirkit import safeprint
from voussoirkit import spinal
@ -161,76 +162,6 @@ def raise_no_such_thing(exception_class, thing_id=None, thing_name=None, comment
message = ''
raise exception_class(message)
def searchfilter_expression(photo_tags, expression, frozen_children, token_normalizer, warning_bag=None):
photo_tags = set(tag.name for tag in photo_tags)
operator_stack = collections.deque()
operand_stack = collections.deque()
expression = expression.replace('-', ' ')
expression = expression.strip()
if not expression:
return False
expression = expression.replace('(', ' ( ')
expression = expression.replace(')', ' ) ')
while ' ' in expression:
expression = expression.replace(' ', ' ')
tokens = [token for token in expression.split(' ') if token]
has_operand = False
can_shortcircuit = False
for token in tokens:
#print(token, end=' ', flush=True)
if can_shortcircuit and token != ')':
continue
if token not in constants.EXPRESSION_OPERATORS:
try:
token = token_normalizer(token)
value = any(option in photo_tags for option in frozen_children[token])
except KeyError:
if warning_bag:
warning_bag.add(constants.WARNING_NO_SUCH_TAG.format(tag=token))
else:
raise exceptions.NoSuchTag(token)
return False
operand_stack.append(value)
if has_operand:
operate(operand_stack, operator_stack)
has_operand = True
continue
if token == '(':
has_operand = False
if token == ')':
if not can_shortcircuit:
while operator_stack[-1] != '(':
operate(operand_stack, operator_stack)
operator_stack.pop()
has_operand = True
continue
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
operator_stack.append(token)
#time.sleep(.3)
#print()
while len(operand_stack) > 1 or len(operator_stack) > 0:
operate(operand_stack, operator_stack)
#print(operand_stack)
success = operand_stack.pop()
return success
def searchfilter_must_may_forbid(photo_tags, tag_musts, tag_mays, tag_forbids, frozen_children):
if tag_musts and not all(any(option in photo_tags for option in frozen_children[must]) for must in tag_musts):
#print('Failed musts')
@ -728,7 +659,16 @@ class PDBPhotoMixin:
searchhelpers.minmax('duration', duration, minimums, maximums, warning_bag=warning_bag)
orderby = searchhelpers.normalize_orderby(orderby)
query = searchhelpers.build_query(orderby)
notnulls = []
if extension or mimetype:
notnulls.append('extension')
if width or height or ratio or area:
notnulls.append('width')
if bytes:
notnulls.append('bytes')
if duration:
notnulls.append('duration')
query = searchhelpers.build_query(orderby, notnulls)
print(query)
generator = helpers.select_generator(self.sql, query)
@ -774,6 +714,12 @@ class PDBPhotoMixin:
else:
frozen_children = self.export_tags(tag_export_totally_flat)
self._cached_frozen_children = frozen_children
if tag_expression:
expression_tree = expressionmatch.ExpressionTree.parse(tag_expression)
expression_tree.map(self.normalize_tagname)
expression_matcher = searchhelpers.tag_expression_matcher_builder(frozen_children, warning_bag)
photos_received = 0
# LET'S GET STARTED
@ -791,7 +737,7 @@ class PDBPhotoMixin:
(photo.extension in extension_not)
)
)
if (ext_fail):
if ext_fail:
#print('Failed extension_not')
continue
@ -822,7 +768,7 @@ class PDBPhotoMixin:
continue
if (has_tags is not None) or is_tagsearch:
photo_tags = photo.tags()
photo_tags = set(photo.tags())
if has_tags is False and len(photo_tags) > 0:
#print('Failed has_tags=False')
@ -832,15 +778,11 @@ class PDBPhotoMixin:
#print('Failed has_tags=True')
continue
photo_tags = set(photo_tags)
if tag_expression:
success = searchfilter_expression(
photo_tags=photo_tags,
expression=tag_expression,
frozen_children=frozen_children,
token_normalizer=self.normalize_tagname,
warning_bag=warning_bag,
success = expression_tree.evaluate(
photo_tags,
match_function=expression_matcher,
)
if not success:
#print('Failed tag expression')
@ -872,7 +814,7 @@ class PDBPhotoMixin:
yield warning_bag
end_time = time.time()
print(end_time - start_time)
print('Search results took:', end_time - start_time)
class PDBTagMixin:
@ -954,7 +896,7 @@ class PDBTagMixin:
tag = objects.Tag(self, [tagid, tagname])
return tag
def normalize_tagname(self, tagname):
def normalize_tagname(self, tagname, warning_bag=None):
'''
Tag names can only consist of characters defined in the config.
The given tagname is lowercased, gets its spaces and hyphens
@ -968,11 +910,19 @@ class PDBTagMixin:
tagname = ''.join(tagname)
if len(tagname) < self.config['min_tag_name_length']:
raise exceptions.TagTooShort(tagname)
if len(tagname) > self.config['max_tag_name_length']:
raise exceptions.TagTooLong(tagname)
if warning_bag is not None:
warning_bag.add(constants.WARNING_TAG_TOO_SHORT.format(tag=tagname))
else:
raise exceptions.TagTooShort(tagname)
return tagname
elif len(tagname) > self.config['max_tag_name_length']:
if warning_bag is not None:
warning_bag.add(constants.WARNING_TAG_TOO_LONG.format(tag=tagname))
else:
raise exceptions.TagTooLong(tagname)
else:
return tagname
class PDBUserMixin:
def generate_user_id(self):
@ -1200,6 +1150,8 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs
*,
exclude_directories=None,
exclude_filenames=None,
make_albums=True,
recurse=True,
commit=True,
):
'''
@ -1220,19 +1172,31 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs
directory,
exclude_directories=exclude_directories,
exclude_filenames=exclude_filenames,
recurse=recurse,
yield_style='nested',
)
try:
album = self.get_album_by_path(directory.absolute_path)
except exceptions.NoSuchAlbum:
album = self.new_album(
associated_directory=directory.absolute_path,
commit=False,
title=directory.basename,
)
albums = {directory.absolute_path: album}
if make_albums:
try:
album = self.get_album_by_path(directory.absolute_path)
except exceptions.NoSuchAlbum:
album = self.new_album(
associated_directory=directory.absolute_path,
commit=False,
title=directory.basename,
)
albums = {directory.absolute_path: album}
for (current_location, directories, files) in generator:
for filepath in files:
try:
photo = self.new_photo(filepath.absolute_path, commit=False)
except exceptions.PhotoExists as e:
photo = e.photo
if not make_albums:
continue
current_album = albums.get(current_location.absolute_path, None)
if current_album is None:
try:
@ -1253,17 +1217,16 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs
#safeprint.safeprint('Added to %s' % parent.title)
except exceptions.GroupExists:
pass
for filepath in files:
try:
photo = self.new_photo(filepath.absolute_path, commit=False)
except exceptions.PhotoExists as e:
photo = e.photo
current_album.add_photo(photo, commit=False)
if commit:
self.log.debug('Committing - digest')
self.commit()
return album
if make_albums:
return album
else:
return None
# def digest_new_files(
# self,

View File

@ -5,19 +5,28 @@ from . import exceptions
from . import helpers
from . import objects
def build_query(orderby):
def build_query(orderby, notnulls):
query = 'SELECT * FROM photos'
if orderby:
orderby = [o.split('-') for o in orderby]
orderby_columns = [column for (column, sorter) in orderby if column != 'RANDOM()']
else:
orderby_columns = []
if notnulls:
notnulls.extend(orderby_columns)
elif orderby_columns:
notnulls = orderby_columns
if notnulls:
notnulls = [x + ' IS NOT NULL' for x in notnulls]
notnulls = ' AND '.join(notnulls)
query += ' WHERE ' + notnulls
if not orderby:
query += ' ORDER BY created DESC'
return query
orderby = [o.split('-') for o in orderby]
whereable_columns = [column for (column, sorter) in orderby if column != 'RANDOM()']
if whereable_columns:
query += ' WHERE '
whereable_columns = [column + ' IS NOT NULL' for column in whereable_columns]
query += ' AND '.join(whereable_columns)
# Combine each column+sorter
orderby = [' '.join(o) for o in orderby]
@ -207,7 +216,6 @@ def normalize_offset(offset, warning_bag=None):
return offset
def normalize_orderby(orderby, warning_bag=None):
if not orderby:
return None
@ -309,3 +317,28 @@ def normalize_tag_mmf(tags, photodb, warning_bag=None):
return None
return tagset
def tag_expression_matcher_builder(frozen_children, warning_bag=None):
def matcher(photo_tags, tagname):
'''
Used as the `match_function` for the ExpressionTree evaluation.
photo:
The set of tag names owned by the photo in question.
tagname:
The tag which the ExpressionTree wants it to have.
'''
if not photo_tags:
return False
try:
options = frozen_children[tagname]
except KeyError:
if warning_bag is not None:
warning_bag.add(constants.WARNING_NO_SUCH_TAG.format(tag=tagname))
return False
else:
raise exceptions.NoSuchTag(tagname)
return any(option in photo_tags for option in options)
return matcher

View File

@ -711,8 +711,10 @@ def post_edit_tags():
status = 400
try:
response = method(tag)
except exceptions.TagTooLong:
response = {'error': constants.ERROR_TAG_TOO_LONG.format(tag=tag), 'tagname': tag}
except exceptions.TagTooShort:
response = {'error': constants.ERROR_TAG_TOO_SHORT, 'tagname': tag}
response = {'error': constants.ERROR_TAG_TOO_SHORT.format(tag=tag), 'tagname': tag}
except exceptions.CantSynonymSelf:
response = {'error': constants.ERROR_SYNONYM_ITSELF, 'tagname': tag}
except exceptions.NoSuchTag as e:

View File

@ -88,13 +88,15 @@ li
display: block;
padding: 4px;
margin: 8px;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.photo_card_grid
{
vertical-align: middle;
position: relative;
display: inline-flex;
flex-direction: column;
box-shadow: 2px 2px 5px 0px rgba(0,0,0,0.25);
display: inline-block;
min-width: 150px;
max-width: 300px;
height: 200px;
@ -102,6 +104,7 @@ li
padding: 8px;
margin: 8px;
border-radius: 8px;
vertical-align: middle;
}
.photo_card_grid_thumb
{
@ -131,35 +134,32 @@ li
}
.photo_card_grid_info
{
position: absolute;
top: 160px;
bottom: 0px;
left: 8px;
right: 8px;
display: flex;
flex: 1;
justify-content: space-between;
flex-direction: column;
font-size: 0.8em;
}
.photo_card_grid_file_metadata
{
position: absolute;
bottom: 0;
right: 0;
display: flex;
justify-content: space-between;
}
.photo_card_grid_filename
{
position: absolute;
/*position: absolute;*/
max-height: 30px;
overflow: hidden;
align-self: flex-start;
word-break:break-word;
}
.photo_card_grid_filename:hover
{
max-height: 100%;
overflow: visible;
}
.photo_card_grid_tags
{
position: absolute;
bottom: 0;
left: 0;
align-self: flex-start;
}
.tag_object
{

View File

@ -81,6 +81,7 @@ function entry_with_history_hook(box, button)
box.entry_history_pos -= 1;
}
box.value = box.entry_history[box.entry_history_pos];
setTimeout(function(){box.selectionStart = box.value.length;}, 0);
}
else if (event.keyCode == 27)
{

View File

@ -1,403 +0,0 @@
/**
* complete.ly 1.0.0
* MIT Licensing
* Copyright (c) 2013 Lorenzo Puccetti
*
* This Software shall be used for doing good things, not bad things.
*
**/
function completely(container, config) {
config = config || {};
config.fontSize = config.fontSize || '16px';
config.fontFamily = config.fontFamily || 'sans-serif';
config.promptInnerHTML = config.promptInnerHTML || '';
config.color = config.color || '#333';
config.hintColor = config.hintColor || '#aaa';
config.backgroundColor = config.backgroundColor || '#fff';
config.dropDownBorderColor = config.dropDownBorderColor || '#aaa';
config.dropDownZIndex = config.dropDownZIndex || '100'; // to ensure we are in front of everybody
config.dropDownOnHoverBackgroundColor = config.dropDownOnHoverBackgroundColor || '#ddd';
var txtInput = document.createElement('input');
txtInput.type ='text';
txtInput.spellcheck = false;
txtInput.style.fontSize = config.fontSize;
txtInput.style.fontFamily = config.fontFamily;
txtInput.style.color = config.color;
txtInput.style.backgroundColor = config.backgroundColor;
txtInput.style.width = '100%';
txtInput.style.outline = '0';
txtInput.style.border = '0';
txtInput.style.margin = '0';
txtInput.style.padding = '0';
var txtHint = txtInput.cloneNode();
txtHint.disabled='';
txtHint.style.position = 'absolute';
txtHint.style.top = '0';
txtHint.style.left = '0';
txtHint.style.borderColor = 'transparent';
txtHint.style.boxShadow = 'none';
txtHint.style.color = config.hintColor;
txtInput.style.backgroundColor ='transparent';
txtInput.style.verticalAlign = 'top';
txtInput.style.position = 'relative';
var wrapper = document.createElement('div');
wrapper.style.position = 'relative';
wrapper.style.outline = '0';
wrapper.style.border = '0';
wrapper.style.margin = '0';
wrapper.style.padding = '0';
var prompt = document.createElement('div');
prompt.style.position = 'absolute';
prompt.style.outline = '0';
prompt.style.margin = '0';
prompt.style.padding = '0';
prompt.style.border = '0';
prompt.style.fontSize = config.fontSize;
prompt.style.fontFamily = config.fontFamily;
prompt.style.color = config.color;
prompt.style.backgroundColor = config.backgroundColor;
prompt.style.top = '0';
prompt.style.left = '0';
prompt.style.overflow = 'hidden';
prompt.innerHTML = config.promptInnerHTML;
prompt.style.background = 'transparent';
if (document.body === undefined) {
throw 'document.body is undefined. The library was wired up incorrectly.';
}
document.body.appendChild(prompt);
var w = prompt.getBoundingClientRect().right; // works out the width of the prompt.
wrapper.appendChild(prompt);
prompt.style.visibility = 'visible';
prompt.style.left = '-'+w+'px';
wrapper.style.marginLeft= w+'px';
wrapper.appendChild(txtHint);
wrapper.appendChild(txtInput);
var dropDown = document.createElement('div');
dropDown.style.position = 'absolute';
dropDown.style.visibility = 'hidden';
dropDown.style.outline = '0';
dropDown.style.margin = '0';
dropDown.style.padding = '0';
dropDown.style.textAlign = 'left';
dropDown.style.fontSize = config.fontSize;
dropDown.style.fontFamily = config.fontFamily;
dropDown.style.backgroundColor = config.backgroundColor;
dropDown.style.zIndex = config.dropDownZIndex;
dropDown.style.cursor = 'default';
dropDown.style.borderStyle = 'solid';
dropDown.style.borderWidth = '1px';
dropDown.style.borderColor = config.dropDownBorderColor;
dropDown.style.overflowX= 'hidden';
dropDown.style.whiteSpace = 'pre';
dropDown.style.overflowY = 'scroll'; // note: this might be ugly when the scrollbar is not required. however in this way the width of the dropDown takes into account
var createDropDownController = function(elem) {
var rows = [];
var ix = 0;
var oldIndex = -1;
var onMouseOver = function() { this.style.outline = '1px solid #ddd'; }
var onMouseOut = function() { this.style.outline = '0'; }
var onMouseDown = function() { p.hide(); p.onmouseselection(this.__hint); }
var p = {
hide : function() { elem.style.visibility = 'hidden'; },
refresh : function(token, array) {
elem.style.visibility = 'hidden';
ix = 0;
elem.innerHTML ='';
var vph = (window.innerHeight || document.documentElement.clientHeight);
var rect = elem.parentNode.getBoundingClientRect();
var distanceToTop = rect.top - 6; // heuristic give 6px
var distanceToBottom = vph - rect.bottom -6; // distance from the browser border.
rows = [];
for (var i=0;i<array.length;i++) {
if (array[i].indexOf(token)!==0) { continue; }
var divRow =document.createElement('div');
divRow.style.color = config.color;
divRow.onmouseover = onMouseOver;
divRow.onmouseout = onMouseOut;
divRow.onmousedown = onMouseDown;
divRow.__hint = array[i];
divRow.innerHTML = token+'<b>'+array[i].substring(token.length)+'</b>';
rows.push(divRow);
elem.appendChild(divRow);
}
if (rows.length===0) {
return; // nothing to show.
}
if (rows.length===1 && token === rows[0].__hint) {
return; // do not show the dropDown if it has only one element which matches what we have just displayed.
}
if (rows.length<2) return;
p.highlight(0);
if (distanceToTop > distanceToBottom*3) { // Heuristic (only when the distance to the to top is 4 times more than distance to the bottom
elem.style.maxHeight = distanceToTop+'px'; // we display the dropDown on the top of the input text
elem.style.top ='';
elem.style.bottom ='100%';
} else {
elem.style.top = '100%';
elem.style.bottom = '';
elem.style.maxHeight = distanceToBottom+'px';
}
elem.style.visibility = 'visible';
},
highlight : function(index) {
if (oldIndex !=-1 && rows[oldIndex]) {
rows[oldIndex].style.backgroundColor = config.backgroundColor;
}
rows[index].style.backgroundColor = config.dropDownOnHoverBackgroundColor; // <-- should be config
oldIndex = index;
},
move : function(step) { // moves the selection either up or down (unless it's not possible) step is either +1 or -1.
if (elem.style.visibility === 'hidden') return ''; // nothing to move if there is no dropDown. (this happens if the user hits escape and then down or up)
if (ix+step === -1 || ix+step === rows.length) return rows[ix].__hint; // NO CIRCULAR SCROLLING.
ix+=step;
p.highlight(ix);
return rows[ix].__hint;//txtShadow.value = uRows[uIndex].__hint ;
},
onmouseselection : function() {} // it will be overwritten.
};
return p;
}
var dropDownController = createDropDownController(dropDown);
dropDownController.onmouseselection = function(text) {
txtInput.value = txtHint.value = leftSide+text;
rs.onChange(txtInput.value); // <-- forcing it.
registerOnTextChangeOldValue = txtInput.value; // <-- ensure that mouse down will not show the dropDown now.
setTimeout(function() { txtInput.focus(); },0); // <-- I need to do this for IE
}
wrapper.appendChild(dropDown);
container.appendChild(wrapper);
var spacer;
var leftSide; // <-- it will contain the leftSide part of the textfield (the bit that was already autocompleted)
function calculateWidthForText(text) {
if (spacer === undefined) { // on first call only.
spacer = document.createElement('span');
spacer.style.visibility = 'hidden';
spacer.style.position = 'fixed';
spacer.style.outline = '0';
spacer.style.margin = '0';
spacer.style.padding = '0';
spacer.style.border = '0';
spacer.style.left = '0';
spacer.style.whiteSpace = 'pre';
spacer.style.fontSize = config.fontSize;
spacer.style.fontFamily = config.fontFamily;
spacer.style.fontWeight = 'normal';
document.body.appendChild(spacer);
}
// Used to encode an HTML string into a plain text.
// taken from http://stackoverflow.com/questions/1219860/javascript-jquery-html-encoding
spacer.innerHTML = String(text).replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return spacer.getBoundingClientRect().right;
}
var rs = {
onArrowDown : function() {}, // defaults to no action.
onArrowUp : function() {}, // defaults to no action.
onEnter : function() {}, // defaults to no action.
onTab : function() {}, // defaults to no action.
onChange: function() { rs.repaint() }, // defaults to repainting.
startFrom: 0,
options: [],
wrapper : wrapper, // Only to allow easy access to the HTML elements to the final user (possibly for minor customizations)
input : txtInput, // Only to allow easy access to the HTML elements to the final user (possibly for minor customizations)
hint : txtHint, // Only to allow easy access to the HTML elements to the final user (possibly for minor customizations)
dropDown : dropDown, // Only to allow easy access to the HTML elements to the final user (possibly for minor customizations)
prompt : prompt,
setText : function(text) {
txtHint.value = text;
txtInput.value = text;
},
getText : function() {
return txtInput.value;
},
hideDropDown : function() {
dropDownController.hide();
},
repaint : function() {
var text = txtInput.value;
var startFrom = rs.startFrom;
var options = rs.options;
var optionsLength = options.length;
// breaking text in leftSide and token.
var token = text.substring(startFrom);
leftSide = text.substring(0,startFrom);
// updating the hint.
txtHint.value ='';
for (var i=0;i<optionsLength;i++) {
var opt = options[i];
if (opt.indexOf(token)===0) { // <-- how about upperCase vs. lowercase
txtHint.value = leftSide +opt;
break;
}
}
// moving the dropDown and refreshing it.
dropDown.style.left = calculateWidthForText(leftSide)+'px';
dropDownController.refresh(token, rs.options);
}
};
var registerOnTextChangeOldValue;
/**
* Register a callback function to detect changes to the content of the input-type-text.
* Those changes are typically followed by user's action: a key-stroke event but sometimes it might be a mouse click.
**/
var registerOnTextChange = function(txt, callback) {
registerOnTextChangeOldValue = txt.value;
var handler = function() {
var value = txt.value;
if (registerOnTextChangeOldValue !== value) {
registerOnTextChangeOldValue = value;
callback(value);
}
};
//
// For user's actions, we listen to both input events and key up events
// It appears that input events are not enough so we defensively listen to key up events too.
// source: http://help.dottoro.com/ljhxklln.php
//
// The cost of listening to three sources should be negligible as the handler will invoke callback function
// only if the text.value was effectively changed.
//
//
if (txt.addEventListener) {
txt.addEventListener("input", handler, false);
txt.addEventListener('keyup', handler, false);
txt.addEventListener('change', handler, false);
} else { // is this a fair assumption: that attachEvent will exist ?
txt.attachEvent('oninput', handler); // IE<9
txt.attachEvent('onkeyup', handler); // IE<9
txt.attachEvent('onchange',handler); // IE<9
}
};
registerOnTextChange(txtInput,function(text) { // note the function needs to be wrapped as API-users will define their onChange
rs.onChange(text);
});
var keyDownHandler = function(e) {
e = e || window.event;
var keyCode = e.keyCode;
if (keyCode == 33) { return; } // page up (do nothing)
if (keyCode == 34) { return; } // page down (do nothing);
if (keyCode == 27) { //escape
dropDownController.hide();
txtHint.value = txtInput.value; // ensure that no hint is left.
txtInput.focus();
return;
}
if (keyCode == 39 || keyCode == 35 || keyCode == 9) { // right, end, tab (autocomplete triggered)
if (keyCode == 9) { // for tabs we need to ensure that we override the default behaviour: move to the next focusable HTML-element
e.preventDefault();
e.stopPropagation();
if (txtHint.value.length == 0) {
rs.onTab(); // tab was called with no action.
// users might want to re-enable its default behaviour or handle the call somehow.
}
}
if (txtHint.value.length > 0) { // if there is a hint
dropDownController.hide();
txtInput.value = txtHint.value;
var hasTextChanged = registerOnTextChangeOldValue != txtInput.value
registerOnTextChangeOldValue = txtInput.value; // <-- to avoid dropDown to appear again.
// for example imagine the array contains the following words: bee, beef, beetroot
// user has hit enter to get 'bee' it would be prompted with the dropDown again (as beef and beetroot also match)
if (hasTextChanged) {
rs.onChange(txtInput.value); // <-- forcing it.
}
}
return;
}
if (keyCode == 13) { // enter (autocomplete triggered)
if (txtHint.value.length == 0) { // if there is a hint
rs.onEnter();
} else {
var wasDropDownHidden = (dropDown.style.visibility == 'hidden');
dropDownController.hide();
if (wasDropDownHidden) {
txtHint.value = txtInput.value; // ensure that no hint is left.
txtInput.focus();
rs.onEnter();
return;
}
txtInput.value = txtHint.value;
var hasTextChanged = registerOnTextChangeOldValue != txtInput.value
registerOnTextChangeOldValue = txtInput.value; // <-- to avoid dropDown to appear again.
// for example imagine the array contains the following words: bee, beef, beetroot
// user has hit enter to get 'bee' it would be prompted with the dropDown again (as beef and beetroot also match)
if (hasTextChanged) {
rs.onChange(txtInput.value); // <-- forcing it.
}
}
return;
}
if (keyCode == 40) { // down
var m = dropDownController.move(+1);
if (m == '') { rs.onArrowDown(); }
txtHint.value = leftSide+m;
return;
}
if (keyCode == 38 ) { // up
var m = dropDownController.move(-1);
if (m == '') { rs.onArrowUp(); }
txtHint.value = leftSide+m;
e.preventDefault();
e.stopPropagation();
return;
}
// it's important to reset the txtHint on key down.
// think: user presses a letter (e.g. 'x') and never releases... you get (xxxxxxxxxxxxxxxxx)
// and you would see still the hint
txtHint.value =''; // resets the txtHint. (it might be updated onKeyUp)
};
if (txtInput.addEventListener) {
txtInput.addEventListener("keydown", keyDownHandler, false);
} else { // is this a fair assumption: that attachEvent will exist ?
txtInput.attachEvent('onkeydown', keyDownHandler); // IE<9
}
return rs;
}

View File

@ -50,8 +50,16 @@ p
{% set photos = album.photos() %}
<span>
Download:
{% if photos %}<a href="/album/{{album.id}}.zip?recursive=no">These files</a>{% endif %}
{% if sub_albums %}<a href="/album/{{album.id}}.zip?recursive=yes">Include children</a>{% endif %}
{% if photos %}
<a href="/album/{{album.id}}.zip?recursive=no">
These files ({{album.sum_bytes(recurse=False, string=True)}})
</a>
{% endif %}
{% if sub_albums %}
<a href="/album/{{album.id}}.zip?recursive=yes">
Include children ({{album.sum_bytes(recurse=True, string=True)}})
</a>
{% endif %}
</span>
{% if photos %}
<h3>Photos</h3>

View File

@ -22,7 +22,9 @@
flex-direction: column;
padding: 8px;
margin: 8px;
align-items: baseline;
border-radius: 8px;
box-shadow: 2px 2px 5px 0px rgba(0,0,0,0.25);
}
.bookmark_card .bookmark_url
{

View File

@ -1,162 +0,0 @@
<!DOCTYPE html5>
<html>
<style>
#left
{
display: flex;
flex-direction: column;
justify-content: flex-start;
background-color: rgba(0, 0, 0, 0.1);
width: 300px;
}
#right
{
display: flex;
flex: 1;
}
#editor_area
{
flex: 3;
}
#message_area
{
overflow-y: auto;
flex: 1;
background-color: rgba(0, 0, 0, 0.1);
}
.photo_object
{
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
}
.photo_object img
{
display: flex;
max-width: 100%;
max-height: 100%;
height: auto;
}
.photo_object audio
{
width: 100%;
}
.photo_object video
{
max-width: 100%;
max-height: 100%;
width: 100%;
}
</style>
<head>
{% import "header.html" as header %}
<title>Photo</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="/static/common.css">
<script src="/static/common.js"></script>
</head>
<body>
{{header.make_header()}}
<div id="content_body">
<div id="left">
<div id="editor_area">
<!-- TAG INFO -->
<h4><a href="/tags">Tags</a></h4>
<ul id="this_tags">
{% for tag in tags %}
<li>
<a class="tag_object" href="/search?tag_musts={{tag.name}}">{{tag.qualified_name()}}</a>
<button class="remove_tag_button" onclick="remove_photo_tag('{{photo.id}}', '{{tag.name}}', receive_callback);"></button>
</li>
{% endfor %}
<li>
<input id="add_tag_textbox" type="text" autofocus>
<button id="add_tag_button" class="add_tag_button" onclick="submit_tag(receive_callback);">add</button>
</li>
</ul>
<!-- METADATA & DOWNLOAD -->
<h4>File info</h4>
<ul id="metadata">
{% if photo.width %}
<li>{{photo.width}}x{{photo.height}} px</li>
<li>{{photo.ratio}} aspect ratio</li>
<li>{{photo.bytestring()}}</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.basename}}"</a></li>
</ul>
<!-- CONTAINING ALBUMS -->
{% set albums=photo.albums() %}
{% if albums %}
<h4>Albums containing this photo</h4>
<ul id="containing albums">
{% for album in albums %}
<li><a href="/album/{{album.id}}">{{album.title}}</a></li>
{% endfor %}
{% endif %}
</ul>
</div>
<div id="message_area">
</div>
</div>
<div id="right">
<div class="photo_object">
{% set filename = photo.id + "." + photo.extension %}
{% set link = "/file/" + filename %}
{% set mimetype=photo.mimetype() %}
{% if mimetype == "image" %}
<img src="{{link}}">
{% elif mimetype == "video" %}
<video src="{{link}}" controls preload=none {%if photo.thumbnail%}poster="/thumbnail/{{photo.id}}.jpg"{%endif%}></video>
{% elif mimetype == "audio" %}
<audio src="{{link}}" controls></audio>
{% else %}
<a href="{{link}}">View {{filename}}</a>
{% endif %}
</div>
</div>
</div>
</body>
</html>
<script type="text/javascript">
var box = document.getElementById('add_tag_textbox');
var button = document.getElementById('add_tag_button');
var message_area = document.getElementById('message_area');
bind_box_to_button(box, button);
function receive_callback(response)
{
var tagname = response["tagname"];
if ("error" in response)
{
message_positivity = "callback_message_negative";
message_text = '"' + tagname + '" ' + response["error"];
}
else if ("action" in response)
{
var action = response["action"];
message_positivity = "callback_message_positive";
if (action == "add_tag")
{
message_text = "Added tag " + tagname;
}
else if (action == "remove_tag")
{
message_text = "Removed tag " + tagname;
}
}
create_message_bubble(message_positivity, message_text, 8000);
}
function submit_tag(callback)
{
add_photo_tag('{{photo.id}}', box.value, callback);
box.value='';
}
</script>

View File

@ -13,9 +13,10 @@
#content_body
{
/* Override common.css */
flex-direction: row;
flex: 1;
height: 100%;
width: 100%;
/*height: 100%;
width: 100%;*/
}
#left
{
@ -24,13 +25,41 @@
flex-direction: column;
justify-content: flex-start;
background-color: rgba(0, 0, 0, 0.1);
width: 300px;
max-width: 300px;
}
#right
{
display: flex;
flex: 1;
height: 100%;
display: flex;
}
@media screen and (max-width: 800px)
{
#content_body
{
/*
When flexing, it tries to contain itself entirely in the screen,
forcing #left and #right to squish together.
*/
flex: none;
flex-direction: column-reverse;
}
#left
{
/*
Display: None will be overridden as soon as the page detects that the
screen is in narrow mode and turns off the tag box's autofocus
*/
display: none;
flex-direction: row;
width: initial;
max-width: none;
margin-top: 8px;
}
#right
{
flex: none;
height: calc(100% - 20px);
}
}
#editor_area
{
@ -49,8 +78,8 @@
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
/* height: 100%;
width: 100%;*/
}
.photo_viewer a
{
@ -67,7 +96,7 @@
align-items: center;
background-repeat: no-repeat;
}
.photo_viewer img
#photo_img_holder img
{
max-height: 100%;
max-width: 100%;
@ -146,7 +175,6 @@
<div class="photo_viewer">
{% if photo.mimetype == "image" %}
<div id="photo_img_holder"><img id="photo_img" src="{{link}}" onclick="toggle_hoverzoom()" onload="this.style.opacity=0.99"></div>
<!-- <img src="{{link}}"> -->
{% elif photo.mimetype == "video" %}
<video src="{{link}}" controls preload=none {%if photo.thumbnail%}poster="/thumbnail/{{photo.id}}.jpg"{%endif%}></video>
{% elif photo.mimetype == "audio" %}
@ -161,6 +189,7 @@
<script type="text/javascript">
var content_body = document.getElementById('content_body');
var add_tag_box = document.getElementById('add_tag_textbox');
var add_tag_button = document.getElementById('add_tag_button');
var message_area = document.getElementById('message_area');
@ -239,7 +268,10 @@ function disable_hoverzoom()
div.style.backgroundImage = "none";
div.onmousemove = null;
div.onclick = null;
add_tag_box.focus();
if (getComputedStyle(content_body).flexDirection != "column-reverse")
{
add_tag_box.focus();
}
}
function toggle_hoverzoom()
{
@ -299,5 +331,23 @@ function move_hoverzoom(event)
//console.log(x);
photo_img_holder.style.backgroundPosition=(-x)+"px "+(-y)+"px";
}
setTimeout(
/*
When the screen is in column mode, the autofocusing of the tag box snaps the
screen down to it, which is annoying. By starting the #left hidden, we have
an opportunity to unset the autofocus before showing it.
*/
function()
{
var left = document.getElementById("left");
if (getComputedStyle(content_body).flexDirection == "column-reverse")
{
add_tag_box.autofocus = false;
}
left.style.display = "flex";
},
0
);
</script>
</html>

View File

@ -17,6 +17,7 @@
{% if view == "list" %}
<div class="photo_card_list">
<a target="_blank" href="/photo/{{photo.id}}">{{photo.basename}}</a>
<a target="_blank" href="/file/{{photo.id}}.{{photo.extension}}">{{photo.bytestring()}}</a>
</div>
{% else %}
@ -34,29 +35,32 @@
%}
src="/static/basic_thumbnails/{{choice}}.png"
{% endif %}
>
</a>
</div>
<div class="photo_card_grid_info">
<a target="_blank" class="photo_card_grid_filename" href="/photo/{{photo.id}}">{{photo.basename}}</a>
<span class="photo_card_grid_file_metadata">
{% if photo.width %}
{{photo.width}}x{{photo.height}},
{% endif %}
{% if photo.duration %}
{{photo.duration_string()}},
{% endif %}
<a target="_blank" href="/file/{{photo.id}}.{{photo.extension}}">{{photo.bytestring()}}</a>
</span>
<span class="photo_card_grid_tags">
{% set tags = photo.tags() %}
{% set tag_names = [] %}
{% for tag in tags %}
{% do tag_names.append(tag.name) %}
{% endfor %}
{% if tags %}
<span title="{{", ".join(tag_names)}}">T</span>
<div class="photo_card_grid_file_metadata">
<div class="photo_card_grid_tags">
{% set tags = photo.tags() %}
{% set tag_names = [] %}
{% for tag in tags %}
{% do tag_names.append(tag.name) %}
{% endfor %}
{% if tags %}
<span title="{{", ".join(tag_names)}}">T</span>
{% endif %}
</div>
<span>
{% if photo.width %}
{{photo.width}}x{{photo.height}},
{% endif %}
</span>
{% if photo.duration %}
{{photo.duration_string()}},
{% endif %}
<a target="_blank" href="/file/{{photo.id}}.{{photo.extension}}">{{photo.bytestring()}}</a>
</span>
</div>
</div>
</div>
{% endif %}

View File

@ -39,7 +39,10 @@ form
{
background-color: rgba(0, 0, 0, 0.1);
width: 300px;
max-width: 300px;
min-width: 300px;
padding: 8px;
flex: 1;
}
#right
{
@ -47,6 +50,17 @@ form
padding: 8px;
width: auto;
}
@media screen and (max-width: 800px) {
#content_body
{
flex-direction: column-reverse;
}
#left
{
max-width: none;
width: initial;
}
}
.prev_next_holder
{
display: flex;
@ -396,7 +410,7 @@ function submit_search()
var expression = document.getElementsByName("tag_expression")[0].value;
if (expression)
{
expression = expression.replace(new RegExp(" ", 'g'), "-");
//expression = expression.replace(new RegExp(" ", 'g'), "-");
parameters.push("tag_expression=" + expression);
has_tag_params=true;
}

View File

@ -1,4 +0,0 @@
<form method="POST">
<input type="text" name="phone_number">
<input type="submit">
</form>