Compare commits

...

20 Commits

Author SHA1 Message Date
74647bcdfb remove duplicate lines of code 2025-11-18 21:13:02 +11:00
0ee55ee73d fixed BUG-143, disable next/prev as first thing we do when going next/prev Page 2025-10-28 23:04:33 +11:00
89e8c5d9f7 fix BUG-125: right-click changes highlight if its not in the highlight set 2025-10-28 22:31:15 +11:00
76b0745cc3 remove face data after transform (BUG-142), also only reset viewing image if we are viewing, and remove debugs 2025-10-28 22:16:26 +11:00
bc2d4384c9 fix BUG-141, crashing pa_job_manager when transforming a non-Image 2025-10-28 22:04:47 +11:00
d247ecec54 fix up bug where sucess used instead of success, also if we delete/restore/move a file from inside the viewer, then adjust the files* divs, and go out of the viewer (back/up) and show updated files div 2025-10-27 22:06:54 +11:00
bb43cc5623 new TODOs 2025-10-27 22:04:31 +11:00
a6edbd184b fixed mistaken removal of shift/ctrl buttons on mobiles for file* views 2025-10-26 20:30:44 +11:00
09e7b8eea7 one more old BUG gone, reordered file 2025-10-26 20:30:22 +11:00
f2ef55a58a cleaned up old bugs that are fixed by new entry_amendments logic 2025-10-26 15:55:13 +11:00
4d7f3dfed9 Fixing BUGs 144/145, (needed a parseInt), and force MovedBox path selection to Storage always - if moving from Import path, likely storing, if in Storage path, likely moving inside the Storage area, user can always override 2025-10-26 15:44:13 +11:00
4421da0d1d make move_files have EntryAmendments, do not remove from the UI instantly, and handle just like delete_files, remove restriction on forcing the page to go back to / on search, with new logic its not an issue, also force MoveDBox to start with Storage path rather than another, MOST moves should be to Storage, but should tweak this to be the opposite of current path type 2025-10-26 13:32:31 +11:00
6eba21e028 added BUG-143 around fast page changes 2025-10-25 20:26:32 +11:00
9bdd9d5b78 revamp whole EA flow. Server created EAs when we do certain jobs (transform, delete_files, restore_files), then instead of faking amendments in the jscript, get job creation to return EA from job ORM, then check is now generic for end of any amendment job, and when it finishes, use that to clear our amendments in document, and redraw through normal UI code. No smarts in client, all driven by state from server, and if we reload a page mid jobs, it has required state, and because an amendment job is still progressing, it runs check code again 2025-10-25 18:21:28 +11:00
d3ae9b788f make sure loading viewer for the first time add query param to get latest version of img, and when we leave viewer reset document.viewing 2025-10-25 18:18:58 +11:00
1396cbab78 added removeEntryAmendment() and call that when transform/delete_files/restore_files 2025-10-25 18:13:19 +11:00
b7500e5170 use throbber gray, rather than white 2025-10-25 18:12:32 +11:00
392708bad9 reformat 2025-10-25 18:12:15 +11:00
90b3fe4c2f try to warm the cache to help with restart / traefik not noticing 2025-10-25 18:06:38 +11:00
bd6c9c1fbd code formatting 2025-10-25 10:52:30 +11:00
14 changed files with 341 additions and 260 deletions

29
BUGs
View File

@@ -1,28 +1,9 @@
### Next: 143
BUG-142: after transforming, the face data is still in the old spots, really should delete it / make it recalc
BUG-141: can currently try to flip a video (in a highlighted group)
### Next: 146
BUG-140: When db is restarted underneath PA, it crashes job mgr... It should just accept timeouts, and keep trying to reconnect every 2? mins
BUG-100: I managed to get 2 photos matching mich in the NOT_WORKING photo (probably dif refimgs but same p.tag?)
= /photos/2012/20120414-damien/IMG_8467.JPG
BUG-118: can move files from Bin path, but it leaves the del_file entry for it - need to remove it
BUG-117: when search returns files that can be deleted and/or restored, the icon stays as delete and tries to delete!
BUG-106: cant add trudy /pat? as refimgs via FaceDBox
- seems the cropped trudy face is not sufficient to find a face, how odd...
(it came from a face bbox, BUT, I have grown the face seln by 10%?)
BUG-117: when search returns files that can be deleted and/or restored, the icon stays as delete and tries to delete!
BUG-118: can move files from Bin path, but it leaves the del_file entry for it - need to remove it
BUG-119: "Uncaught (in promise) Error: A listener indicated an asynchronous
response by returning true, but the message channel closed before a response
was received"
investigate this (possible I'm calling check_for_jobs and maybe not doing the async right?)
BUG-123: pa_job_manager crashed with timeout on connection (probably when I turned off traefik for a bit?). Regardless, should be more fault tolerant --> maybe offer to restart pa_job_manager IF its crashed?
this definitely happened also, when I shutdown the DB back-end mid job, and it was able to be restarted, so could get f/e to at least suggest a restart of the contianer, or auto-restart job_mgr?
BUG-125: when an image is highlighted, then post the contextmenu on a different image - the highlight does not move to the new image
and the selected menu function processes the original or the new depending on the way the code works.
There is a chance we need to change the document on click to a mouse down (or whatever the context menu
uses for default), rather than just fix the highlight
BUG-132: right arrow to go to next photo in viewer ALSO scrolls to the right, needs a return somewhere in the jscript
BUG-134: when moving set of photos on page, then move another set of photos on page, the first set reappears. Could really delete them from the dom?
BUG-137: after moving/refiling photos, the next shift-click is out of order (reload fixes it)
BUG-100: I managed to get 2 photos matching mich in the NOT_WORKING photo (probably dif refimgs but same p.tag?)
= /photos/2012/20120414-damien/IMG_8467.JPG

12
TODO
View File

@@ -1,15 +1,3 @@
* new viewing model (get ids of query on first load, then paginate only inside that known list)
- BUT, when we finish a delete, what do I do with pageList / entryList???
- start by showing them as deleted (via amend)
- then on success, remove the ids from the *List arrays in js -- but do
this via repagination, invalidate page cache fully, then getPage(currentPage)
(e.g. assume 1, 2, 3 ... 40 in eList). delete 23, 24,
then reset lists to remove 23 and 24, pageList would then
get reset to page with: 21,22,25,26 ... 30, 31
? get rid of style and just use class -- think this should work, so change in
templates/files.html for throbber, etc. and dont set style as much in view_support.js
### GENERAL
* jobs for AI should show path name
* rm dups job should show progress bar

View File

@@ -32,6 +32,7 @@ class EntryAmendment(PA,db.Model):
job_id = db.Column(db.Integer, db.ForeignKey("job.id"), primary_key=True )
amend_type = db.Column(db.Integer, db.ForeignKey("amendment_type.id"))
type = db.relationship("AmendmentType", backref="entry_amendment")
job = db.relationship("Job", back_populates="amendments")
################################################################################

View File

@@ -5,6 +5,7 @@ from main import db, app, ma
from sqlalchemy import Sequence, text, select, union, or_
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload
import numbers
import os
import glob
import json
@@ -266,6 +267,22 @@ class EntrySchema(ma.SQLAlchemyAutoSchema):
def get_full_path(self, obj):
return obj.FullPathOnFS()
class JobExtraSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = JobExtra
load_instance = True
name = ma.auto_field()
value = ma.auto_field()
class JobSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Job
load_instance = True
id = ma.auto_field()
name = ma.auto_field()
extra = ma.Nested(JobExtraSchema, many=True)
amendments = ma.Nested(EntryAmendmentSchema, many=True)
# global - this will be use more than once below, so do it once for efficiency
entries_schema = EntrySchema(many=True)
FOT_Schema = FaceOverrideTypeSchema(many=True)
@@ -273,6 +290,8 @@ path_Schema = PathSchema(many=True)
person_Schema = PersonSchema(many=True)
et_schema = AmendmentTypeSchema(many=True)
ea_schema = EntryAmendmentSchema(many=True)
job_schema = JobSchema(many=False)
job_schemas = JobSchema(many=True)
################################################################################
# /get_entries_by_ids -> route where we supply list of entry ids (for next/prev
@@ -312,7 +331,7 @@ def process_ids():
ea = db.session.execute(stmt).unique().scalars().all()
ea_data=ea_schema.dump(ea)
return jsonify(entries=entries_schema.dump(sorted_data), amend=ea_data)
return jsonify(entries=entries_schema.dump(sorted_data), amendments=ea_data)
################################################################################
@@ -629,7 +648,7 @@ def restore_files():
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) )
job=NewJob( name="restore_files", num_files=0, wait_for=None, jex=jex, desc="to restore selected file(s)" )
return redirect("/jobs")
return jsonify( job=job_schema.dump(job) )
################################################################################
# /delete_files -> create a job to delete files for the b/e to process
@@ -642,7 +661,7 @@ def delete_files():
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) )
job=NewJob( name="delete_files", num_files=0, wait_for=None, jex=jex, desc="to delete selected file(s)" )
return redirect("/jobs")
return jsonify( job=job_schema.dump(job) )
################################################################################
# /move_files -> create a job to move files for the b/e to process
@@ -655,8 +674,7 @@ def move_files():
for el in request.form:
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) )
job=NewJob( name="move_files", num_files=0, wait_for=None, jex=jex, desc="to move selected file(s)" )
# data is not used, but send response to trigger CheckForJobs()
return jsonify( job_id=job.id )
return jsonify( job=job_schema.dump(job) )
@login_required
@app.route("/view/", methods=["POST"])
@@ -685,7 +703,7 @@ def view():
# 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
# specific transorm job is finished (/check_transform_job) which will be called (say) every 1 sec. from f/e
# specific transorm job is finished (/check_amend_job_status) which will be called (say) every 1 sec. from f/e
# with a spinning wheel, then when pa_job_mgr has finished it will return the transformed thumb
@app.route("/transform", methods=["POST"])
@login_required
@@ -698,27 +716,28 @@ def transform():
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) )
job=NewJob( name="transform_image", num_files=0, wait_for=None, jex=jex, desc="to transform selected file(s)" )
return jsonify( job_id=job.id )
return jsonify( job=job_schema.dump(job) )
################################################################################
# /check_transform_job -> URL that is called repeatedly by front-end waiting for the
# b/e to finish the transform job. Once done, the new / now
# transformed image's thumbnail is returned so the f/e can
# update with it
# /check_amend_job_status -> URL that is called repeatedly by front-end waiting
# for the b/e to finish the amendment job (delete/restore/move file).
# Once done, return "ok"
################################################################################
@app.route("/check_transform_job", methods=["POST"])
@app.route("/check_amend_job_status", methods=["POST"])
@login_required
def check_transform_job():
def check_amend_job_status():
job_id = request.form['job_id']
stmt=select(Job).where(Job.id==job_id)
job=db.session.execute(stmt).scalars().one_or_none()
j=jsonify( finished=False )
if job.pa_job_state == 'Completed':
id=[jex.value for jex in job.extra if jex.name == "id"][0]
stmt=select(Entry).where(Entry.id==id)
stmt = select(Job).options(joinedload(Job.amendments)).where(Job.id == job_id)
job=db.session.execute(stmt).unique().scalars().first()
# FIXME: should validate job_id is real from UI
if job.name == 'transform_image':
eid=[jex.value for jex in job.extra if jex.name == "id"][0]
stmt=select(Entry).where(Entry.id==eid)
ent=db.session.execute(stmt).scalars().all()
ent_data=entries_schema.dump(ent)
j=jsonify( finished=True, entry=ent_data[0] )
j=jsonify(finished=(job.pa_job_state == 'Completed'), job=job_schema.dump(job), entry=ent_data[0] )
else:
j=jsonify(finished=(job.pa_job_state == 'Completed'), job=job_schema.dump(job))
return j
################################################################################

View File

@@ -87,21 +87,9 @@ function GetExistingDirsAsDiv( dt, divname, ptype )
} )
}
// wrapper to do some clean up before POST to /move_files or /delete_files
// used to remove the highlighted item(s) && reset the numbering so highlighting continues to work
function MoveOrDelCleanUpUI()
{
// remove the images being moved (so UI immediately 'sees' the move)
$("[name^=eid-]").each( function() { $('#'+$(this).attr('value')).remove() } )
// reorder the images via ecnt again, so future highlighting can work
// document.mf_id=0; $('.figure').each( function() { $(this).attr('ecnt', document.mf_id ); document.mf_id++ } )
$('#dbox').modal('hide')
}
// show the DBox for a move file, includes all thumbnails of selected files to move
// and a pre-populated folder to move them into, with text field to add a suffix
function MoveDBox(path_details)
function MoveDBox()
{
$('#dbox-title').html('Move Selected File(s) to new directory in Storage Path')
div =`
@@ -111,21 +99,21 @@ function MoveDBox(path_details)
<form id="mv_fm" class="form form-control-inline col-12">
<input id="move_path_type" name="move_path_type" type="hidden"
`
div += ' value="' + path_details[0].type.name + '"></input>'
div += ' value="' + move_paths[0].type.name + '"></input>'
div+=GetSelnAsDiv()
yr=$('.highlight').first().attr('yr')
dt=$('.highlight').first().attr('date')
div+='<div class="row">Use Existing Directory (in the chosen path):</div><div id="existing"></div>'
GetExistingDirsAsDiv( dt, "existing", path_details[0].type.name )
GetExistingDirsAsDiv( dt, "existing", 'Storage' )
div+=`
<div class="input-group my-3">
<alert class="alert alert-primary my-auto py-1">
`
// NB: alert-primary here is a hack to get the bg the same color as the alert primary by
div+= '<svg id="move_path_icon" width="20" height="20" fill="currentColor"><use xlink:href="' + path_details[0].icon_url + '"></svg>'
div+= '<svg id="move_path_icon" width="20" height="20" fill="currentColor"><use xlink:href="' + move_paths[0].icon_url + '"></svg>'
div+= '<select id="rp_sel" name="rel_path" class="text-primary alert-primary py-1 border border-primary rounded" onChange="change_rp_sel()">'
for(p of path_details) {
div+= '<option path_type="'+p.type.name+'" icon_url="'+p.icon_url+'">'+p.root_dir+'</option>'
for(p of move_paths) {
div+= `<option path_type="${p.type.name}" icon_url="${p.icon_url}">${p.root_dir}</option>`
}
div+= '</select>'
div+=`
@@ -139,11 +127,26 @@ function MoveDBox(path_details)
</div>
<div class="form-row col-12 mt-2">
<button onClick="$('#dbox').modal('hide'); return false;" class="btn btn-outline-secondary offset-1 col-2">Cancel</button>
<button id="move_submit" onClick="MoveOrDelCleanUpUI(); $.ajax({ type: 'POST', data: $('#mv_fm').serialize(), url: '/move_files', success: function(data) {
if( $(location).attr('pathname').match('search') !== null ) { window.location='/' }; CheckForJobs() } }); return false" class="btn btn-outline-primary col-2">Ok</button>
<button onClick="
$.ajax({ type: 'POST', data: $('#mv_fm').serialize(), url: '/move_files',
success: function(data) {
processAmendments( data.job.amendments )
checkForAmendmentJobToComplete(data.job.id)
}
});
$('#dbox').modal('hide')
return false"
class="btn btn-outline-secondary col-2">Ok</button>
</div>
</form>
`
// force to Storage always - if in Import, liekly storing, if in Storage, likely moving, user can always override
div+=`
<script>
storage_rp = move_paths.find(item => item.type.name === "Storage")?.root_dir;
$('#rp_sel').val(storage_rp);change_rp_sel()
</script>
`
$('#dbox-content').html(div)
$('#dbox').modal('show')
@@ -151,36 +154,71 @@ function MoveDBox(path_details)
$("#suffix").keypress(function (e) { if (e.which == 13) { $("#move_submit").click(); return false; } } )
}
// This function is called anytime we have a job that returns amendments
// (visually we want to show this entry is being amended by a job)
// as we check for a job to end every second, we can call this multiple times
// during the runtime of a job, so only redraw/react to a new amendment
// NOTE: we update all views, as we might go into one via jscript before the job ends
function processAmendments( ams )
{
for (const am of ams)
{
// if we return anything here, we already have this amendment, so continue to next
if( document.amendments.filter(obj => obj.eid === am.eid).length > 0 )
continue
document.amendments.push(am)
if( document.viewing && document.viewing.id == am.eid )
{
im.src=im.src + '?t=' + new Date().getTime();
DrawImg()
}
// find where in the page this image is being viewed
idx = pageList.indexOf(am.eid)
// createFigureHtml uses matching document.amendments to show thobber, etc
html = createFigureHtml( document.entries[idx] )
$('#'+am.eid).replaceWith( html )
}
}
// function to add data for document.amendment based on id and amt
// used when we transform several images in files_*, or single image in viewer
// show the DBox for a delete/restore file, includes all thumbnails of selected files
// with appropriate coloured button to Delete or Restore files`
// with appropriate coloured button to Delete or Restore files
function DelDBox(del_or_undel)
{
to_del = GetSelnAsData()
$('#dbox-title').html(del_or_undel+' Selected File(s)')
div ='<div class="row col-12"><p class="col">' + del_or_undel + ' the following files?</p></div>'
div+=GetSelnAsDiv()
if( del_or_undel == "Delete" )
{
which="delete"
col="danger"
}
else
{
which="restore"
col="success"
}
document.ents_to_del=[]
$('.highlight').each(function( cnt ) { document.ents_to_del[cnt]=parseInt($(this).attr('id')) } )
div+=`<div class="row col-12 mt-3">
<button onClick="$('#dbox').modal('hide')" class="btn btn-outline-secondary col-2">Cancel</button>
`
div+=`
<button onClick="MoveOrDelCleanUpUI(); $.ajax({ type: 'POST', data: to_del, url:
`
if( del_or_undel == "Delete" )
div+=`
'/delete_files',
success: function(data){
if( $(location).attr('pathname').match('search') !== null || document.viewing ) { window.location='/' }; CheckForJobs() } }); return false" class="btn btn-outline-danger col-2">Ok</button>
</div>
`
else
// just force page reload to / for now if restoring files from a search path -- a search (by name)
// would match the deleted/restored file, so it would be complex to clean up the UI (and can't reload, as DB won't be changed yet)
div+=`
'/restore_files',
success: function(data){
if( $(location).attr('pathname').match('search') !== null || document.viewing ) { window.location='/' }; CheckForJobs() } }); return false" class="btn btn-outline-success col-2">Ok</button>
</div>
`
<button onClick="
$.ajax({ type: 'POST', data: to_del, url: '/${which}_files',
success: function(data) {
processAmendments( data.job.amendments )
checkForAmendmentJobToComplete(data.job.id)
}
});
$('#dbox').modal('hide')
return false"
class="btn btn-outline-${col} col-2">Ok</button>
</div>`
$('#dbox-content').html(div)
$('#dbox').modal('show')
}
@@ -393,7 +431,7 @@ function createFigureHtml( obj )
ent=""
gs="style='filter: grayscale(100%);'"
am_html ='<img class="position-absolute top-50 start-50 translate-middle" height="60" src="/internal/white-circle.png">'
am_html +='<img class="position-absolute top-50 start-50 translate-middle" height="64" src="/internal/throbber.gif">'
am_html+='<img class="position-absolute top-50 start-50 translate-middle" height="64" src="/internal/throbber.gif">'
if( am.type.which == 'icon' )
am_html+=`<svg class="position-absolute top-50 start-50 translate-middle" height="32" style="color:${am.type.colour}" fill="${am.type.colour}"><use xlink:href="/internal/icons.svg#${am.type.what}"></use></svg>`
else
@@ -408,7 +446,7 @@ function createFigureHtml( obj )
path_type="${pathType}" size="${size}" hash="${hash}" in_dir="${inDir}"
fname="${fname}" yr="${yr}" date="${date}" pretty_date="${prettyDate}" type="${type}">
${renderMedia(obj,gs,am_html)}
</figure>`;
`
}
// Directory entry
else if (obj.type.name === "Directory" && OPT.folders) {
@@ -422,7 +460,6 @@ function createFigureHtml( obj )
<use xlink:href="/internal/icons.svg#Directory"></use>
</svg>
<figcaption class="svg_cap figure-caption text-center text-wrap text-break">${obj.name}</figcaption>
</figure>
`;
html += `<script>f=$('#${obj.id}'); w=f.find('svg').width(); f.find('figcaption').width(w);</script>`;
}
@@ -434,7 +471,8 @@ function createFigureHtml( obj )
$('#${obj.id}').click( function(e) { DoSel(e, this ); SetButtonState(); return false; });
$('#${obj.id}').dblclick( function(e) { startViewing( $(this).attr('id') ) } )
}
</script>`
</script>
</figure>`
return html
}
@@ -621,6 +659,7 @@ function getEntriesByIdSuccessHandler(res,pageNumber,successCallback,viewingIdx)
document.entries=res;
// cache this
document.page[pageNumber]=res
// FIXME: I want to remove successCallback, instead: if viewing, or files_*, or file_list, then call relevant draw routine
successCallback(res,viewingIdx)
resetNextPrevButtons()
// if search, disable folders
@@ -663,11 +702,11 @@ function getPage(pageNumber, successCallback, viewingIdx=0)
data: JSON.stringify(data), contentType: 'application/json',
dataType: 'json',
success: function(res) {
document.amendments=res.amend;
// this is only called when we are viewing a page in files/list view, so check for job(s) ending...
for (const tmp of document.amendments) {
CheckTransformJob(tmp.eid,tmp.job_id,handleTransformFiles)
}
document.amendments=res.amendments;
// only called when an amendment is pending & we are viewing a page in files/list view
// so check for amendment job(s) ending...
for (const tmp of document.amendments)
checkForAmendmentJobToComplete(tmp.job_id)
getEntriesByIdSuccessHandler( res.entries, pageNumber, successCallback, viewingIdx )
},
error: function(xhr, status, error) { console.error("Error:", error); } });
@@ -720,6 +759,10 @@ 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(successCallback)
{
// start with disabling more next presses until we are ready to process them
$('.prev').prop('disabled', true).addClass('disabled');
$('.next').prop('disabled', true).addClass('disabled');
// pageList[0] is the first entry on this page
const currentPage=getPageNumberForId( pageList[0] )
// should never happen / just return pageList unchanged
@@ -735,6 +778,10 @@ function nextPage(successCallback)
// get list of eids for the prev page, also make sure next/prev buttons make sense for page we are on
function prevPage(successCallback)
{
// start with disabling more prev presses until we are ready to process them
$('.prev').prop('disabled', true).addClass('disabled');
$('.next').prop('disabled', true).addClass('disabled');
// pageList[0] is the first entry on this page
const currentPage=getPageNumberForId( pageList[0] )
// should never happen / just return pageList unchanged
@@ -811,11 +858,78 @@ function changeSize()
$('.svg_cap').width(sz);
}
// when a delete or restore files job has completed successfullly, then get ids
// find the page we are on, remove amendments & ids from entryList and re-get page
// which will reset pageList and the UI of images for that page
function handleMoveOrDeleteOrRestoreFileJobCompleted(job)
{
// this grabs the values from the object attributes of eid-0, eid-1, etc.
const ids = job.extra.filter(item => item.name.startsWith("eid-")).map(item => item.value);
// find page number of first element to delete (this is the page we will return too)
pnum=getPageNumberForId( parseInt(ids[0]) )
// remove amendment data
for (const ent of ids)
{
id=parseInt(ent)
removeAmendment( id )
// remove the item in the entryList
index=entryList.indexOf(id);
if( index != -1 )
entryList.splice(index, 1); // Remove the element
else
{
return; // have to get out of here, or calling getPage() below will loop forever
}
}
// re-create pageList by reloading the page
getPage(pnum,getPageFigures)
}
// POST to a check URL, that will tell us if the amendment job has completed,
// it also calls CheckForJobs() which will fix up the Active Jobs badge,
function checkForAmendmentJobToComplete(job_id)
{
CheckForJobs()
$.ajax( { type: 'POST', data: '&job_id='+job_id, url: '/check_amend_job_status',
success: function(res) { handleCheckAmendmentJobStatus(res); } } )
}
// the status of a Amendment Job has been returned, finished is True/False
// if not finished try again in 1 second... If finished then invalidate page
// cache and based on job type call code correct func to update the UI appropriately
function handleCheckAmendmentJobStatus(data)
{
if( data.finished )
{
// invalidate the cache
document.page.length=0
// transforms contain the single transformed entry data for convenience
if( data.job.name == 'transform_image' )
handleTransformImageJobCompleted(data.job, data.entry)
else if ( data.job.name == 'delete_files' || data.job.name == 'restore_files' || data.job.name == 'move_files' )
handleMoveOrDeleteOrRestoreFileJobCompleted(data.job)
// if we are viewing this file, then just go up / back,b/c this file is "gone" from this view
if( document.viewing )
goOutOfViewer()
}
else { setTimeout( function() { checkForAmendmentJobToComplete(data.job.id) }, 1000 ); }
}
// different context menu on files
$.contextMenu({
selector: '.entry',
itemClickEvent: "click",
build: function($triggerElement, e) {
// if we are not in the highlight set, then move the highlight to this element
if( ! $(e.currentTarget).is('.highlight') )
{
$('.highlight').removeClass('highlight');
$(e.currentTarget).addClass('highlight')
}
// when right-clicking & no selection add one OR deal with ctrl/shift right-lick as it always changes seln
if( NoSel() || e.ctrlKey || e.shiftKey )
{
@@ -877,7 +991,7 @@ $.contextMenu({
callback: function( key, options) {
if( key == "details" ) { DetailsDBox() }
if( key == "view" ) { startViewing( $(this).attr('id') ) }
if( key == "move" ) { MoveDBox(move_paths) }
if( key == "move" ) { MoveDBox() }
if( key == "del" ) { DelDBox('Delete') }
if( key == "undel") { DelDBox('Restore') }
if( key == "r90" ) { Transform(90) }

View File

@@ -5,72 +5,27 @@ function removeAmendment( id )
document.amendments=document.amendments.filter(obj => obj.eid !== id)
}
// POST to a check URL, that will tell us if the transformation has completed,
// if not, try again in 1 second... If it has finished then reset the thumbnail
// to full colour, put it back to being an entry and reset the thumbnail to the
// newly created one that was sent back in the response to the POST
function handleTransformFiles(data,id,job_id)
// If Transform job has finished then reset relevant document.entries
// with updated from DB, remove the amendment and redraw image
function handleTransformImageJobCompleted(job, entry)
{
if( data.finished )
removeAmendment( entry.id )
// update viewer if we are viewing an image
if( document.viewing )
{
id=parseInt(id)
idx = entryList.indexOf(id)
// replace data for this entry now its been transformed
document.entries[idx]=data.entry
// update cache too
// document.page[getPageNumberForId(id)][howFarIntoPageCache(id)]=data.entry
// FIXME: for now just invalidate whole cache
document.page.length=0
removeAmendment( id )
// redraw into figure html in dom
last={ 'printed': 'not required' }
html = createFigureHtml( data.entry, last, 9999 )
$('#'+id).replaceWith( html )
return false;
}
else
{
setTimeout( function() { CheckTransformJob(id,job_id,handleTransformFiles) }, 1000,id, job_id );
}
}
// POST to a check URL, that will tell us if the transformation has completed,
// if not, try again in 1 second... If it has finished then reset the image
// to full colour
function handleTransformViewing(data,id,job_id)
{
if( data.finished )
{
// stop throbber, remove grayscale & then force reload with timestamped version of im.src
// force reload with timestamped version of im.src
im.src=im.src + '?t=' + new Date().getTime();
removeAmendment( id )
return false;
DrawImg()
}
else
{
setTimeout( function() { CheckTransformJob(id,job_id,handleTransformViewing) }, 1000,id, job_id );
}
}
// POST to a check URL, that will tell us if the transformation has completed,
// if not, try again in 1 second... If it has finished then reset the thumbnail
// to full colour, put it back to being an entry and reset the thumbnail to the
// newly created one that was sent back in the response to the POST
function CheckTransformJob(id,job_id,successCallback)
{
CheckForJobs()
$.ajax( { type: 'POST', data: '&job_id='+job_id, url: '/check_transform_job',
success: function(res) { successCallback(res,id,job_id); } } )
}
// function to add data for document.amendment based on id and amt
// used when we transform several images in files_*, or single image in viewer
function addTransformAmendment(id,amt)
{
am={}
am.eid=parseInt(id)
am.type = document.amendTypes.filter(obj => obj.job_name === 'transform_image:'+amt )[0]
document.amendments.push(am)
// ALWAYS update files* div as we could go back to this from a viewer, and
// the thumbnail needs the updated data
idx = entryList.indexOf(entry.id)
// replace data for this entry now its been transformed
document.entries[idx]=entry
// redraw into figure html in dom
html = createFigureHtml( entry )
$('#'+entry.id).replaceWith( html )
}
// for each highlighted image, POST the transform with amt (90, 180, 270,
@@ -81,15 +36,13 @@ function addTransformAmendment(id,amt)
function Transform(amt)
{
// we are in the viewer with 1 image only...
if( document.viewing )
if( $('#viewer_div').length && ! $('#viewer_div').hasClass('d-none') )
{
post_data = '&amt='+amt+'&id='+document.viewing.id
// POST /transform for image, grayscale the image, add throbber, & start checking for end of job
$.ajax({ type: 'POST', data: post_data, url: '/transform', success: function(data) {
addTransformAmendment(document.viewing.id, amt)
DrawImg();
CheckTransformJob(document.viewing.id,data.job_id,handleTransformViewing);
return false;
processAmendments(data.job.amendments)
checkForAmendmentJobToComplete(data.job.id)
} })
}
else
@@ -98,13 +51,8 @@ function Transform(amt)
post_data = '&amt='+amt+'&id='+e.id
// POST /transform for image, grayscale the thumbnail, add throbber, & start checking for end of job
$.ajax({ type: 'POST', data: post_data, url: '/transform', success: function(data){
addTransformAmendment(e.id, amt)
last={ 'printed': 'not required' }
idx = pageList.indexOf(parseInt(e.id))
html = createFigureHtml( document.entries[idx], last, 9999 )
$('#'+e.id).replaceWith( html )
CheckTransformJob(e.id,data.job_id,handleTransformFiles);
return false;
processAmendments(data.job.amendments)
checkForAmendmentJobToComplete(data.job.id)
} })
} )
}

View File

@@ -71,21 +71,14 @@ function SetActiveJobsBadge(num_jobs)
// after a 1 second timeout
function CheckForJobs()
{
$.ajax(
{
$.ajax( {
type: 'POST', url: '/check_for_jobs',
success: function(data) {
data.sts.forEach(
function(el)
{
StatusMsg(el)
}
)
// for each status, handle it/make toast in UI
data.sts.forEach( function(el) { StatusMsg(el) } )
SetActiveJobsBadge(data.num_active_jobs)
if( data.num_active_jobs > 0 )
{
setTimeout( function() { CheckForJobs() }, 1000 );
}
// still active job(s), keep checking for them to end
if( data.num_active_jobs > 0 ) { setTimeout( function() { CheckForJobs() }, 1000 ); }
},
} )
return false;

View File

@@ -80,16 +80,19 @@ function DrawImg()
// -50 is a straight up hack, no idea why this works, but its good enough for me
if (!Array.isArray(am))
{
const style = 'position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);';
$('#throbber').attr('style', style + ' height: 96px;');
$('#white-circle').attr('style', style + ' height: 72px;');
$('#throbber').show()
$('#white-circle').show()
if(am.type.which == 'img' )
$('#inside-img').attr('style', style + ' height: 64px;').attr('src', '/internal/'+am.type.what );
{
$('#inside-img').attr('src', '/internal/'+am.type.what );
$('#inside-img').show()
}
else
{
$('#inside-icon').attr('style', `${style} color:${am.type.colour}; height: 64px;`)
$('#inside-icon').attr('style', `color:${am.type.colour};height:64px` )
$('#inside-icon').attr('fill', am.type.colour )
$('#inside-icon use').attr('xlink:href', `/internal/icons.svg#${am.type.what}`);
$('#inside-icon').show()
}
} else {
$('#throbber').hide()
@@ -186,7 +189,7 @@ function ViewImageOrVideo()
if( ! document.viewing ) return
if( document.viewing.type.name == 'Image' )
{
im.src='../' + document.viewing.FullPathOnFS
im.src='../' + document.viewing.FullPathOnFS + '?t=' + new Date().getTime();
$('#video_div').hide()
if( $('#fname_toggle').prop('checked' ) )
$('#img-cap').show()
@@ -623,6 +626,8 @@ function goOutOfViewer()
// hide viewer div, then show files_div
$('#viewer_div').addClass('d-none')
$('#files_div').removeClass('d-none')
// no longer viewing an image too
document.viewing=null
}
// change the viewer to the previous entry (handle page change too)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

29
job.py
View File

@@ -4,6 +4,7 @@ from flask import request, render_template, redirect, make_response, jsonify, ur
from settings import Settings
from main import db, app, ma
from sqlalchemy import Sequence, func, select
from sqlalchemy.orm import joinedload
from sqlalchemy.exc import SQLAlchemyError
from datetime import datetime, timedelta
import pytz
@@ -58,10 +59,12 @@ class Job(db.Model):
extra = db.relationship( "JobExtra")
logs = db.relationship( "Joblog")
amendments = db.relationship("EntryAmendment", back_populates="job")
def __repr__(self):
return "<id: {}, start_time: {}, last_update: {}, name: {}, state: {}, num_files: {}, current_file_num: {}, current_file: {}, pa_job_state: {}, wait_for: {}, extra: {}, logs: {}>".format(self.id, self.start_time, self.last_update, self.name, self.state, self.num_files, self.current_file_num, self.current_file, self.pa_job_state, self.wait_for, self.extra, self.logs)
################################################################################
# Class describing PA_JobManager_Message and in the DB (via sqlalchemy)
# the job manager can send a message back to the front end (this code) via the
@@ -83,7 +86,7 @@ class PA_JobManager_Message(PA,db.Model):
# Used in main html to show a red badge of # jobs to draw attention there are
# active jobs being processed in the background
################################################################################
def GetNumActiveJobs():
def getNumActiveJobs():
ret=Job.query.filter(Job.pa_job_state != 'Completed').with_entities(func.count(Job.id).label('count') ).first()
return ret[0]
@@ -122,16 +125,14 @@ def NewJob(name, num_files="0", wait_for=None, jex=None, desc="No description pr
if job.name == 'transform_image':
id=[jex.value for jex in job.extra if jex.name == "id"][0]
ea=EntryAmendment( eid=id, job_id=job.id, amend_type=at_id )
print( f"just added an EA for eid={id}, j={job.id}" )
db.session.add(ea)
elif job.name == 'delete_files':
job.amendments.append(ea)
elif job.name == 'delete_files' or job.name == 'restore_files' or job.name == 'move_files':
for j in jex:
if 'eid-' in j.name:
ea=EntryAmendment( eid=j.value, amend_type=at_id )
ea=EntryAmendment( eid=j.value, job_id=job.id, amend_type=at_id )
db.session.add(ea)
# need to return this to the f/e somehow
# this is for removes, really need to think about this more
#job.amendment=ea
job.amendments.append(ea)
SetFELog( message=f'Created <a class="link-light" href="/job/{job.id}">Job #{job.id}</a> to {desc}', level="success" )
WakePAJobManager(job.id)
@@ -327,14 +328,22 @@ def joblog_search():
@app.route("/check_for_jobs", methods=["POST"])
@login_required
def check_for_jobs():
num=GetNumActiveJobs()
from files import job_schemas
num=getNumActiveJobs()
messages = PA_JobManager_Message.query.all()
sts=[]
for msg in PA_JobManager_Message.query.all():
for msg in messages:
u=''
if 'Job #' not in msg.message and msg.job_id:
u='<a class="link-light" href="' + url_for('joblog', id=msg.job_id) + '">Job #' + str(msg.job_id) + '</a>: '
sts.append( { 'id': msg.id, 'message': u+msg.message, 'level': msg.level, 'job_id': msg.job_id, 'persistent': msg.persistent, 'cant_close': msg.cant_close } )
return make_response( jsonify( num_active_jobs=num, sts=sts ) )
# get jobs mentioned in messages as we may need to process the by client for UI
job_list=[obj.job_id for obj in messages]
stmt = select(Job).options(joinedload(Job.amendments)).where(Job.id.in_(job_list))
jobs=db.session.execute(stmt).unique().scalars().all()
return make_response( jsonify( num_active_jobs=num, sts=sts, jobs=job_schemas.dump(jobs) ) )
###############################################################################
# /clear_msg -> POST -> clears out a F/E message based on passed in <id>

View File

@@ -66,8 +66,6 @@ app.config['LDAP_USER_DN'] = 'ou=users'
app.config['LDAP_GROUP_DN'] = 'ou=groups'
app.config['LDAP_USER_RDN_ATTR'] = 'uid'
app.config['LDAP_USER_LOGIN_ATTR'] = 'uid'
app.config['LDAP_BIND_USER_DN'] = None
app.config['LDAP_BIND_USER_PASSWORD'] = None
app.config['LDAP_GROUP_OBJECT_FILTER'] = '(objectclass=posixGroup)'
app.config['LDAP_BIND_USER_DN'] = None
app.config['LDAP_BIND_USER_PASSWORD'] = None

View File

@@ -886,7 +886,6 @@ def RunJob(job):
elif job.name == "run_ai_on_path":
JobRunAIOnPath(job)
elif job.name == "transform_image":
#time.sleep(10)
JobTransformImage(job)
elif job.name == "clean_bin":
JobCleanBin(job)
@@ -1874,6 +1873,20 @@ def JobRunAIOn(job):
FinishJob(job, "Finished Processesing AI")
return
################################################################################
# removeEntryAmendment(): helper routine to remove an Etnry Amendment for a
# given job and eid (called after Transform or Delete/Restore/Move files
################################################################################
def removeEntryAmendment( job, eid ):
# now remove the matching amendment for the transform job
stmt=select(EntryAmendment).where(EntryAmendment.eid==eid)
ea=session.execute(stmt).scalars().one_or_none()
if ea:
session.delete(ea)
else:
AddLogForJob( job, f"ERROR: failed to remove entry amendment in DB for this transformation? (eid={id})" )
PAprint( f"ERROR: failed to remove entry amendment in DB for this transformation? (eid={id}, job={job} )" )
####################################################################################################################################
# JobTransformImage(): transform an image by the amount requested (can also flip horizontal or vertical)
####################################################################################################################################
@@ -1884,6 +1897,11 @@ def JobTransformImage(job):
amt=[jex.value for jex in job.extra if jex.name == "amt"][0]
e=session.query(Entry).join(File).filter(Entry.id==id).first()
PAprint( f"JobTransformImage: job={job.id}, id={id}, amt={amt}" )
# cant transfer non-image, but may get here if multi-select includes non-Image
if e.type.name != 'Image':
removeEntryAmendment( job, id )
FinishJob(job, "Cannot rotate file as it is not an Image","Failed")
return
if amt == "fliph":
AddLogForJob(job, f"INFO: Flipping {e.FullPathOnFS()} horizontally" )
@@ -1907,14 +1925,9 @@ def JobTransformImage(job):
e.file_details.hash = md5( job, e )
PAprint( f"JobTransformImage DONE thumb: job={job.id}, id={id}, amt={amt}" )
session.add(e)
# now remove the matching amendment for the transform job
stmt=select(EntryAmendment).where(EntryAmendment.eid==id)
ea=session.execute(stmt).scalars().one_or_none()
if ea:
session.delete(ea)
else:
AddLogForJob( job, f"ERROR: failed to remove entry amendment in DB for this transformation? (eid={id})" )
PAprint( f"ERROR: failed to remove entry amendment in DB for this transformation? (eid={id}, job={job} )" )
# any faces in this file are no longer valid, remove them
session.query(FaceFileLink).filter(FaceFileLink.file_eid==e.id).delete()
removeEntryAmendment( job, id )
FinishJob(job, "Finished Processesing image rotation/flip")
return
@@ -2193,6 +2206,7 @@ def JobMoveFiles(job):
if 'eid-' in jex.name:
move_me=session.query(Entry).get(jex.value)
MoveEntriesToOtherFolder( job, move_me, dst_storage_path, f"{prefix}{suffix}" )
removeEntryAmendment( job, move_me.id )
NewJob( name="check_dups", num_files=0, wait_for=None, jex=None, parent_job=None, desc="check for duplicate files" )
FinishJob(job, f"Finished move selected file(s)")
return
@@ -2207,6 +2221,7 @@ def JobDeleteFiles(job):
if 'eid-' in jex.name:
del_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first()
MoveFileToRecycleBin(job,del_me)
removeEntryAmendment(job,del_me.id)
NewJob( name="check_dups", num_files=0, wait_for=None, jex=None, parent_job=None, desc="check for duplicate files" )
FinishJob(job, f"Finished deleting selected file(s)")
return
@@ -2221,6 +2236,7 @@ def JobRestoreFiles(job):
if 'eid-' in jex.name:
restore_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first()
RestoreFile(job,restore_me)
removeEntryAmendment(job,restore_me.id)
NewJob( name="check_dups", num_files=0, wait_for=None, jex=None, parent_job=None, desc="check for duplicate files" )
FinishJob(job, f"Finished restoring selected file(s)")
return

View File

@@ -140,10 +140,12 @@
<figure style="position: relative;" class="col col-auto border border-info rounded m-0 p-1" id="figure">
<canvas id="canvas"></canvas>
<!-- next 4 are placeholders and called on during amendments only in viewer code -->
<img id="throbber" src="{{url_for('internal', filename='throbber.gif')}}?v={{js_vers[th]}}" style="display:none;">
<img id="white-circle" src="{{url_for('internal', filename='white-circle.png')}}?v={{js_vers[th]}}" style="display:none;">
<img id="inside-img" style="display:none;">
<svg id="inside-icon" style="display:none;" fill="currentColor">
<img id="throbber" src="{{url_for('internal', filename='throbber.gif')}}?v={{js_vers[th]}}" style="display:none;height:96px"
class="position-absolute top-50 start-50 translate-middle">
<img id="white-circle" src="{{url_for('internal', filename='white-circle.png')}}?v={{js_vers[th]}}" style="display:none;height:72px"
class="position-absolute top-50 start-50 translate-middle">
<img id="inside-img" style="display:none;height:64px" class="position-absolute top-50 start-50 translate-middle">
<svg id="inside-icon" style="display:none;height:64px" class="position-absolute top-50 start-50 translate-middle">
<use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#flip_v">
</use></svg>
<script>
@@ -274,10 +276,8 @@
// force pageList to set pageList for & render the first page
getPage(1,getPageFigures)
// FIXME: doco, but also gather all globals together, many make them all document. to be obviously global (and add fullscreen)
// gap is used to keep some space around video in viewer - tbh, not sure why anymore
var gap=0.8
var grayscale=0
var throbber=0
function PrettyFname(fname)
{
@@ -327,5 +327,11 @@
$('#viewer_del').on('click', function() { DelDBox('Restore') } )
}
if( isMobile() )
{
$('#shift-key').css('visibility', 'visible');
$('#ctrl-key').css('visibility', 'visible');
}
</script>
{% endblock script_content %}

View File

@@ -13,6 +13,9 @@ else
sudo -u pauser gunicorn --bind=0.0.0.0:80 --workers=1 --threads=1 main:app --env ENV="development" --error-logfile gunicorn.error.log --access-logfile gunicorn.log --capture-output --enable-stdio-inheritance --reload
fi
# warm the cache to see if this helps with odd restart 404s
curl -sf http://localhost/health
# this should never be invoked unless gunicorn fails -- in that case, at least
# we will keep the container can login by hand and check the issue/error
sleep 99999