moved cFO() to changeOPT() into internal/js, transformed file_list_ip to work with new query_data model, made the Size buttons use modern bootstrap5 radio buttons and work again without the form for all file* routes. Removed a lot of dead code, moved code to resetNextPrev buttons to anytime we getPage, reducing code, handle setting OPT in jscript via a to_dict() for States. Overall, search and file_list_ip now work. Only main thing left is the override code

This commit is contained in:
2025-10-02 23:11:14 +10:00
parent 2b9e0e19a2
commit b0c738fcc1
5 changed files with 240 additions and 584 deletions

2
TODO
View File

@@ -1,5 +1,5 @@
###
# get search to work
# get override data into view
###
### major fix - go to everywhere I call GetEntries(), and redo the logic totally...

451
files.py
View File

@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
from flask import request, render_template, redirect, send_from_directory, url_for, jsonify, make_response
from marshmallow import Schema, fields
from main import db, app, ma
from sqlalchemy import Sequence, text, select
from sqlalchemy import Sequence, text, select, union
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload
import os
@@ -252,173 +252,6 @@ def UpdatePref( pref, OPT ):
pref.last_used=last_used
db.session.add(pref)
db.session.commit()
################################################################################
# GetEntriesInFlatView: func. to retrieve DB entries appropriate for flat view
################################################################################
def GetEntriesInFlatView( OPT, prefix ):
entries=[]
num_entries=0
join = "Entry.query.join(File).join(EntryDirLink).join(Dir).join(PathDirLink).join(Path).filter(Path.path_prefix==prefix)"
entries = eval( f"{join}.{OPT.order}.offset({OPT.offset}).limit({OPT.how_many}).all()" )
if OPT.first_eid == 0 and OPT.offset == 0 and len(entries):
OPT.first_eid = entries[0].id
if OPT.last_eid==0:
num_entries = eval( f"{join}.count()" )
last_entry = eval( f"{join}.{OPT.last_order}.limit(1).first()" )
if last_entry:
OPT.last_eid = last_entry.id
return entries, num_entries
################################################################################
# GetEntriesInFolderView: func. to retrieve DB entries appropriate for folder view
# read inline comments to deal with variations / ordering...
################################################################################
def GetEntriesInFolderView( OPT, prefix ):
entries=[]
num_entries=0
# okay the root cwd is fake, so treat it specially - its Dir can be found by path with dir.rel_path=''
if os.path.dirname(OPT.cwd) == 'static':
dir=Entry.query.join(Dir).join(PathDirLink).join(Path).filter(Dir.rel_path=='').filter(Path.path_prefix==prefix).order_by(Entry.name).first()
# this can occur if the path in settings does not exist as it wont be in # the DB
if not dir:
return entries, num_entries
# although this is 1 entry, needs to come back via all() to be iterable
entries+= Entry.query.filter(Entry.id==dir.id).all()
else:
rp = OPT.cwd.replace( prefix, '' )
# when in subdirs, replacing prefix will leave the first char as /, get rid of it
if len(rp) and rp[0] == '/':
rp=rp[1:]
dir=Entry.query.join(Dir).join(PathDirLink).join(Path).filter(Dir.rel_path==rp).filter(Path.path_prefix==prefix).order_by(Entry.name).first()
# this can occur if the path in settings does not exist as it wont be in # the DB
if not dir:
return entries, 0
# dirs cant be sorted by date really, so do best I can for now
if OPT.noo == "Z to A" or OPT.noo == "Newest":
entries+= Entry.query.join(EntryDirLink).join(FileType).filter(EntryDirLink.dir_eid==dir.id).filter(FileType.name=='Directory').order_by(Entry.name.desc()).all()
# just do A to Z / Oldest by default or if no valid option
else:
entries+= Entry.query.join(EntryDirLink).join(FileType).filter(EntryDirLink.dir_eid==dir.id).filter(FileType.name=='Directory').order_by(Entry.name).all()
# add any files at the current CWD (based on dir_eid in DB)
join="Entry.query.join(File).join(EntryDirLink).filter(EntryDirLink.dir_eid==dir.id)"
file_entries= eval( f"{join}.{OPT.order}.offset(OPT.offset).limit(OPT.how_many).all()")
if OPT.offset == 0 and len(file_entries):
OPT.first_eid = file_entries[0].id
num_entries = eval( f"{join}.count()" )
last_entry = eval( f"{join}.{OPT.last_order}.limit(1).first()" )
if last_entry:
OPT.last_eid = last_entry.id
entries += file_entries;
return entries, num_entries
################################################################################
# GetEntriesInSearchView: func. to retrieve DB entries appropriate for Search view
# Defaults search is for any matching filename, contents of any matching dirname
# and any match with AI / face for that term. Explicit, only AI match via
# AI:<tag> syntax
################################################################################
def GetEntriesInSearchView( OPT ):
search_term=OPT.orig_search_term
# turn * wildcard into sql wildcard of %
search_term=search_term.replace('*', '%' )
if 'AI:' in OPT.orig_search_term:
search_term = search_term.replace('AI:','')
join=f"Entry.query.join(File).join(FaceFileLink).join(Face).join(FaceRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person).filter(Person.tag == search_term)"
else:
join=f"Entry.query.join(File).join(FaceFileLink).join(Face).join(FaceRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person).filter(Person.tag.ilike('%{search_term}%'))"
if 'AI:' in OPT.orig_search_term:
all_entries = eval( f"{join}.{OPT.order}.offset(OPT.offset).limit(OPT.how_many).all()")
else:
file_data=eval( f"Entry.query.join(File).filter(Entry.name.ilike('%{search_term}%')).{OPT.order}.offset({OPT.offset}).limit({OPT.how_many}).all()" )
dir_data =eval( f"Entry.query.join(File).join(EntryDirLink).join(Dir).filter(Dir.rel_path.ilike('%{search_term}%')).{OPT.order}.offset({OPT.offset}).limit({OPT.how_many}).all()" )
ai_data =eval( f"{join}.{OPT.order}.offset({OPT.offset}).limit({OPT.how_many}).all()")
# remove any duplicates from combined data
all_entries = []
for f in file_data:
all_entries.append(f)
for d in dir_data:
add_it=1
for f in file_data:
if d.name == f.name:
add_it=0
break
if add_it:
all_entries.append(d)
for a in ai_data:
add_it=1
for f in file_data:
if a.name == f.name:
add_it=0
break
if add_it:
all_entries.append(a)
# nothing found, just return now
if len(all_entries) == 0:
OPT.num_entries = 0
return []
# for all searches first_entry is worked out when first_eid not set yet & offset is 0 and we have some entries
if OPT.first_eid == 0 and OPT.offset == 0 and len(all_entries):
OPT.first_eid = all_entries[0].id
if OPT.last_eid == 0:
by_fname= f"select e.id from entry e where e.name ilike '%%{search_term}%%'"
by_dirname=f"select e.id from entry e, entry_dir_link edl where edl.entry_id = e.id and edl.dir_eid in ( select d.eid from dir d where d.rel_path ilike '%%{search_term}%%' )"
by_ai =f"select e.id from entry e, face_file_link ffl, face_refimg_link frl, person_refimg_link prl, person p where e.id = ffl.file_eid and frl.face_id = ffl.face_id and frl.refimg_id = prl.refimg_id and prl.person_id = p.id and p.tag = '{search_term}'"
if 'AI:' in OPT.orig_search_term:
sel_no_order=f"select e.*, f.* from entry e, file f where e.id=f.eid and e.id in ( {by_ai} ) "
else:
sel_no_order=f"select e.*, f.* from entry e, file f where e.id=f.eid and e.id in ( {by_fname} union {by_dirname} union {by_ai} ) "
#num_entries
num_e_sql = f"select count(1) from ( {by_fname} union {by_dirname} union {by_ai} ) as foo"
with db.engine.connect() as conn:
OPT.num_entries = conn.execute( text( num_e_sql ) ).first().count
if OPT.num_entries == 0:
return []
last_entry_sql= f"{sel_no_order} order by {OPT.last_order_raw} limit 1"
with db.engine.connect() as conn:
OPT.last_eid = conn.execute( text( last_entry_sql ) ).first().id
# store first/last eid into prefs
pref=PA_UserState.query.filter(PA_UserState.pa_user_dn==current_user.dn,PA_UserState.path_type==OPT.path_type,PA_UserState.orig_ptype==OPT.orig_ptype,PA_UserState.orig_search_term==OPT.orig_search_term).first()
UpdatePref( pref, OPT )
return all_entries
################################################################################
# set up "order strings" to use in ORM and raw queries as needed for
# GetEntries*Search*, GetEntries*Flat*, GetEntries*Fold*
################################################################################
def SetOrderStrings( OPT ):
if OPT.noo == "Newest":
OPT.order="order_by(File.year.desc(),File.month.desc(),File.day.desc(),Entry.name.desc())"
OPT.last_order="order_by(File.year,File.month,File.day,Entry.name)"
OPT.last_order_raw=f"f.year, f.month, f.day, e.name"
elif OPT.noo == "Oldest":
OPT.order="order_by(File.year,File.month,File.day,Entry.name)"
OPT.last_order="order_by(File.year.desc(),File.month.desc(),File.day.desc(),Entry.name.desc())"
OPT.last_order_raw=f"f.year desc, f.month desc, f.day desc, e.name desc"
elif OPT.noo == "Z to A":
OPT.order="order_by(Entry.name.desc())"
OPT.last_order="order_by(Entry.name)"
OPT.last_order_raw=f"e.name"
else:
# A to Z
OPT.order="order_by(Entry.name)"
OPT.last_order="order_by(Entry.name.desc())"
OPT.last_order_raw=f"e.name desc"
return
################################################################################
@@ -431,9 +264,6 @@ def process_ids():
data = request.get_json() # Parse JSON body
ids = data.get('ids', []) # Extract list of ids
# DDP: debate here, do I get query_id, do I validate whether we are asking
# for ids not in the query? OR, dont even make/store/have query?
# Query DB for matching entries
stmt = (
select(Entry)
@@ -473,23 +303,63 @@ def get_dir_entries():
# get content of dir_id
stmt=( select(Entry.id).join(EntryDirLink).filter(EntryDirLink.dir_eid==dir_id) )
stmt=stmt.order_by(*order_map.get(OPT.noo) )
ids=db.session.execute(stmt).scalars().all()
entries_schema = EntrySchema(many=True)
entries = Entry.query.filter(Entry.id.in_(ids)).all()
return jsonify(entries_schema.dump(entries))
################################################################################
# Call this ONCE on first menu choice of View files, or search box submission
# create the list of entry ids that matcht the required viewing/list
# Get all relevant Entry.ids based on search_term passed in and OPT visuals
################################################################################
def GetSearchQueryData(OPT):
query_data={}
query_data['entry_list']=None
query_data['root_eid']=0
search_term = OPT.search_term
# turn * wildcard into sql wildcard of %
search_term = search_term.replace('*', '%')
if 'AI:' in search_term:
search_term = search_term.replace('AI:', '')
# AI searches are for specific ppl/joins in the DB AND we do them for ALL types of searches, define this once
ai_query = (
select(Entry.id)
.join(File).join(FaceFileLink).join(Face).join(FaceRefimgLink).join(Refimg).join(PersonRefimgLink).join(Person)
.where(Person.tag == search_term)
.order_by(*order_map.get(OPT.noo) )
)
if 'AI:' in search_term:
all_entries = db.session.execute(ai_query).scalars().all()
else:
# match name of File
file_query = select(Entry.id).join(File).where(Entry.name.ilike(f'%{search_term}%')).order_by(*order_map.get(OPT.noo))
# match name of Dir
dir_query = select(Entry.id).join(File).join(EntryDirLink).join(Dir).where(Dir.rel_path.ilike(f'%{search_term}%')).order_by(*order_map.get(OPT.noo))
# Combine ai, file & dir matches with union() to dedup and then order them
combined_query = union( file_query, dir_query, ai_query )
all_entries = db.session.execute(combined_query).scalars().all()
query_data['entry_list']=all_entries
return query_data
#################################################################################
# Get all relevant Entry.ids based on files_ip/files_sp/files_rbp and OPT visuals
#################################################################################
def GetQueryData( OPT ):
query_data={}
query_data['entry_list']=None
# always get the top of the (OPT.prefix) Path's eid and keep it for OPT.folders toggling/use
dir_stmt=( select(Entry.id).join(Dir).join(PathDirLink).join(Path).
filter(Dir.rel_path == '').filter(Path.path_prefix==OPT.prefix) )
dir_stmt=(
select(Entry.id)
.join(Dir).join(PathDirLink).join(Path)
.where(Dir.rel_path == '').where(Path.path_prefix==OPT.prefix)
)
# this should return the 1 Dir (that we want to see the content of) - and with only 1, no need to worry about order
dir_arr=db.session.execute(dir_stmt).scalars().all()
dir_id=dir_arr[0]
@@ -501,75 +371,28 @@ def GetQueryData( OPT ):
stmt=( select(Entry.id).join(EntryDirLink).filter(EntryDirLink.dir_eid==dir_id) )
else:
# get every File that is in the OPT.prefix Path
stmt=( select(Entry.id).join(File).join(EntryDirLink).join(Dir).join(PathDirLink).join(Path).
filter(Path.path_prefix == OPT.prefix) )
stmt=(
select(Entry.id)
.join(File).join(EntryDirLink).join(Dir).join(PathDirLink).join(Path)
.where(Path.path_prefix == OPT.prefix)
)
stmt=stmt.order_by(*order_map.get(OPT.noo) )
query_data['entry_list']=db.session.execute(stmt).scalars().all()
return query_data
################################################################################
# /GetEntries -> helper function that Gets Entries for required files to show
# for several routes (ifles_ip, files_sp, files_rbp, search, view_list)
################################################################################
def GetEntries( OPT ):
entries=[]
SetOrderStrings( OPT )
if OPT.path_type == 'Search' or (OPT.path_type == 'View' and OPT.orig_ptype=='Search'):
return GetEntriesInSearchView( OPT )
# if we are a view, then it will be of something else, e.g. a list of
# import, storage, or bin images, reset OPT.path_type so that the paths array below works
if 'View' in OPT.path_type:
eid = OPT.url[6:]
OPT.path_type= OPT.orig_ptype
paths = []
if OPT.path_type == 'Storage':
path = SettingsSPath()
elif OPT.path_type == 'Import':
path = SettingsIPath()
elif OPT.path_type == 'Bin':
path = SettingsRBPath()
num_entries=0
path_cnt=1
# if we have not set last_eid yet, then we need to 'reset' it during the
# path loop below (if we have more than one dir in (say) Import path)
if OPT.last_eid == 0 or OPT.folders:
update_last_eid = True
else:
update_last_eid = False
prefix = SymlinkName(OPT.path_type,path,path+'/')
if OPT.folders:
tmp_ents, tmp_num_ents = GetEntriesInFolderView( OPT, prefix )
else:
tmp_ents, tmp_num_ents = GetEntriesInFlatView( OPT, prefix )
entries += tmp_ents
num_entries += tmp_num_ents
if update_last_eid:
# find pref... via path_type if we are here
OPT.num_entries=num_entries
pref=PA_UserState.query.filter(PA_UserState.pa_user_dn==current_user.dn,PA_UserState.path_type==OPT.path_type).first()
UpdatePref( pref, OPT )
return entries
################################################################################
# /change_file_opts -> allow sort order, how_many per page, etc. to change, and
# then send back the new query_data to update entryList
################################################################################
@app.route("/change_file_opts", methods=["POST"])
@login_required
def change_file_opts2():
def change_file_opts():
data = request.get_json() # Parse JSON body
# allow dot-notation for OPT
OPT = SimpleNamespace(**data)
if OPT.folders == 'True':
if hasattr(OPT, 'folders') and OPT.folders == 'True':
OPT.folders=True
else:
OPT.folders=False
@@ -581,26 +404,20 @@ def change_file_opts2():
################################################################################
# /file_list -> show detailed file list of files from import_path(s)
################################################################################
@app.route("/file_list_ip", methods=["GET", "POST"])
@app.route("/file_list_ip", methods=["GET"])
@login_required
def file_list_ip():
OPT=States( request )
# now we have reset the offset, etc. into the prefs, we can use a GET and this will be back/forward browser button safe
if request.method=='POST':
redirect("/file_list_ip")
entries=GetEntries( OPT )
return render_template("file_list.html", page_title='View File Details (Import Path)', entry_data=entries, OPT=OPT )
query_data = GetQueryData( OPT )
return render_template("file_list.html", page_title='View File Details (Import Path)', query_data=query_data, OPT=OPT )
################################################################################
# /files -> show thumbnail view of files from import_path(s)
################################################################################
@app.route("/files_ip", methods=["GET", "POST"])
@app.route("/files_ip", methods=["GET"])
@login_required
def files_ip():
OPT=States( request )
# now we have reset the offset, etc. into the prefs, we can use a GET and this will be back/forward browser button safe
if request.method=='POST':
redirect("/files_ip")
people = Person.query.all()
move_paths = MovePathDetails()
query_data = GetQueryData( OPT )
@@ -609,13 +426,10 @@ def files_ip():
################################################################################
# /files -> show thumbnail view of files from storage_path
################################################################################
@app.route("/files_sp", methods=["GET", "POST"])
@app.route("/files_sp", methods=["GET"])
@login_required
def files_sp():
OPT=States( request )
# now we have reset the offset, etc. into the prefs, we can use a GET and this will be back/forward browser button safe
if request.method=='POST':
redirect("/files_sp")
people = Person.query.all()
move_paths = MovePathDetails()
query_data = GetQueryData( OPT )
@@ -625,13 +439,10 @@ def files_sp():
################################################################################
# /files -> show thumbnail view of files from recycle_bin_path
################################################################################
@app.route("/files_rbp", methods=["GET", "POST"])
@app.route("/files_rbp", methods=["GET"])
@login_required
def files_rbp():
OPT=States( request )
# now we have reset the offset, etc. into the prefs, we can use a GET and this will be back/forward browser button safe
if request.method=='POST':
redirect("/files_rbp")
people = Person.query.all()
move_paths = MovePathDetails()
query_data = GetQueryData( OPT )
@@ -645,19 +456,13 @@ def files_rbp():
@app.route("/search/<search_term>", methods=["GET", "POST"])
@login_required
def search(search_term):
# print( f"req={request}" )
OPT=States( request )
# print( f"OPT={OPT}" )
# if we posted to get here, its a change in State, so save it to pa_user_state, and go back to the GET version or URL
if request.method=="POST":
redirect("/search/"+search_term)
OPT.search_term = search_term
# always show flat results for search to start with
OPT.folders=False
entries=GetEntries( OPT )
OPT.folders = False
query_data=GetSearchQueryData( OPT )
move_paths = MovePathDetails()
return render_template("files.html", page_title='View Files', search_term=search_term, entry_data=entries, OPT=OPT, move_paths=move_paths )
return render_template("files.html", page_title='View Files', search_term=search_term, query_data=query_data, OPT=OPT, move_paths=move_paths )
################################################################################
# /files/scan_ip -> allows us to force a check for new files
@@ -786,80 +591,8 @@ def move_files():
return make_response( jsonify( job_id=job.id ) )
@login_required
@app.route("/view_list", methods=["POST"])
def view_list():
OPT=States( request )
# Get next/prev set of data - e.g. if next set, then it will use orig_url
# to go forward how_many from offset and then use viewer.html to show that
# first obj of the new list of entries
entries=GetEntries( OPT )
# this occurs when we went from the last image on a page (with how_many on
# it) and it just happened to also be the last in the DB...
if not entries:
SetFELog( message="DDP: DONT think this can happen anymore", level="danger", job=None, persistent=True, cant_close=True )
# undo the skip by how_many and getentries again
OPT.offset -= int(OPT.how_many)
entries=GetEntries( OPT )
# now flag we are at the last in db, to reset current below
objs = {}
eids=""
resp={}
resp['objs']={}
for e in entries:
if not e.file_details:
continue
eids=eids+f"{e.id},"
resp['objs'][e.id]={}
resp['objs'][e.id]['url'] = e.FullPathOnFS()
resp['objs'][e.id]['name'] = e.name
resp['objs'][e.id]['type'] = e.type.name
if e.file_details.faces:
# model is used for whole file, so set it at that level (based on first face)
resp['objs'][e.id]['face_model'] = e.file_details.faces[0].facefile_lnk.model_used
resp['objs'][e.id]['faces'] = []
# put face data back into array format (for js processing)
for face in e.file_details.faces:
fd= {}
fd['x'] = face.face_left
fd['y'] = face.face_top
fd['w'] = face.w
fd['h'] = face.h
if face.refimg:
fd['pid'] = face.refimg.person.id
fd['who'] = face.refimg.person.tag
fd['distance'] = round(face.refimg_lnk.face_distance,2)
resp['objs'][e.id]['faces'].append(fd)
eids=eids.rstrip(",")
lst = eids.split(',')
if 'next' in request.form:
OPT.current = int(lst[0])
if 'prev' in request.form:
OPT.current = int(lst[-1])
resp['current']=OPT.current
# OPT.first_eid can still be 0 IF we have gone past the first page, I could
# better set this in states rather than kludge this if... think about it
if OPT.first_eid>0:
resp['first_eid']=OPT.first_eid
resp['last_eid']=OPT.last_eid
resp['eids']=eids
resp['offset']=OPT.offset
# print( f"BUG-DEBUG: /view_list route #1 - OPT={OPT}, eids={eids} ")
# save pref to keep the new current value, first/last
pref=PA_UserState.query.filter(PA_UserState.pa_user_dn==current_user.dn,PA_UserState.orig_ptype==OPT.orig_ptype,PA_UserState.view_eid==OPT.view_eid).first()
# print( f"BUG-DEBUG: /view_list route #2 - OPT={OPT}, eids={eids} ")
UpdatePref( pref, OPT )
# print( f"BUG-DEBUG: /view_list route #3 - OPT={OPT}, eids={eids} ")
return make_response( resp )
@login_required
@app.route("/newview/", methods=["POST"])
def newview():
@app.route("/view/", methods=["POST"])
def view():
data = request.get_json() # Parse JSON body
eid = data.get('eid', 0) # Extract list of ids
@@ -882,22 +615,9 @@ def newview():
data=db.session.execute(stmt).unique().scalars().all()
return jsonify(entries_schema.dump(data))
################################################################################
# /view/id -> grabs data from DB and views it (GET)
################################################################################
@login_required
@app.route("/view/<id>", methods=["GET"])
def view(id):
OPT=States( request )
objs = {}
entries=GetEntries( OPT )
eids=""
for e in entries:
objs[e.id]=e
eids += f"{e.id},"
# if this is a dir, we wont view it with a click anyway, so move on...
if not e.file_details:
continue
####
"""
# process any overrides
for face in e.file_details.faces:
# now get any relevant override and store it in objs...
@@ -909,20 +629,6 @@ def view(id):
mo.type = FaceOverrideType.query.filter( FaceOverrideType.name== 'Manual match to existing person' ).first()
face.manual_override=mo
eids=eids.rstrip(",")
# jic, sometimes we trip this, and rather than show broken pages / destroy
if id not in eids:
# SetFELog( message=f"ERROR: viewing an id, but its not in eids OPT={OPT}, id={id}, eids={eids}", level="danger", persistent=True, cant_close=False)
# msg="Sorry, viewing data is confused, cannot view this image now"
# if os.environ['ENV'] == "production":
# msg += "Clearing out all states. This means browser back buttons will not work, please start a new tab and try again"
# PA_UserState.query.delete()
# db.session.commit()
# SetFELog( msg, "warning", persistent=True, cant_close=False )
# return redirect("/")
print( f"id={id}, eids={eids}" )
return "200"
else:
NMO_data = FaceOverrideType.query.all()
setting = Settings.query.first()
imp_path = setting.import_path
@@ -930,18 +636,7 @@ def view(id):
bin_path = setting.recycle_bin_path
# print( f"BUG-DEBUG: /view/id GET route - OPT={OPT}, eids={eids}, current={int(id)} ")
return render_template("viewer.html", current=int(id), eids=eids, objs=objs, OPT=OPT, NMO_data=NMO_data, imp_path=imp_path, st_path=st_path, bin_path=bin_path )
##################################################################################
# /view/id -> grabs data from DB and views it (POST -> set state, redirect to GET)
##################################################################################
@app.route("/view/<id>", methods=["POST"])
@login_required
def view_img_post(id):
# set pa_user_states...
OPT=States( request )
# print( f"BUG-DEBUG: /view/id POST route - OPT={OPT}, id={id} ")
# then use back-button friendly URL (and use pa_user_states to view the right image in the right list
return redirect( "/view/" + id );
"""
# route called from front/end - if multiple images are being transformed, each transorm == a separate call
# to this route (and therefore a separate transorm job. Each reponse allows the f/e to check the
@@ -1006,14 +701,6 @@ def _jinja2_filter_toplevelfolderof(path, cwd):
else:
return False
###############################################################################
# This func creates a new filter in jinja2 to test to hand back the parent path
# from a given path
################################################################################
@app.template_filter('ParentPath')
def _jinja2_filter_parentpath(path):
return os.path.dirname(path)
###############################################################################
# route to allow the Move Dialog Box to pass a date (YYYYMMDD) and returns a
# json list of existing dir names that could be near it in time. Starting

View File

@@ -351,11 +351,6 @@ function addFigure( obj, last, ecnt)
last.printed = obj.file_details.month;
}
}
/*
{% if not entry_data %}
<span class="alert alert-danger p-2 col-auto"> No matches for: '{{search_term}}'</span>
{% endif %}
*/
// Image/Video/Unknown entry
if (obj.type.name === "Image" || obj.type.name === "Video" || obj.type.name === "Unknown") {
@@ -466,9 +461,9 @@ function getDirEntries(dir_id, back)
$.ajax({
type: 'POST',
url: '/get_dir_entries',
data: JSON.stringify(data), // Stringify the data
contentType: 'application/json', // Set content type
dataType: 'json', // Expect JSON response
data: JSON.stringify(data),
contentType: 'application/json',
dataType: 'json',
success: function(res) {
document.entries=res
// rebuild entryList/pageList as each dir comes with new entries
@@ -525,6 +520,8 @@ function drawPageOfFigures()
addFigure( obj, last, ecnt )
ecnt++
}
if( document.entries.length == 0 && OPT.search_term != '' )
$('#figures').append( `<span class="alert alert-danger p-2 col-auto"> No matches for: '${OPT.search_term}'</span>` )
$('.figure').click( function(e) { DoSel(e, this ); SetButtonState(); return false; });
$('.figure').dblclick( function(e) { dblClickToViewEntry( $(this).attr('id') ) } )
// for dir, getDirEntries 2nd param is back (or "up" a dir)
@@ -532,8 +529,47 @@ function drawPageOfFigures()
$(".back").click( function(e) { getDirEntries(this.id,true) } )
}
function getPageFileList(res, viewingIdx)
{
$('#file_list_div').empty()
html='<table class="table table-striped table-sm col-12">'
html+='<thead><tr class="table-primary"><th>Name</th><th>Size (MB)</th><th>Path Prefix</th><th>Hash</th></tr></thead><tbody>'
for (const obj of res) {
html+=`<tr>
<td>
<div class="d-flex align-items-center">
<a href="${obj.in_dir.in_path.path_prefix}/${obj.in_dir.rel_path}/${obj.name}">
<img class="img-fluid me-2" style="max-width: 100px;"
src="data:image/jpeg;base64,${obj.file_details.thumbnail}"></img>
</a>
<span>${obj.name}</span>
</div>
<td>${obj.file_details.size_mb}</td>
<td>${obj.in_dir.in_path.path_prefix.replace("static/","")}/${obj.in_dir.rel_path}</td>
<td>${obj.file_details.hash}</td>
</tr>`
}
html+='</tbody></table>'
$('#file_list_div').append(html)
}
// function called when we get another page from inside the files view
function getPageFigures(res, viewingIdx)
{
// add all the figures to files_div
drawPageOfFigures()
}
// function called when we get another page from inside the viewer
function getPageViewer(res, viewingIdx)
{
document.viewing=document.entries[viewingIdx]
// update viewing, arrows and image/video too
ViewImageOrVideo()
}
// Function to get the 'page' of entry ids out of entryList
function getPage(pageNumber,viewing_idx=0)
function getPage(pageNumber, successCallback, viewingIdx=0)
{
// before we do anything, disabled left/right arrows on viewer to stop
// getting another event before we have the data for the page back
@@ -546,29 +582,14 @@ function getPage(pageNumber,viewing_idx=0)
// set up data to send to server to get the entry data for entries in pageList
data={}
data.ids = pageList
data.query = 99999
$.ajax({
type: 'POST',
url: '/get_entries_by_ids',
data: JSON.stringify(data), // Stringify the data
contentType: 'application/json', // Set content type
dataType: 'json', // Expect JSON response
success: function(res) {
document.entries=res
// add all the figures to files_div
drawPageOfFigures()
// noting we could have been in files_div, or viewer_div, update both jic
// and fix viewer_div - update viewing, arrows and image/video too
document.viewing=document.entries[viewing_idx]
resetNextPrevButtons()
ViewImageOrVideo()
},
error: function(xhr, status, error) {
console.error("Error:", error);
}
});
type: 'POST', url: '/get_entries_by_ids',
data: JSON.stringify(data), contentType: 'application/json',
dataType: 'json',
success: function(res) { document.entries=res; successCallback(res,viewingIdx); },
error: function(xhr, status, error) { console.error("Error:", error); } });
resetNextPrevButtons()
return
}
@@ -611,7 +632,7 @@ function resetNextPrevButtons()
}
// get list of eids for the next page, also make sure next/prev buttons make sense for page we are on
function nextPage()
function nextPage(successCallback)
{
// pageList[0] is the first entry on this page
const currentPage=getPageNumberForId( pageList[0] )
@@ -621,13 +642,12 @@ function nextPage()
console.error( "WARNING: seems first on pg=" + firstEntryOnPage + " of how many=" + OPT.how_many + " gives currentPage=" + currentPage + " and we cant go next page?" )
return
}
getPage( currentPage+1 )
resetNextPrevButtons()
getPage( currentPage+1, successCallback )
return
}
// get list of eids for the prev page, also make sure next/prev buttons make sense for page we are on
function prevPage()
function prevPage(successCallback)
{
// pageList[0] is the first entry on this page
const currentPage=getPageNumberForId( pageList[0] )
@@ -637,8 +657,7 @@ function prevPage()
console.error( "WARNING: seems first on pg=" + firstEntryOnPage + " of how many=" + OPT.how_many + " gives currentPage=" + currentPage + " and we cant go prev page?" )
return
}
getPage( currentPage-1 )
resetNextPrevButtons()
getPage( currentPage-1, successCallback )
return
}
@@ -646,3 +665,45 @@ function isMobile() {
try{ document.createEvent("TouchEvent"); return true; }
catch(e){ return false; }
}
function changeOPT(successCallback) {
OPT.how_many=$('#how_many').val()
new_f=$('#folders').val()
new_f=( new_f == 'True' )
// if change to/from folders, also fix the noo menu
if( new_f != OPT.folders )
{
if( new_f )
{
$('#noo option:lt(2)').prop('disabled', true);
$('#noo').val(OPT.default_folder_noo)
}
else
{
$('#noo option:lt(2)').prop('disabled', false);
$('#noo').val(OPT.default_flat_noo)
}
}
OPT.noo=$('#noo').val()
OPT.folders=new_f
OPT.folders=$('#folders').val()
OPT.grouping=$('#grouping').val()
OPT.size=$('#size').val()
$.ajax({
type: 'POST',
url: '/change_file_opts',
data: JSON.stringify(OPT),
contentType: 'application/json',
success: function(resp) {
entryList=resp.query_data.entry_list
// put data back into booleans, ints, etc
OPT.folders=( OPT.folders == 'True' )
OPT.how_many=parseInt(OPT.how_many)
$('.how_many_text').html( `&nbsp;${OPT.how_many} files&nbsp;` )
OPT.root_eid=parseInt(OPT.root_eid)
OPT.size=parseInt(OPT.size)
getPage(1,successCallback)
}
})
}

View File

@@ -1,64 +1,49 @@
{% extends "base.html" %} {% block main_content %}
<script src="{{ url_for( 'internal', filename='js/files_support.js')}}"></script>
<div class="container-fluid">
<h3 class="offset-2">{{page_title}}</h3>
<form id="main_form" method="POST">
<input id="offset" type="hidden" name="offset" value="{{OPT.offset}}">
<input id="grouping" type="hidden" name="grouping" value="">
<input id="folders" type="hidden" name="folders" value="False">
<div class="col col-auto">
<div class="input-group">
{{CreateSelect( "noo", OPT.noo, ["Oldest", "Newest","A to Z", "Z to A"], "$('#offset').val(0)", "rounded-start py-1 my-1")|safe }}
{{CreateSelect( "how_many", OPT.how_many|string, ["10", "25", "50", "75", "100", "150", "200", "500"], "", "rounded-end py-1 my-1" )|safe }}
{{CreateSelect( "noo", OPT.noo, ["Oldest", "Newest","A to Z", "Z to A"], "changeOPT(getPageFileList); return false", "rounded-start py-1 my-1")|safe }}
{{CreateSelect( "how_many", OPT.how_many|string, ["10", "25", "50", "75", "100", "150", "200", "500"], "changeOPT(getPageFileList); return false", "rounded-end py-1 my-1" )|safe }}
<div class="mb-1 col my-auto d-flex justify-content-center">
{% set prv_disabled="" %}
{% if OPT.offset|int == 0 %}
{% set prv_disabled="disabled" %}
{% endif %}
<button id="prev" {{prv_disabled}} name="prev" class="prev sm-txt btn btn-outline-secondary">
<button id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary" onClick="prevPage(getPageFileList)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
</button>
<span class="sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
{% set nxt_disabled="" %}
{% if entry_data|length < OPT.how_many|int %}
{% set nxt_disabled="disabled" %}
{% endif %}
<button id="next" {{nxt_disabled}} name="next" class="next sm-txt btn btn-outline-secondary">
<span class="how_many_text sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
<button id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage(getPageFileList)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
</button>
</div class="col...">
</div class="input-group...">
</div class="col col-auto">
</form
<div class="row">
<table class="table table-striped table-sm col-xl-12">
<thead><tr class="table-primary"><th>Name</th><th>Size (MB)</th><th>Path Prefix</th><th>Hash</th></tr></thead><tbody>
{% for obj in entry_data %}
<tr><td>
{% if obj.type.name == "Image" or obj.type.name == "Video" %}
<figure class="figure" font-size: 24px;>
<div style="position:relative; width:100%">
{% if obj.type.name=="Image" %}
<a href="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}/{{obj.name}}">
{% elif obj.type.name == "Video" %}
<a href="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}/{{obj.name}}">
{% endif %}
<img class="thumb" style="display:block" height="48" src="data:image/jpeg;base64,{{obj.file_details.thumbnail}}"></img>
{% if obj.type.name=="Image" or obj.type.name == "Video" %}
</a>
{% endif %}
</div>
<figcaption class="figure-caption">{{obj.name}}</figcaption>
</figure>
{% endif %}
</td>
{% if obj.type.name != "Directory" %}
<td>{{obj.file_details.size_mb}}</td><td>{{obj.in_dir.in_path.path_prefix.replace("static/","")}}/{{obj.in_dir.rel_path}}</td><td>{{obj.file_details.hash}}</td>
{% else %}
<td></td><td></td><td></td>
{% endif %}
</tr>
{% endfor %}
</tbody></table>
</div class="row">
</div class="container">
<div id="file_list_div" class="container-fluid">
</div class="container">
<div class="container-fluid">
<input type="hidden" name="cwd" id="cwd" value="{{OPT.cwd}}">
<div class="row">
<div class="col my-auto d-flex justify-content-center">
<button aria-label="prev" id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary disabled" onClick="prevPage(getPageFileList)" disabled>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
</button>
<span class="how_many_text sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
<button aria-label="next" id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage(getPageFileList)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
</button>
</div class="col my-auto"> </div class="row">
</div class="container-fluid">
{% endblock main_content %}
{% block script_content %}
<script>
// this is the list of entry ids for the images for ALL matches for this query
var entryList={{query_data.entry_list}}
var OPT = {{ OPT.to_dict()|tojson }};
// pageList is just those entries shown on this page from the full entryList
var pageList=[]
// force pageList to set pageList for & render the first page
getPage( 1, getPageFileList )
</script>
{% endblock script_content %}

View File

@@ -23,19 +23,8 @@
document.viewing_eid=null;
document.viewing=null;
var OPT={}
OPT.noo='{{OPT.noo}}'
OPT.how_many={{OPT.how_many}}
OPT.folders="{{OPT.folders}}" === "True"
OPT.grouping='{{OPT.grouping}}'
OPT.cwd='{{OPT.cwd}}'
OPT.root_eid={{query_data.root_eid}}
OPT.search_term='{{OPT.orig_search_term}}'
OPT.size={{OPT.size}}
OPT.prefix='{{OPT.prefix}}'
OPT.default_flat_noo='{{OPT.default_flat_noo}}'
OPT.default_folder_noo='{{OPT.default_folder_noo}}'
OPT.default_search_noo='{{OPT.default_search_noo}}'
var OPT = {{ OPT.to_dict()|tojson }};
OPT.root_eid = {{ query_data.root_eid }};
// this is the list of entry ids for the images for ALL matches for this query
var entryList={{query_data.entry_list}}
@@ -43,57 +32,17 @@
// pageList is just those entries shown on this page from the full entryList
var pageList=[]
// force pageList to set pageList for & render the first page
getPage(1)
getPage(1,getPageFigures)
function cFO() {
OPT.how_many=$('#how_many').val()
new_f=$('#folders').val()
new_f=( new_f == 'True' )
// if change to/from folders, also fix the noo menu
if( new_f != OPT.folders )
{
if( new_f )
{
$('#noo option:lt(2)').prop('disabled', true);
$('#noo').val(OPT.default_folder_noo)
}
else
{
$('#noo option:lt(2)').prop('disabled', false);
$('#noo').val(OPT.default_flat_noo)
}
}
OPT.noo=$('#noo').val()
OPT.folders=new_f
OPT.folders=$('#folders').val()
OPT.grouping=$('#grouping').val()
OPT.size=$('#size').val()
$.ajax({
type: 'POST',
url: '/change_file_opts',
data: JSON.stringify(OPT),
contentType: 'application/json',
success: function(resp) {
entryList=resp.query_data.entry_list
// put data back into booleans, ints, etc
OPT.folders=( OPT.folders == 'True' )
OPT.how_many=parseInt(OPT.how_many)
$('.how_many_text').html( `&nbsp;${OPT.how_many} files&nbsp;` )
OPT.root_eid=parseInt(OPT.root_eid)
OPT.size=parseInt(OPT.size)
getPage(1)
}
})
function changeSize()
{
sz=$('input[name="size"]:checked').val();
$('.thumb').prop('height',sz);
}
</script>
<div id="files_div">
<div class="container-fluid">
<input type="hidden" name="cwd" id="cwd" value="{{OPT.cwd}}">
{% if search_term is defined %}
<input type="hidden" name="search_term" id="view_term" value="{{search_term}}">
{% endif %}
<div class="d-flex row mb-2">
{% if OPT.folders %}
<div class="my-auto col col-auto">
@@ -113,13 +62,12 @@
{% endif %}
<div class="col col-auto">
<div class="input-group">
{{CreateSelect( "noo", OPT.noo, ["Oldest", "Newest","A to Z", "Z to A"], "cFO(); return false", "rounded-start py-2")|safe }}
{{CreateSelect( "how_many", OPT.how_many|string, ["10", "25", "50", "75", "100", "150", "200", "500"], "cFO(); return false" )|safe }}
{{CreateSelect( "noo", OPT.noo, ["Oldest", "Newest","A to Z", "Z to A"], "changeOPT(getPageFigures); return false", "rounded-start py-2")|safe }}
{{CreateSelect( "how_many", OPT.how_many|string, ["10", "25", "50", "75", "100", "150", "200", "500"], "changeOPT(getPageFigures); return false" )|safe }}
{% if OPT.folders %}
<input type="hidden" name="grouping" id="grouping" value="{{OPT.grouping}}">
{{CreateFoldersSelect( OPT.folders, "cFO(); return false", "rounded-end" )|safe }}
{{CreateFoldersSelect( OPT.folders, "changeOPT(getPageFigures); return false", "rounded-end" )|safe }}
{% else %}
{{CreateFoldersSelect( OPT.folders, "cFO(); return false" )|safe }}
{{CreateFoldersSelect( OPT.folders, "changeOPT(getPageFigures); return false" )|safe }}
<span class="sm-txt my-auto btn btn-outline-info disabled border-top border-bottom">grouped by:</span>
{{CreateSelect( "grouping", OPT.grouping, ["None", "Day", "Week", "Month"], "OPT.grouping=$('#grouping').val();drawPageOfFigures();return false", "rounded-end")|safe }}
{% endif %}
@@ -134,11 +82,11 @@
</script>
{% endif %}
<div class="col flex-grow-1 my-auto d-flex justify-content-center w-100">
<button aria-label="prev" id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary disabled" onClick="prevPage()" disabled>
<button aria-label="prev" id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary disabled" onClick="prevPage(getPageFigures)" disabled>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
</button>
<span class="how_many_text sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
<button aria-label="next" id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage()">
<button aria-label="next" id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage(getPageFigures)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
</button>
<button aria-label="move" id="move" disabled name="move" class="sm-txt btn btn-outline-primary ms-4" onClick="MoveDBox(move_paths,'{{url_for('internal', filename='icons.svg')}}'); return false;">
@@ -156,60 +104,35 @@
<button style="visibility:hidden" class="btn btn-outline-secondary" aria-label="ctrl-key" id="ctrl-key" onclick="document.fake_ctrl=1-document.fake_ctrl; event.stopPropagation(); return false">ctrl</button>
</div class="col flex-grow-1">
<div class="d-flex col col-auto justify-content-end">
<div class="btn-group">
{% if OPT.size == 64 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="extra small" id="64" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(64)">XS</button>
{% if OPT.size == 96 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="small" id="96" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(96)">S</button>
{% if OPT.size == 128 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="medium" id="128" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(128)">M</button>
{% if OPT.size == 192 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="large" id="192" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(192)">L</button>
{% if OPT.size == 256 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="extra large" id="256" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(256)">XL</button>
</div class="btn-group">
<div class="btn-group" role="group" aria-label="Size radio button group">
<input type="radio" class="btn-check" name="size" id="size-xs" onCLick="changeSize()" autocomplete="off" value="64">
<label class="btn btn-outline-info btn-radio" for="size-xs">XS</label>
<input type="radio" class="btn-check" name="size" id="size-s" onCLick="changeSize()" autocomplete="off" value="96">
<label class="btn btn-outline-info btn-radio" for="size-s">S</label>
<input type="radio" class="btn-check" name="size" id="size-m" onCLick="changeSize()" autocomplete="off" value="128">
<label class="btn btn-outline-info btn-radio" for="size-m">M</label>
<input type="radio" class="btn-check" name="size" id="size-l" onCLick="changeSize()" autocomplete="off" value="192">
<label class="btn btn-outline-info btn-radio" for="size-l">L</label>
<input type="radio" class="btn-check" name="size" id="size-xl" onCLick="changeSize()" autocomplete="off" value="256">
<label class="btn btn-outline-info btn-radio" for="size-xl">XL</label>
</div>
</div class="d-flex col">
<input id="offset" type="hidden" name="offset" value="{{OPT.offset}}">
<input id="size" type="hidden" name="size" value="{{OPT.size}}">
</div class="d-flex row mb-2">
{% set eids=namespace( str="" ) %}
{# gather all the file eids and collect them in case we go gallery mode #}
{% for obj in query_data.entry_list %}
{% set eids.str = eids.str + obj|string +"," %}
{% endfor %}
<input name="eids" id="eids" type="hidden" value="{{eids.str}}">
</div container="fluid">
<div id="figures" class="row ms-2">
</div>
<div class="container-fluid">
<input type="hidden" name="cwd" id="cwd" value="{{OPT.cwd}}">
<div class="row">
<div class="col my-auto d-flex justify-content-center">
<button aria-label="prev" id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary disabled" onClick="prevPage()" disabled>
<button aria-label="prev" id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary disabled" onClick="prevPage(getPageFigures)" disabled>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
</button>
<span class="how_many_text sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
<button aria-label="next" id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage()">
<button aria-label="next" id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage(getPageFigures)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
</button>
</div class="col my-auto">
@@ -219,6 +142,8 @@
<div id="viewer_div" class="d-none">
<style>
.norm-txt { font-size: 1.0rem }
.form-check-input:checked {
background-color: #39C0ED;
@@ -280,7 +205,7 @@
// pref page, load it
if( oldPageOffset != pageOffset )
// pref page is pageOffset+1 now
getPage(pageOffset+1,currentIndex)
getPage(pageOffset+1,getPageViewer,currentIndex)
else
document.viewing=document.entries[currentIndex]
}
@@ -297,7 +222,7 @@
// next page, load it
if( oldPageOffset != pageOffset )
// next page is pageOffset+1 now
getPage(pageOffset+1,currentIndex)
getPage(pageOffset+1,getPageViewer,currentIndex)
else
document.viewing=document.entries[currentIndex]
}
@@ -434,7 +359,9 @@
</div class="row">
</div id="viewer">
</div id="viewer_div">
{% endblock main_content %}
{% block script_content %}
<script>
$( document ).keydown(function(event) {
// if dbox is visible, dont process this hot-key, we are inputting text
@@ -477,12 +404,6 @@ $( document ).keydown(function(event) {
});
var fullscreen=false;
</script>
{% endblock main_content %}
{% block script_content %}
<script>
$(document).on('click', function(e) { $('.highlight').removeClass('highlight') ; SetButtonState() });
@@ -593,5 +514,7 @@ if( isMobile() )
$('#ctrl-key').css('visibility', 'visible');
}
// check the size radiobutton
$(`input[name="size"][value="${OPT.size}"]`).prop('checked', true)
</script>
{% endblock script_content %}