Compare commits

...

35 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
56771308a6 updated BUGs in general to remove older / fixed BUGs relating to the confusion of current/eids, etc.
update amendments in tables.sql to include job_id in entry_ammendment
added amend.py to move amendment-related code into its own file when we create a job (NewJob)
  and that job matches an amendmentType (via job_name or job_name:amt <- where amt relates to how we do a transform_image), then
  we enter a new EntryAmendment pa_job_mgr knows when a Transform job ends, and removes relevant EntryAmendment
files*.js use EntryAmendment data to render thumbnails with relevant AmendmentType
if a normal page load (like /files_ip), and there is an EntryAmendment, mark up the thumb, run the check jobs to look for completion of the job,
  removeal of the EntryAmendment and update the entry based on 'transformed' image

OVERALL: this is a functioning version that uses EntryAmendments and can handle loading a new page with outstanding amendments
  and 'deals' with it.  This is a good base, but does not cater for remove_files or move_files
2025-10-20 19:31:57 +11:00
905910ecf0 updated BUGs in general to remove older / fixed BUGs relating to the confusion of current/eids, etc.
update amendments in tables.sql to include job_id in entry_ammendment
added amend.py to move amendment-related code into its own file
when we create a job (NewJob) and that job matches an amendmentType (via job_name or job_name:amt <- where amt relates to how we do a transform_image), then
  we enter a new EntryAmendment
pa_job_mgr knows when a Transform job ends, and removes relevant EntryAmendment
files*.js use EntryAmendment data to render thumbnails with relevant AmendmentType and
  if a normal page load (like /files_ip), and there is an EntryAmendment, mark
  up the thumb, run  the check jobs to look for completion of the job, removeal
  of the EntryAmendment and update the entry based on 'transformed' image
OVERALL: this is a functioning version that uses EntryAmendments and can handle
loading a new page with outstanding amendments and 'deals' with it.  This is a
good base, but does not cater for remove_files or move_files
2025-10-20 19:23:52 +11:00
a38c54812c use amendments to render throbber/grayscale 2025-10-20 19:23:12 +11:00
dc6b831481 remove overkill use of make_response 2025-10-20 19:16:25 +11:00
8969cd452e for now, store away amendment types into dom for client-side additions - still working on right place for the additions 2025-10-19 11:29:52 +11:00
d65f3b32d3 pass amendmentTypes to client in query_data, make query_data single func to remove duplicate code, reference new amend.py for class defintions 2025-10-19 11:29:10 +11:00
0b0035d1d2 adding job_name into amendment type, and updating inserts to match 2025-10-19 11:24:54 +11:00
80ceb7aaed removed __repr__ from classes in files.py, and added in sqlalchemy class and marshmallow schemas for entry amendments, then load amendments on get_entry_by_id - so any page load (first or next/prev) will see amendments, we then display them into the files list and now add a white circle inside the throbber and overlay that with approrpiate icon/image - all of which is taken from amendment type and eid. tables.sql also updated to create the amendment data, tweaked icons.svg to remove hardcoded-colours for flip_[vh] 2025-10-15 23:06:05 +11:00
9cf47f4582 removed __repr__ from classes in files.py, and added in sqlalchemy class and marshmallow schemas for entry amendments 2025-10-15 22:52:17 +11:00
a683da13cc gave whole of TODO a once over, removed old stuff 2025-10-12 23:49:35 +11:00
9ffb704648 fix bug where AI search used wrong query as I rewrote the search_term and lost AI: now compares original / OPT.search_term. Also, the union/sql did not work with otdering, rewrote this to use python code to remove duplicates from the 3 separate queries and remove duplicates (if any) 2025-10-12 21:23:45 +11:00
27e06a9462 any change of opts needs to invaldiate cache 2025-10-12 21:22:14 +11:00
4556afb3bb changed token word 2025-10-12 19:34:00 +11:00
0eee594206 fix up bug with cache not working when we change how_many 2025-10-12 19:33:12 +11:00
78b112d050 when we change file options, make sure we call correct query data function - search needs GetSerachQueryData 2025-10-12 19:31:45 +11:00
16 changed files with 708 additions and 395 deletions

42
BUGs
View File

@@ -1,41 +1,9 @@
### Next: 141 ### 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-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-139: using any large entry list and going next a few times, ends say 4 pages of 50 into 4000 matches (entries from DB < 50)... BUG-118: can move files from Bin path, but it leaves the del_file entry for it - need to remove it
- confirmed this is when person has 2 or more refimgs: BUG-117: when search returns files that can be deleted and/or restored, the icon stays as delete and tries to delete!
- on page "2", we get 49 pulled back in the ORM instead of the 50 expected -- b/c I use that to indicate we must be at the end of the list if not 50 found
-- really, need to fix once and for all the eids / re-running query.
do GetEntries as we do now, once done however, get all entry ids. Stick those into the DB with a unique query-id and datestamp
new func to get all details needed for entries in an eid list (of 1-page) - show this page of entries
use current, full eidlist and to work our start/end of list (next/prev), disabling.
then client can keep current page of data, if you hit next/prev, use DB unique query id / full list and page of eids, and give full data for new page of entries
Implications though, are if a search is invalidated (maybe delete / move a photo), need to remove them from the list on the DB too OR let user know/decide to fix/wait.
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-106: cant add trudy /pat? as refimgs via FaceDBox BUG-106: cant add trudy /pat? as refimgs via FaceDBox
- seems the cropped trudy face is not sufficient to find a face, how odd... - 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%?) (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-100: I managed to get 2 photos matching mich in the NOT_WORKING photo (probably dif refimgs but same p.tag?)
BUG-118: can move files from Bin path, but it leaves the del_file entry for it - need to remove it = /photos/2012/20120414-damien/IMG_8467.JPG
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-130: moving files and then trying to go next page and it got confused...
BUG-132: right arrow to go to next photo in viewer ALSO scrolls to the right, needs a return somewhere in the jscript
BUG-133: when rebuilding pa[dev], the first run fails to have symlinks to the right paths for Import/Storage, etc. a simple restart fixes - so potentially the intial copy or some other race condition?
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-135: failed to rotate: 2006/20061215-ITS-xmas-KP/DSC00582.JPG - not sure why && not repeatable, so its not the image, timing/race condition maybe?
BUG-137: after moving/refiling photos, the next shift-click is out of order (reload fixes it)
BUG-138: Placeholder for all the ways we can get the front-end confused:
---> JUST fix all these BUGs (relating to confused/lost state) by revisiting the overally complex way I remember state and my position in a list (probably FAR easier, to make an initial sql just save all eids, and then not try to recreate that list ever again and not care how I got into the list). Can attach a "running server-side sequence number", and if old sequence, and the original eid list results in a failure, then just pop up that the saved list is no longer valid, and ask user to re-do their search/list..."

39
TODO
View File

@@ -1,27 +1,13 @@
###
#
# consider how to better version jscript - across all html files, consistently
# mtime, didnt work anyway, my phone still wont pick up the change, it was adding any ?v= changed this (once)
#
# 5 think I killed pa_job_manager without passing an eid to a transform job, shouldn't crash
# SHOULD JUST get AI to help clean-up and write defensive code here...
#
###
### major fix - go to everywhere I call GetEntries(), and redo the logic totally...
* client side:
* for real chance to stop confusion, instead of removing deleted images from DOM, we should gray them out and put a big Del (red circle with line?) though it as overlay.
* Create another table is entry_ammendments - note the deletions, rotations, flips of specific eids - then reproduce that on the client side visually as needed
- at least grayed-out, to indicate a pending action is not complete.
- When job that flips, rotates, deletes completes then lets update the query details (e.g. remove eids, or remove the ammendments)
- this actually is quite an improvement, if someone is deleting 2 as per above, I will see that as a pending change in my unrelated query, ditto flips, etc.
### GENERAL ### GENERAL
* jobs for AI should show path name * jobs for AI should show path name
* rm dups job should show progress bar * rm dups job should show progress bar
* in viewer, there is no move button (maybe add one?) * in viewer, there is no move button (maybe add one?)
* think I killed pa_job_manager without passing an eid to a transform job, shouldn't crash
- SHOULD JUST get AI to help clean-up and write defensive code here...
* consider doing duplicates before AI, and if there are say 100s+, then maybe pause the AI work * consider doing duplicates before AI, and if there are say 100s+, then maybe pause the AI work
- had 5000+ new photos, took 8 hours to finish, for me to just delete them anyway - had 5000+ new photos, took 8 hours to finish, for me to just delete them anyway
* consider how to better version jscript - across all html files, consistently
- mtime, didnt work anyway, my phone still wont pick up the change, it was adding any ?v= changed this (once)
* optimisation: * optimisation:
- keep track of just new files since scan (even if we did this from the DB), - keep track of just new files since scan (even if we did this from the DB),
then we could just feed those eid's explicitly into a 'get_file_details_on_new_files'. then we could just feed those eid's explicitly into a 'get_file_details_on_new_files'.
@@ -32,9 +18,7 @@
(is there a library for this???) (is there a library for this???)
* sqlalchemy 2 migration: * sqlalchemy 2 migration:
* fix unmapped (in fact make all the code properly sqlachemy 2.0 compliant) - get AI to help
-- path.py has the __allow_unmapped__ = True
* remove all '.execute' from *.py
* allow actions for wrong person: * allow actions for wrong person:
-> someone else? OR override no match for this person ever for this image? -> someone else? OR override no match for this person ever for this image?
@@ -63,10 +47,6 @@
- rename (does this work already somehow? see issue below) - rename (does this work already somehow? see issue below)
- dont allow me to stupidly move a folder to itself - dont allow me to stupidly move a folder to itself
* browser back/forward buttons dont work -- use POST -> redirect to GET
- need some sort of clean-up of pa_user_state -- I spose its triggered by browser session, so maybe just after a week is lazy/good enough
- pa_user_state has last_used as a timestamp so can be used to delete old entries
* back button will fail if we do these POSTs: * back button will fail if we do these POSTs:
job.py:@app.route("/jobs", methods=["GET", "POST"]) job.py:@app.route("/jobs", methods=["GET", "POST"])
job.py:@app.route("/job/<id>", methods=["GET","POST"]) job.py:@app.route("/job/<id>", methods=["GET","POST"])
@@ -74,8 +54,8 @@
* if on jobs page and jobs increase, then 'rebuild' the content of the page to show new jobs, and potentially do that every 5 seconds... * if on jobs page and jobs increase, then 'rebuild' the content of the page to show new jobs, and potentially do that every 5 seconds...
- THINK: could also 'refresh' /job/id via Ajax not a reload, to avoid the POST issue above needing to remember job prefs somewhere? - THINK: could also 'refresh' /job/id via Ajax not a reload, to avoid the POST issue above needing to remember job prefs somewhere?
files.py:@app.route("/fix_dups", methods=["POST"]) * files.py:@app.route("/fix_dups", methods=["POST"])
??? - ???
* GUI overhaul? * GUI overhaul?
* on a phone, the files.html page header is a mess "Oldest.." line is too large to fit on 1 line (make it a hamburger?) * on a phone, the files.html page header is a mess "Oldest.." line is too large to fit on 1 line (make it a hamburger?)
@@ -110,7 +90,6 @@
* revisit SymlinkName() and make it simpler (see comment in shared.py) * revisit SymlinkName() and make it simpler (see comment in shared.py)
*** Need to use thread-safe sessions per Thread, half-assed version did not work *** Need to use thread-safe sessions per Thread, half-assed version did not work
Admin Admin
-> do I want to have admin roles/users? -> do I want to have admin roles/users?
-> purge deleted files (and associated DB data) needs a dbox or privs -> purge deleted files (and associated DB data) needs a dbox or privs

65
amend.py Normal file
View File

@@ -0,0 +1,65 @@
from sqlalchemy import select
from flask import request, jsonify
from flask_login import login_required
from shared import PA
from main import db, app
################################################################################
# Amendments are used to define types of changes being made to an entry (e.g.
# rotate, flip) should contain relatively transient content (e.g. we might be
# processing a long-running job now, and then add a rotate, the rotate wont
# finish for minutes, so these classes allow the UI to handle that gracefully
################################################################################
################################################################################
# Class describing AmendmentType in the DB (via sqlalchemy)
################################################################################
class AmendmentType(PA,db.Model):
__tablename__ = "amendment_type"
id = db.Column(db.Integer, db.Sequence('file_type_id_seq'), primary_key=True )
job_name = db.Column(db.String, nullable=False )
which = db.Column(db.String, nullable=False )
what = db.Column(db.String, nullable=False )
colour = db.Column(db.String, nullable=False )
################################################################################
# Class describing which Entry has a pending Amendment in the DB (via sqlalchemy)
################################################################################
class EntryAmendment(PA,db.Model):
__tablename__ = "entry_amendment"
eid = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True )
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")
################################################################################
# check if this job is something we need to log an EntryAmendment for, based on
# job name and potentially amt in extras, to find the type of amendment
################################################################################
def inAmendmentTypes(job):
if not hasattr(job, 'extra' ) or not job.extra:
return None
amt=None
for jex in job.extra:
if jex.name == "amt":
amt=jex.value
# FIXME: should just cache this once per build, only would change with code updates
for at in getAmendments():
# for transform_image, amt=flip*, 90/180/270 - so amt will be set, use it, otherwise just use job.name
if (amt and f"{job.name}:{amt}" == at.job_name) or (at.job_name == job.name):
return at.id
return None
################################################################################
# Class describing which Entry has a pending Amendment in the DB (via sqlalchemy)
################################################################################
def getAmendments():
# get Amend types (get EAT data once - used in inAmendmentTypes()
stmt=select(AmendmentType)
eat=db.session.execute(stmt).scalars().all()
return eat

172
files.py
View File

@@ -1,10 +1,11 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask import request, render_template, redirect, send_from_directory, url_for, jsonify, make_response from flask import request, render_template, redirect, send_from_directory, url_for, jsonify
from marshmallow import Schema, fields from marshmallow import Schema, fields
from main import db, app, ma from main import db, app, ma
from sqlalchemy import Sequence, text, select, union, or_ from sqlalchemy import Sequence, text, select, union, or_
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
import numbers
import os import os
import glob import glob
import json import json
@@ -22,6 +23,7 @@ import pytz
import html import html
from flask_login import login_required, current_user from flask_login import login_required, current_user
from types import SimpleNamespace from types import SimpleNamespace
from amend import EntryAmendment, AmendmentType
# Local Class imports # Local Class imports
################################################################################ ################################################################################
@@ -31,8 +33,9 @@ from job import Job, JobExtra, Joblog, NewJob, SetFELog
from path import PathType, Path from path import PathType, Path
from person import Refimg, Person, PersonRefimgLink from person import Refimg, Person, PersonRefimgLink
from settings import Settings, SettingsIPath, SettingsSPath, SettingsRBPath from settings import Settings, SettingsIPath, SettingsSPath, SettingsRBPath
from shared import SymlinkName, ICON from shared import SymlinkName, ICON, PA
from dups import Duplicates from dups import Duplicates
from amend import getAmendments
from face import Face, FaceFileLink, FaceRefimgLink, FaceOverrideType, FaceNoMatchOverride, FaceForceMatchOverride from face import Face, FaceFileLink, FaceRefimgLink, FaceOverrideType, FaceNoMatchOverride, FaceForceMatchOverride
# pylint: disable=no-member # pylint: disable=no-member
@@ -41,41 +44,32 @@ from face import Face, FaceFileLink, FaceRefimgLink, FaceOverrideType, FaceNoMat
# Class describing PathDirLink and in the DB (via sqlalchemy) # Class describing PathDirLink and in the DB (via sqlalchemy)
# connects the entry (dir) with a path # connects the entry (dir) with a path
################################################################################ ################################################################################
class PathDirLink(db.Model): class PathDirLink(PA,db.Model):
__tablename__ = "path_dir_link" __tablename__ = "path_dir_link"
path_id = db.Column(db.Integer, db.ForeignKey("path.id"), primary_key=True ) path_id = db.Column(db.Integer, db.ForeignKey("path.id"), primary_key=True )
dir_eid = db.Column(db.Integer, db.ForeignKey("dir.eid"), primary_key=True ) dir_eid = db.Column(db.Integer, db.ForeignKey("dir.eid"), primary_key=True )
def __repr__(self):
return f"<path_id: {self.path_id}, dir_eid: {self.dir_eid}>"
################################################################################ ################################################################################
# Class describing EntryDirLInk and in the DB (via sqlalchemy) # Class describing EntryDirLInk and in the DB (via sqlalchemy)
# connects (many) entry contained in a directory (which is also an entry) # connects (many) entry contained in a directory (which is also an entry)
################################################################################ ################################################################################
class EntryDirLink(db.Model): class EntryDirLink(PA,db.Model):
__tablename__ = "entry_dir_link" __tablename__ = "entry_dir_link"
entry_id = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True ) entry_id = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True )
dir_eid = db.Column(db.Integer, db.ForeignKey("dir.eid"), primary_key=True ) dir_eid = db.Column(db.Integer, db.ForeignKey("dir.eid"), primary_key=True )
def __repr__(self):
return f"<entry_id: {self.entry_id}, dir_eid: {self.dir_eid}>"
################################################################################ ################################################################################
# Class describing Dir and in the DB (via sqlalchemy) # Class describing Dir and in the DB (via sqlalchemy)
# rel_path: rest of dir after path, e.g. if path = /..../storage, then # rel_path: rest of dir after path, e.g. if path = /..../storage, then
# rel_path could be 2021/20210101-new-years-day-pics # rel_path could be 2021/20210101-new-years-day-pics
# in_path: only in this structure, not DB, quick ref to the path this dir is in # in_path: only in this structure, not DB, quick ref to the path this dir is in
################################################################################ ################################################################################
class Dir(db.Model): class Dir(PA,db.Model):
__tablename__ = "dir" __tablename__ = "dir"
eid = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True ) eid = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True )
rel_path = db.Column(db.String, unique=True ) rel_path = db.Column(db.String, unique=True )
in_path = db.relationship("Path", secondary="path_dir_link", uselist=False) in_path = db.relationship("Path", secondary="path_dir_link", uselist=False)
def __repr__(self):
return f"<eid: {self.eid}, rel_path: {self.rel_path}, in_path: {self.in_path}>"
################################################################################ ################################################################################
# Class describing Entry and in the DB (via sqlalchemy) # Class describing Entry and in the DB (via sqlalchemy)
# an entry is the common bits between files and dirs # an entry is the common bits between files and dirs
@@ -85,7 +79,7 @@ class Dir(db.Model):
# in_dir - is the Dir that this entry is located in (convenience for class only) # in_dir - is the Dir that this entry is located in (convenience for class only)
# FullPathOnFS(): method to get path on the FS for this Entry # FullPathOnFS(): method to get path on the FS for this Entry
################################################################################ ################################################################################
class Entry(db.Model): class Entry(PA,db.Model):
__tablename__ = "entry" __tablename__ = "entry"
id = db.Column(db.Integer, db.Sequence('file_id_seq'), primary_key=True ) id = db.Column(db.Integer, db.Sequence('file_id_seq'), primary_key=True )
name = db.Column(db.String, unique=False, nullable=False ) name = db.Column(db.String, unique=False, nullable=False )
@@ -106,9 +100,6 @@ class Entry(db.Model):
s=self.dir_details.in_path.path_prefix s=self.dir_details.in_path.path_prefix
return s return s
def __repr__(self):
return f"<id: {self.id}, name: {self.name}, type={self.type}, dir_details={self.dir_details}, file_details={self.file_details}, in_dir={self.in_dir}"
################################################################################ ################################################################################
# Class describing File and in the DB (via sqlalchemy) # Class describing File and in the DB (via sqlalchemy)
# all files are entries, this is the extra bits only for a file, of note: # all files are entries, this is the extra bits only for a file, of note:
@@ -117,7 +108,7 @@ class Entry(db.Model):
# info can be from exif, or file system, or file name (rarely) # info can be from exif, or file system, or file name (rarely)
# faces: convenience field to show connected face(s) for this file # faces: convenience field to show connected face(s) for this file
################################################################################ ################################################################################
class File(db.Model): class File(PA,db.Model):
__tablename__ = "file" __tablename__ = "file"
eid = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True ) eid = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True )
size_mb = db.Column(db.Integer, unique=False, nullable=False) size_mb = db.Column(db.Integer, unique=False, nullable=False)
@@ -129,22 +120,15 @@ class File(db.Model):
woy = db.Column(db.Integer) woy = db.Column(db.Integer)
faces = db.relationship ("Face", secondary="face_file_link" ) faces = db.relationship ("Face", secondary="face_file_link" )
def __repr__(self):
return f"<eid: {self.eid}, size_mb={self.size_mb}, hash={self.hash}, year={self.year}, month={self.month}, day={self.day}, woy={self.woy}, faces={self.faces}>"
################################################################################ ################################################################################
# Class describing FileType and in the DB (via sqlalchemy) # Class describing FileType and in the DB (via sqlalchemy)
# pre-defined list of file types (image, dir, etc.) # pre-defined list of file types (image, dir, etc.)
################################################################################ ################################################################################
class FileType(db.Model): class FileType(PA,db.Model):
__tablename__ = "file_type" __tablename__ = "file_type"
id = db.Column(db.Integer, db.Sequence('file_type_id_seq'), primary_key=True ) id = db.Column(db.Integer, db.Sequence('file_type_id_seq'), primary_key=True )
name = db.Column(db.String, unique=True, nullable=False ) name = db.Column(db.String, unique=True, nullable=False )
def __repr__(self):
return f"<id: {self.id}, name={self.name}>"
################################################################################ ################################################################################
# this is how we order all queries based on value of 'noo' - used with # this is how we order all queries based on value of 'noo' - used with
# access *order_map.get(OPT.noo) # access *order_map.get(OPT.noo)
@@ -249,6 +233,19 @@ class FileSchema(ma.SQLAlchemyAutoSchema):
load_instance = True load_instance = True
faces = ma.Nested(FaceSchema,many=True,allow_none=True) faces = ma.Nested(FaceSchema,many=True,allow_none=True)
class AmendmentTypeSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = AmendmentType
load_instance = True
class EntryAmendmentSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = EntryAmendment
load_instance = True
eid = ma.auto_field()
job_id = ma.auto_field()
type = ma.Nested(AmendmentTypeSchema)
################################################################################ ################################################################################
# Schema for Entry so we can json for data to the client # Schema for Entry so we can json for data to the client
################################################################################ ################################################################################
@@ -270,11 +267,31 @@ class EntrySchema(ma.SQLAlchemyAutoSchema):
def get_full_path(self, obj): def get_full_path(self, obj):
return obj.FullPathOnFS() 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 # global - this will be use more than once below, so do it once for efficiency
entries_schema = EntrySchema(many=True) entries_schema = EntrySchema(many=True)
FOT_Schema = FaceOverrideTypeSchema(many=True) FOT_Schema = FaceOverrideTypeSchema(many=True)
path_Schema = PathSchema(many=True) path_Schema = PathSchema(many=True)
person_Schema = PersonSchema(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 # /get_entries_by_ids -> route where we supply list of entry ids (for next/prev
@@ -309,7 +326,12 @@ def process_ids():
# Sort the entries according to the order of ids # Sort the entries according to the order of ids
sorted_data = [entry_map[id_] for id_ in ids if id_ in entry_map] sorted_data = [entry_map[id_] for id_ in ids if id_ in entry_map]
return jsonify(entries_schema.dump(sorted_data)) # get any pending entry amendments
stmt = select(EntryAmendment).join(AmendmentType)
ea = db.session.execute(stmt).unique().scalars().all()
ea_data=ea_schema.dump(ea)
return jsonify(entries=entries_schema.dump(sorted_data), amendments=ea_data)
################################################################################ ################################################################################
@@ -328,21 +350,21 @@ def get_dir_entries():
# if we are going back, find the parent id and use that instead # if we are going back, find the parent id and use that instead
if back: if back:
# get parent of this dir, to go back # get parent of this dir, to go back
stmt=( select(EntryDirLink.dir_eid).filter(EntryDirLink.entry_id==dir_id) ) stmt=select(EntryDirLink.dir_eid).filter(EntryDirLink.entry_id==dir_id)
dir_id = db.session.execute(stmt).scalars().one_or_none() dir_id = db.session.execute(stmt).scalars().one_or_none()
if not dir_id: if not dir_id:
# return valid as false, we need to let user know this is not an empty dir, it does not exist # return valid as false, we need to let user know this is not an empty dir, it does not exist
return jsonify( valid=False, entry_list=[] ) return jsonify( valid=False, entry_list=[] )
# Just double-check this is still in the DB, in case it got deleted since client made view # Just double-check this is still in the DB, in case it got deleted since client made view
stmt=( select(Entry.id).where(Entry.id==dir_id) ) stmt=select(Entry.id).where(Entry.id==dir_id)
ent_id = db.session.execute(stmt).scalars().one_or_none() ent_id = db.session.execute(stmt).scalars().one_or_none()
if not ent_id: if not ent_id:
# return valid as false, we need to let user know this is not an empty dir, it does not exist # return valid as false, we need to let user know this is not an empty dir, it does not exist
return jsonify( valid=False, entry_list=[] ) return jsonify( valid=False, entry_list=[] )
# get content of dir_id # get content of dir_id
stmt=( select(Entry.id).join(EntryDirLink).filter(EntryDirLink.dir_eid==dir_id) ) stmt=select(Entry.id).join(EntryDirLink).filter(EntryDirLink.dir_eid==dir_id)
stmt=stmt.order_by(*order_map.get(noo) ) stmt=stmt.order_by(*order_map.get(noo) )
return jsonify( valid=True, entry_list=db.session.execute(stmt).scalars().all() ) return jsonify( valid=True, entry_list=db.session.execute(stmt).scalars().all() )
@@ -365,22 +387,26 @@ def getPeople():
people=db.session.execute(stmt).scalars().all() people=db.session.execute(stmt).scalars().all()
return person_Schema.dump(people) return person_Schema.dump(people)
def initQueryData():
################################################################################
# Get all relevant Entry.ids based on search_term passed in and OPT visuals
################################################################################
def GetSearchQueryData(OPT):
query_data={} query_data={}
query_data['entry_list']=None query_data['entry_list']=None
query_data['root_eid']=0 query_data['root_eid']=0
query_data['NMO'] = getFOT() query_data['NMO'] = getFOT()
query_data['move_paths'] = getMoveDetails() query_data['move_paths'] = getMoveDetails()
query_data['people'] = getPeople() query_data['people'] = getPeople()
query_data['amendTypes'] = et_schema.dump( getAmendments() )
return query_data
################################################################################
# Get all relevant Entry.ids based on search_term passed in and OPT visuals
################################################################################
def GetSearchQueryData(OPT):
query_data=initQueryData()
search_term = OPT.search_term search_term = OPT.search_term
# turn * wildcard into sql wildcard of % # turn * wildcard into sql wildcard of %
search_term = search_term.replace('*', '%') search_term = search_term.replace('*', '%')
if 'AI:' in search_term: if 'AI:' in OPT.search_term:
search_term = search_term.replace('AI:', '') 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 searches are for specific ppl/joins in the DB AND we do them for ALL types of searches, define this once
@@ -391,7 +417,7 @@ def GetSearchQueryData(OPT):
.order_by(*order_map.get(OPT.noo) ) .order_by(*order_map.get(OPT.noo) )
) )
if 'AI:' in search_term: if 'AI:' in OPT.search_term:
all_entries = db.session.execute(ai_query).scalars().all() all_entries = db.session.execute(ai_query).scalars().all()
else: else:
# match name of File # match name of File
@@ -399,9 +425,11 @@ def GetSearchQueryData(OPT):
# match name of Dir # 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)) 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))
ai_entries = db.session.execute(ai_query).scalars().all()
file_entries = db.session.execute(file_query).scalars().all()
dir_entries = db.session.execute(dir_query).scalars().all()
# Combine ai, file & dir matches with union() to dedup and then order them # Combine ai, file & dir matches with union() to dedup and then order them
combined_query = union( file_query, dir_query, ai_query ) all_entries = list(dict.fromkeys(ai_entries + dir_entries + file_entries))
all_entries = db.session.execute(combined_query).scalars().all()
query_data['entry_list']=all_entries query_data['entry_list']=all_entries
return query_data return query_data
@@ -410,11 +438,7 @@ def GetSearchQueryData(OPT):
# Get all relevant Entry.ids based on files_ip/files_sp/files_rbp and OPT visuals # Get all relevant Entry.ids based on files_ip/files_sp/files_rbp and OPT visuals
################################################################################# #################################################################################
def GetQueryData( OPT ): def GetQueryData( OPT ):
query_data={} query_data=initQueryData()
query_data['entry_list']=None
query_data['NMO'] = getFOT()
query_data['move_paths'] = getMoveDetails()
query_data['people'] = getPeople()
# always get the top of the (OPT.prefix) Path's eid and keep it for OPT.folders toggling/use # always get the top of the (OPT.prefix) Path's eid and keep it for OPT.folders toggling/use
dir_stmt=( dir_stmt=(
@@ -433,7 +457,7 @@ def GetQueryData( OPT ):
if OPT.folders: if OPT.folders:
# start folder view with only the root folder # start folder view with only the root folder
stmt=( select(Entry.id).join(EntryDirLink).filter(EntryDirLink.dir_eid==dir_id) ) stmt=select(Entry.id).join(EntryDirLink).filter(EntryDirLink.dir_eid==dir_id)
else: else:
# get every File that is in the OPT.prefix Path # get every File that is in the OPT.prefix Path
stmt=( stmt=(
@@ -461,8 +485,11 @@ def change_file_opts():
else: else:
OPT.folders=False OPT.folders=False
# so create a new entryList, and handle that on the client # so create a new entryList, and handle that on the client
query_data = GetQueryData( OPT ) if 'search' in request.referrer:
return make_response( jsonify( query_data=query_data ) ) query_data = GetSearchQueryData( OPT )
else:
query_data = GetQueryData( OPT )
return jsonify( query_data=query_data )
################################################################################ ################################################################################
@@ -621,7 +648,7 @@ def restore_files():
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) ) 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)" ) 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 # /delete_files -> create a job to delete files for the b/e to process
@@ -634,7 +661,7 @@ def delete_files():
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) ) 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)" ) 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 # /move_files -> create a job to move files for the b/e to process
@@ -647,8 +674,7 @@ def move_files():
for el in request.form: for el in request.form:
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) ) 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)" ) 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=job_schema.dump(job) )
return make_response( jsonify( job_id=job.id ) )
@login_required @login_required
@app.route("/view/", methods=["POST"]) @app.route("/view/", methods=["POST"])
@@ -677,7 +703,7 @@ def view():
# route called from front/end - if multiple images are being transformed, each transorm == a separate call # 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 # 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 # with a spinning wheel, then when pa_job_mgr has finished it will return the transformed thumb
@app.route("/transform", methods=["POST"]) @app.route("/transform", methods=["POST"])
@login_required @login_required
@@ -690,25 +716,29 @@ def transform():
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) ) 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)" ) job=NewJob( name="transform_image", num_files=0, wait_for=None, jex=jex, desc="to transform selected file(s)" )
return make_response( 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 # /check_amend_job_status -> URL that is called repeatedly by front-end waiting
# b/e to finish the transform job. Once done, the new / now # for the b/e to finish the amendment job (delete/restore/move file).
# transformed image's thumbnail is returned so the f/e can # Once done, return "ok"
# update with it
################################################################################ ################################################################################
@app.route("/check_transform_job", methods=["POST"]) @app.route("/check_amend_job_status", methods=["POST"])
@login_required @login_required
def check_transform_job(): def check_amend_job_status():
job_id = request.form['job_id'] job_id = request.form['job_id']
job = Job.query.get(job_id) stmt = select(Job).options(joinedload(Job.amendments)).where(Job.id == job_id)
j=jsonify( finished=False ) job=db.session.execute(stmt).unique().scalars().first()
if job.pa_job_state == 'Completed': # FIXME: should validate job_id is real from UI
id=[jex.value for jex in job.extra if jex.name == "id"][0] if job.name == 'transform_image':
e=Entry.query.join(File).filter(Entry.id==id).first() eid=[jex.value for jex in job.extra if jex.name == "id"][0]
j=jsonify( finished=True, thumbnail=e.file_details.thumbnail ) stmt=select(Entry).where(Entry.id==eid)
return make_response( j ) ent=db.session.execute(stmt).scalars().all()
ent_data=entries_schema.dump(ent)
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
################################################################################ ################################################################################
# /include -> return contents on /include and does not need a login, so we # /include -> return contents on /include and does not need a login, so we
@@ -754,7 +784,7 @@ def get_existing_paths(dt):
except: except:
# this is not a date, so we cant work out possible dirs, just # this is not a date, so we cant work out possible dirs, just
# return an empty set # return an empty set
return make_response( '[]' ) return jsonify( '[]' )
new_dt=new_dtime.strftime('%Y%m%d') new_dt=new_dtime.strftime('%Y%m%d')
# find dirs named with this date # find dirs named with this date
dirs_arr+=Dir.query.filter(Dir.rel_path.ilike('%'+new_dt+'%')).all(); dirs_arr+=Dir.query.filter(Dir.rel_path.ilike('%'+new_dt+'%')).all();
@@ -768,8 +798,8 @@ def get_existing_paths(dt):
ret='[ ' ret='[ '
first_dir=1 first_dir=1
for dir in dirs: for dir in dirs:
# this can occur if there is a file with this date name in the top-levle of the path, its legit, but only really happens in DEV # this can occur if there is a file with this date name in the top-level of the path, its legit, but only really happens in DEV
# regardless, it cant be used for a existpath button in the F/E, ignore it # regardless, it cant be used for a existing path button in the F/E, ignore it
if dir.rel_path == '': if dir.rel_path == '':
continue continue
if not first_dir: if not first_dir:
@@ -787,7 +817,7 @@ def get_existing_paths(dt):
ret+= ' } ' ret+= ' } '
first_dir=0 first_dir=0
ret+= ' ]' ret+= ' ]'
return make_response( ret ) return jsonify ( ret )
# quick helper func to return timestamps of jscript files # quick helper func to return timestamps of jscript files
# we use this as a quick/hacky way of versioning them # we use this as a quick/hacky way of versioning them

View File

@@ -161,13 +161,13 @@
c4.142,0,7.5-3.357,7.5-7.5S339.642,328,335.5,328z"/> c4.142,0,7.5-3.357,7.5-7.5S339.642,328,335.5,328z"/>
<g style="fill:#00000025;" transform="matrix(16, 0, 0, 16, 120, 115)"><path d="M4.502 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/><path d="M14.002 13a2 2 0 0 1-2 2h-10a2 2 0 0 1-2-2V5A2 2 0 0 1 2 3a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v8a2 2 0 0 1-1.998 2zM14 2H4a1 1 0 0 0-1 1h9.002a2 2 0 0 1 2 2v7A1 1 0 0 0 15 11V3a1 1 0 0 0-1-1zM2.002 4a1 1 0 0 0-1 1v8l2.646-2.354a.5.5 0 0 1 .63-.062l2.66 1.773 3.71-3.71a.5.5 0 0 1 .577-.094l1.777 1.947V5a1 1 0 0 0-1-1h-10z"/></g> <g style="fill:#00000025;" transform="matrix(16, 0, 0, 16, 120, 115)"><path d="M4.502 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z"/><path d="M14.002 13a2 2 0 0 1-2 2h-10a2 2 0 0 1-2-2V5A2 2 0 0 1 2 3a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v8a2 2 0 0 1-1.998 2zM14 2H4a1 1 0 0 0-1 1h9.002a2 2 0 0 1 2 2v7A1 1 0 0 0 15 11V3a1 1 0 0 0-1-1zM2.002 4a1 1 0 0 0-1 1v8l2.646-2.354a.5.5 0 0 1 .63-.062l2.66 1.773 3.71-3.71a.5.5 0 0 1 .577-.094l1.777 1.947V5a1 1 0 0 0-1-1h-10z"/></g>
</svg> </svg>
<svg id="flip_h" fill="currentColor" viewBox='0 0 512 512'> <svg id="flip_h" viewBox='0 0 512 512'>
<path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='32' d='M304 48l112 112-112 112M398.87 160H96M208 464L96 352l112-112M114 352h302'/> <path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='32' d='M304 48l112 112-112 112M398.87 160H96M208 464L96 352l112-112M114 352h302'/>
</svg> </svg>
<svg id="flip_v" fill="currentColor" viewBox='0 0 512 512'> <svg id="flip_v" viewBox='0 0 512 512'>
<path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='32' d='M464 208L352 96 240 208M352 113.13V416M48 304l112 112 112-112M160 398V96'/> <path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='32' d='M464 208L352 96 240 208M352 113.13V416M48 304l112 112 112-112M160 398V96'/>
</svg> </svg>
<svg id="fullscreen" fill="currentColor" viewBox="0 0 16 16"> <svg id="fullscreen" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707zm4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707zm0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707zm-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707z"/> <path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707zm4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707zm0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707zm-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707z"/>
</svg> </svg>
<svg id="unknown_ftype" fill="grey" viewBox="0 0 16 16"> <svg id="unknown_ftype" fill="grey" viewBox="0 0 16 16">

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

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 // 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 // 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') $('#dbox-title').html('Move Selected File(s) to new directory in Storage Path')
div =` div =`
@@ -111,21 +99,21 @@ function MoveDBox(path_details)
<form id="mv_fm" class="form form-control-inline col-12"> <form id="mv_fm" class="form form-control-inline col-12">
<input id="move_path_type" name="move_path_type" type="hidden" <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() div+=GetSelnAsDiv()
yr=$('.highlight').first().attr('yr') yr=$('.highlight').first().attr('yr')
dt=$('.highlight').first().attr('date') dt=$('.highlight').first().attr('date')
div+='<div class="row">Use Existing Directory (in the chosen path):</div><div id="existing"></div>' 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+=`
<div class="input-group my-3"> <div class="input-group my-3">
<alert class="alert alert-primary my-auto py-1"> <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 // 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()">' 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) { for(p of move_paths) {
div+= '<option path_type="'+p.type.name+'" icon_url="'+p.icon_url+'">'+p.root_dir+'</option>' div+= `<option path_type="${p.type.name}" icon_url="${p.icon_url}">${p.root_dir}</option>`
} }
div+= '</select>' div+= '</select>'
div+=` div+=`
@@ -139,11 +127,26 @@ function MoveDBox(path_details)
</div> </div>
<div class="form-row col-12 mt-2"> <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 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) { <button onClick="
if( $(location).attr('pathname').match('search') !== null ) { window.location='/' }; CheckForJobs() } }); return false" class="btn btn-outline-primary col-2">Ok</button> $.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> </div>
</form> </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-content').html(div)
$('#dbox').modal('show') $('#dbox').modal('show')
@@ -151,36 +154,71 @@ function MoveDBox(path_details)
$("#suffix").keypress(function (e) { if (e.which == 13) { $("#move_submit").click(); return false; } } ) $("#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 // 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) function DelDBox(del_or_undel)
{ {
to_del = GetSelnAsData() to_del = GetSelnAsData()
$('#dbox-title').html(del_or_undel+' Selected File(s)') $('#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 ='<div class="row col-12"><p class="col">' + del_or_undel + ' the following files?</p></div>'
div+=GetSelnAsDiv() 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"> div+=`<div class="row col-12 mt-3">
<button onClick="$('#dbox').modal('hide')" class="btn btn-outline-secondary col-2">Cancel</button> <button onClick="$('#dbox').modal('hide')" class="btn btn-outline-secondary col-2">Cancel</button>
` <button onClick="
div+=` $.ajax({ type: 'POST', data: to_del, url: '/${which}_files',
<button onClick="MoveOrDelCleanUpUI(); $.ajax({ type: 'POST', data: to_del, url: success: function(data) {
` processAmendments( data.job.amendments )
if( del_or_undel == "Delete" ) checkForAmendmentJobToComplete(data.job.id)
div+=` }
'/delete_files', });
success: function(data){ $('#dbox').modal('hide')
if( $(location).attr('pathname').match('search') !== null || document.viewing ) { window.location='/' }; CheckForJobs() } }); return false" class="btn btn-outline-danger col-2">Ok</button> return false"
</div> class="btn btn-outline-${col} 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>
`
$('#dbox-content').html(div) $('#dbox-content').html(div)
$('#dbox').modal('show') $('#dbox').modal('show')
} }
@@ -213,44 +251,82 @@ function DetailsDBox()
} }
// DoSel is called when a click event occurs, and sets the selection via adding // DoSel is called when a click event occurs, and sets the selection via adding
// 'highlight' to the class of the appropriate thumbnails // 'highlight' to the class of the appropriate thumbnails
// e == event (can see if shift/ctrl held down while left-clicking // e == event (can see if shift/ctrl held down while left-clicking
// el == element the click is on // el == element the click is on
// this allows single-click to select, ctrl-click to (de)select 1 item, and // this allows single-click to select, ctrl-click to (de)select 1 item, and
// shift-click to add all elements between highlighted area and clicked area, // shift-click to add all elements between highlighted area and clicked el,
// whether you click after highlight or before // whether you click before highlight or after, or inside a gap and then back
function DoSel(e, el) // or forward to the closest higlighted entry - also, only works on entry class,
{ // so it ignores figures that we take entry off while we transform, etc it
if( e.ctrlKey || document.fake_ctrl === 1 ) function DoSel(e, el) {
{ const id = $(el).attr('id');
$(el).toggleClass('highlight') const entries = $('.entry');
if( document.fake_ctrl === 1 )
document.fake_ctrl=0
return
}
if( e.shiftKey || document.fake_shift === 1 )
{
st=Number($('.highlight').first().attr('ecnt'))
end=Number($('.highlight').last().attr('ecnt'))
clicked=Number($(el).attr('ecnt'))
// if we shift-click first element, then st/end are NaN, so just highlightthe one clicked
if( isNaN(st) )
{
$('.entry').slice( clicked, clicked+1 ).addClass('highlight')
return
}
if( clicked > end )
$('.entry').slice( end, clicked+1 ).addClass('highlight')
else
$('.entry').slice( clicked, st ).addClass('highlight')
if( document.fake_shift === 1 ) // Collect currently highlighted entries
document.fake_shift=0 const currentHighlights = $('.highlight');
return const highlighted = new Set();
currentHighlights.each(function() {
highlighted.add($(this).attr('id'));
});
// Ctrl+click: toggle highlight for the clicked entry
if (e.ctrlKey || document.fake_ctrl === 1) {
$(el).toggleClass('highlight');
if (highlighted.has(id)) {
highlighted.delete(id);
} else {
highlighted.add(id);
}
if (document.fake_ctrl === 1) {
document.fake_ctrl = 0;
}
return;
}
// Shift+click: select a range
else if (e.shiftKey || document.fake_shift === 1) {
if (currentHighlights.length === 0) {
// If no highlights, just highlight the clicked entry
$(el).addClass('highlight');
highlighted.add(id);
} else {
// Find the nearest highlighted entry
const clickedIndex = entries.index($(el));
let nearestHighlightIndex = -1;
let minDistance = Infinity;
currentHighlights.each(function() {
const highlightIndex = entries.index($(this));
const distance = Math.abs(highlightIndex - clickedIndex);
if (distance < minDistance) {
minDistance = distance;
nearestHighlightIndex = highlightIndex;
}
});
// Highlight the range between the nearest highlighted entry and the clicked entry
const from = Math.min(clickedIndex, nearestHighlightIndex);
const to = Math.max(clickedIndex, nearestHighlightIndex);
for (let i = from; i <= to; i++) {
const entryId = entries.eq(i).attr('id');
highlighted.add(entryId);
entries.eq(i).addClass('highlight');
}
}
if (document.fake_shift === 1) {
document.fake_shift = 0;
}
return;
}
// Single click: clear all highlights and highlight the clicked entry
else {
$('.highlight').removeClass('highlight');
highlighted.clear();
$(el).addClass('highlight');
highlighted.add(id);
} }
$('.highlight').removeClass('highlight')
$(el).addClass('highlight')
} }
// if a selection exists, enable move & del/restore buttons otherwise disable them // if a selection exists, enable move & del/restore buttons otherwise disable them
@@ -316,54 +392,61 @@ function NoSel() {
return true return true
} }
// quick wrapper to add a single <figure> to the #figures div
function addFigure( obj )
{
html=createFigureHtml( obj )
$('#figures').append( html )
}
/** /**
* Renders a group header or entry based on the object and options. * Renders a group header or entry based on the object and options.
* obj - The object containing file/directory details. * obj - The object containing file/directory details.
* last - Tracks the last printed group (e.g., { printed: null }).
* ecnt - Entry counter (e.g., { val: 0 }).
* returns {string} - Generated HTML string. * returns {string} - Generated HTML string.
*/ */
function addFigure( obj, last, ecnt) function createFigureHtml( obj )
{ {
let html = ""; // if am is null, no amendment for this obj, otherwise we have one
var am=null
for (const tmp of document.amendments)
if( tmp.eid == obj.id )
am=tmp
// Grouping logic let html = "";
if (OPT.grouping === "Day") {
if (last.printed !== obj.file_details.day) {
html += `<div class="row ps-3"><h6>Day: ${obj.file_details.day} of ${obj.file_details.month}/${obj.file_details.year}</h6></div>`;
last.printed = obj.file_details.day;
}
} else if (OPT.grouping === "Week") {
if (last.printed !== obj.file_details.woy) {
html += `<div class="row ps-3"><h6>Week #: ${obj.file_details.woy} of ${obj.file_details.year}</h6></div>`;
last.printed = obj.file_details.woy;
}
} else if (OPT.grouping === "Month") {
if (last.printed !== obj.file_details.month) {
html += `<div class="row ps-3"><h6>Month: ${obj.file_details.month} of ${obj.file_details.year}</h6></div>`;
last.printed = obj.file_details.month;
}
}
// Image/Video/Unknown entry // Image/Video/Unknown entry
if (obj.type.name === "Image" || obj.type.name === "Video" || obj.type.name === "Unknown") { if (obj.type.name === "Image" || obj.type.name === "Video" || obj.type.name === "Unknown") {
const pathType = obj.in_dir.in_path.type.name; const pathType = obj.in_dir.in_path.type.name;
const size = obj.file_details.size_mb; const size = obj.file_details.size_mb;
const hash = obj.file_details.hash; const hash = obj.file_details.hash;
const inDir = `${obj.in_dir.in_path.path_prefix}/${obj.in_dir.rel_path}`; const inDir = `${obj.in_dir.in_path.path_prefix}/${obj.in_dir.rel_path}`;
const fname = obj.name; const fname = obj.name;
const yr = obj.file_details.year; const yr = obj.file_details.year;
const date = `${yr}${String(obj.file_details.month).padStart(2, '0')}${String(obj.file_details.day).padStart(2, '0')}`; const date = `${yr}${String(obj.file_details.month).padStart(2, '0')}${String(obj.file_details.day).padStart(2, '0')}`;
const prettyDate = `${obj.file_details.day}/${obj.file_details.month}/${obj.file_details.year}`; const prettyDate = `${obj.file_details.day}/${obj.file_details.month}/${obj.file_details.year}`;
const type = obj.type.name; const type = obj.type.name;
html += ` // if amendment for this obj, do not add entry class - prevents highlighting
<figure id="${obj.id}" ecnt="${ecnt}" class="col col-auto g-0 figure entry m-1" if( am ) {
path_type="${pathType}" size="${size}" hash="${hash}" in_dir="${inDir}" ent=""
fname="${fname}" yr="${yr}" date="${date}" pretty_date="${prettyDate}" type="${type}"> gs="style='filter: grayscale(100%);'"
${renderMedia(obj)} am_html ='<img class="position-absolute top-50 start-50 translate-middle" height="60" src="/internal/white-circle.png">'
</figure> 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
am_html+=`<img class="position-absolute top-50 start-50 translate-middle" src="/internal/${am.type.what}?v={{js_vers['r270']}}" height="32">`
} else {
ent="entry"
gs=""
am_html=""
}
html += `
<figure id="${obj.id}" class="col col-auto g-0 figure ${ent} m-1"
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)}
`
} }
// Directory entry // Directory entry
else if (obj.type.name === "Directory" && OPT.folders) { else if (obj.type.name === "Directory" && OPT.folders) {
@@ -372,57 +455,51 @@ function addFigure( obj, last, ecnt)
: obj.dir_details.in_path.path_prefix; : obj.dir_details.in_path.path_prefix;
html += ` html += `
<figure class="col col-auto g-0 dir entry m-1" id="${obj.id}" ecnt="${ecnt}" dir="${dirname}" type="Directory"> <figure class="col col-auto g-0 dir entry m-1" id="${obj.id}" dir="${dirname}" type="Directory">
<svg class="svg" width="${OPT.size - 22}" height="${OPT.size - 22}" fill="currentColor"> <svg class="svg" width="${OPT.size - 22}" height="${OPT.size - 22}" fill="currentColor">
<use xlink:href="/internal/icons.svg#Directory"></use> <use xlink:href="/internal/icons.svg#Directory"></use>
</svg> </svg>
<figcaption class="svg_cap figure-caption text-center text-wrap text-break">${obj.name}</figcaption> <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>`; html += `<script>f=$('#${obj.id}'); w=f.find('svg').width(); f.find('figcaption').width(w);</script>`;
} }
// moved the bindings to here as we need to reset them if we recreate this Figure (after a transform job)
$('#figures').append( html ) html += `<script>
return if( "${obj.type.name}" === "Directory" ) {
$("#${obj.id}").click( function(e) { document.back_id=this.id; getDirEntries(this.id,false) } )
} else {
$('#${obj.id}').click( function(e) { DoSel(e, this ); SetButtonState(); return false; });
$('#${obj.id}').dblclick( function(e) { startViewing( $(this).attr('id') ) } )
}
</script>
</figure>`
return html
} }
// Helper function to render media (image/video/unknown) // Helper function to render media (image/video/unknown)
function renderMedia(obj) { function renderMedia(obj,gs,am_html) {
const isImageOrUnknown = obj.type.name === "Image" || obj.type.name === "Unknown"; const isImageOrUnknown = obj.type.name === "Image" || obj.type.name === "Unknown";
const isVideo = obj.type.name === "Video"; const isVideo = obj.type.name === "Video";
const path = `${obj.in_dir.in_path.path_prefix}/${obj.in_dir.rel_path}/${obj.name}`; const path = `${obj.in_dir.in_path.path_prefix}/${obj.in_dir.rel_path}/${obj.name}`;
const thumb = obj.file_details.thumbnail const thumb = obj.file_details.thumbnail
? `<a href="${path}"><img alt="${obj.name}" class="thumb" height="${OPT.size}" src="data:image/jpeg;base64,${obj.file_details.thumbnail}"></a>` ? `<a href="${path}"><img alt="${obj.name}" ${gs} class="thumb" height="${OPT.size}" src="data:image/jpeg;base64,${obj.file_details.thumbnail}"></a>`
: `<a href="${path}"><svg width="${OPT.size}" height="${OPT.size}" fill="white"><use xlink:href="/internal/icons.svg#unknown_ftype"/></svg></a>`; : `<a href="${path}"><svg width="${OPT.size}" height="${OPT.size}" fill="white"><use xlink:href="/internal/icons.svg#unknown_ftype"/></svg></a>`;
let mediaHtml = `<div style="position:relative; width:100%">${thumb}`; let mediaHtml = `<div style="position:relative; width:100%">${thumb}${am_html}`;
if (isImageOrUnknown) { if (isVideo) {
if (OPT.search_term) {
mediaHtml += `
<div style="position:absolute; bottom: 0px; left: 2px;">
<svg width="16" height="16" fill="white"><use xlink:href="/internal/icons.svg#${getLocationIcon(obj)}"/></svg>
</div>
`;
}
mediaHtml += `
<div id="s${obj.id}" style="display:none; position:absolute; top: 50%; left:50%; transform:translate(-50%, -50%);">
<img height="64px" src="/internal/throbber.gif">
</div>
`;
} else if (isVideo) {
mediaHtml += ` mediaHtml += `
<div style="position:absolute; top: 0px; left: 2px;"> <div style="position:absolute; top: 0px; left: 2px;">
<svg width="16" height="16" fill="white"><use xlink:href="/internal/icons.svg#film"/></svg> <svg width="16" height="16" fill="white"><use xlink:href="/internal/icons.svg#film"/></svg>
</div> </div>
`; `;
if (OPT.search_term) { }
if (OPT.search_term) {
mediaHtml += ` mediaHtml += `
<div style="position:absolute; bottom: 0px; left: 2px;"> <div style="position:absolute; bottom: 0px; left: 2px;">
<svg width="16" height="16" fill="white"><use xlink:href="/internal/icons.svg#${getLocationIcon(obj)}"/></svg> <svg width="16" height="16" fill="white"><use xlink:href="/internal/icons.svg#${getLocationIcon(obj)}"/></svg>
</div> </div>
`; `;
}
} }
mediaHtml += `</div>`; mediaHtml += `</div>`;
@@ -471,7 +548,6 @@ function drawPageOfFigures()
{ {
$('#figures').empty() $('#figures').empty()
var last = { printed: null } var last = { printed: null }
var ecnt=0
// something is up, let the user know // something is up, let the user know
if( document.alert ) if( document.alert )
@@ -501,30 +577,41 @@ function drawPageOfFigures()
// with clas "back" this gets a different click handler which flags server to return data by 'going back/up' in dir tree // with clas "back" this gets a different click handler which flags server to return data by 'going back/up' in dir tree
// we give the server the id of the first item on the page so it can work out how to go back // we give the server the id of the first item on the page so it can work out how to go back
html=`<div class="col col-auto g-0 m-1"> html=`<div class="col col-auto g-0 m-1">
<figure id="${back_id}" ecnt="0" class="${cl} entry m-1" type="Directory"> <figure id="${back_id}" class="${cl} entry m-1" type="Directory">
<svg class="svg" width="${OPT.size-22}" height="${OPT.size-22}"> <svg class="svg" width="${OPT.size-22}" height="${OPT.size-22}">
<use xlink:href="internal/icons.svg#folder_back${gray}"/> <use xlink:href="internal/icons.svg#folder_back${gray}"/>
</svg> </svg>
<figcaption class="figure-caption text-center">${back}</figcaption> <figcaption class="figure-caption text-center">${back}</figcaption>
</figure> </figure>
</div>` </div>`
ecnt++
$('#figures').append(html) $('#figures').append(html)
} }
for (const obj of document.entries) { for (const obj of document.entries) {
addFigure( obj, last, ecnt ) // Grouping logic
ecnt++ if (OPT.grouping === "Day") {
if (last.printed !== obj.file_details.day) {
$('#figures').append(`<div class="row ps-3"><h6>Day: ${obj.file_details.day} of ${obj.file_details.month}/${obj.file_details.year}</h6></div>` );
last.printed = obj.file_details.day;
}
} else if (OPT.grouping === "Week") {
if (last.printed !== obj.file_details.woy) {
$('#figures').append(`<div class="row ps-3"><h6>Week #: ${obj.file_details.woy} of ${obj.file_details.year}</h6></div>` );
last.printed = obj.file_details.woy;
}
} else if (OPT.grouping === "Month") {
if (last.printed !== obj.file_details.month) {
$('#figures').append(`<div class="row ps-3"><h6>Month: ${obj.file_details.month} of ${obj.file_details.year}</h6></div>` );
last.printed = obj.file_details.month;
}
}
addFigure( obj )
} }
$(".back").click( function(e) { getDirEntries(this.id,true) } )
if( document.entries.length == 0 ) if( document.entries.length == 0 )
if( OPT.search_term ) if( OPT.search_term )
$('#figures').append( `<span class="alert alert-danger p-2 col-auto"> No matches for: '${OPT.search_term}'</span>` ) $('#figures').append( `<span class="alert alert-danger p-2 col-auto"> No matches for: '${OPT.search_term}'</span>` )
else if( OPT.root_eid == 0 ) else if( OPT.root_eid == 0 )
$('#figures').append( `<span class="alert alert-danger p-2 col-auto d-flex align-items-center">No files in Path!</span>` ) $('#figures').append( `<span class="alert alert-danger p-2 col-auto d-flex align-items-center">No files in Path!</span>` )
$('.figure').click( function(e) { DoSel(e, this ); SetButtonState(); return false; });
$('.figure').dblclick( function(e) { startViewing( $(this).attr('id') ) } )
// for dir, getDirEntries 2nd param is back (or "up" a dir)
$(".dir").click( function(e) { document.back_id=this.id; getDirEntries(this.id,false) } )
$(".back").click( function(e) { getDirEntries(this.id,true) } )
} }
// emtpy out file_list_div, and repopulate it with new page of content // emtpy out file_list_div, and repopulate it with new page of content
@@ -567,11 +654,12 @@ function getPageFileList(res, viewingIdx)
function getEntriesByIdSuccessHandler(res,pageNumber,successCallback,viewingIdx) function getEntriesByIdSuccessHandler(res,pageNumber,successCallback,viewingIdx)
{ {
if( res.length != pageList.length ) if( res.length != pageList.length )
document.alert="<alert class='alert alert-warning'>WARNING: something in data has changed since viewing this page (likely someone deleted content in another view), strongly suggest a page reload to get the latest data</alert>" document.alert="<alert class='alert alert-warning'>WARNING: something has changed since viewing this page (likely someone deleted content in another view), strongly suggest a page reload to get the latest data</alert>"
document.entries=res; document.entries=res;
// cache this // cache this
document.page[pageNumber]=res 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) successCallback(res,viewingIdx)
resetNextPrevButtons() resetNextPrevButtons()
// if search, disable folders // if search, disable folders
@@ -595,7 +683,7 @@ function getPage(pageNumber, successCallback, viewingIdx=0)
const startIndex = (pageNumber - 1) * OPT.how_many; const startIndex = (pageNumber - 1) * OPT.how_many;
const endIndex = startIndex + OPT.how_many; const endIndex = startIndex + OPT.how_many;
pageList = entryList.slice(startIndex, endIndex); pageList = entryList.slice(startIndex, endIndex);
// set up data to send to server to get the entry data for entries in pageList // set up data to send to server to get the entry data for entries in pageList
data={} data={}
data.ids = pageList data.ids = pageList
@@ -613,7 +701,14 @@ function getPage(pageNumber, successCallback, viewingIdx=0)
type: 'POST', url: '/get_entries_by_ids', type: 'POST', url: '/get_entries_by_ids',
data: JSON.stringify(data), contentType: 'application/json', data: JSON.stringify(data), contentType: 'application/json',
dataType: 'json', dataType: 'json',
success: function(res) { getEntriesByIdSuccessHandler( res, pageNumber, successCallback, viewingIdx ) }, success: function(res) {
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); } }); error: function(xhr, status, error) { console.error("Error:", error); } });
return return
} }
@@ -664,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 // get list of eids for the next page, also make sure next/prev buttons make sense for page we are on
function nextPage(successCallback) 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 // pageList[0] is the first entry on this page
const currentPage=getPageNumberForId( pageList[0] ) const currentPage=getPageNumberForId( pageList[0] )
// should never happen / just return pageList unchanged // should never happen / just return pageList unchanged
@@ -679,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 // get list of eids for the prev page, also make sure next/prev buttons make sense for page we are on
function prevPage(successCallback) 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 // pageList[0] is the first entry on this page
const currentPage=getPageNumberForId( pageList[0] ) const currentPage=getPageNumberForId( pageList[0] )
// should never happen / just return pageList unchanged // should never happen / just return pageList unchanged
@@ -701,6 +804,8 @@ function isMobile() {
// tweak noo menu for folders/flat view then reset the page contents based on current OPT values // tweak noo menu for folders/flat view then reset the page contents based on current OPT values
function changeOPT(successCallback) { function changeOPT(successCallback) {
OPT.how_many=$('#how_many').val() OPT.how_many=$('#how_many').val()
// changes invalidate page cache so clear it out
document.page.length=0
new_f=$('#folders').val() new_f=$('#folders').val()
new_f=( new_f == 'True' ) new_f=( new_f == 'True' )
// if change to/from folders, also fix the noo menu // if change to/from folders, also fix the noo menu
@@ -729,9 +834,10 @@ function changeOPT(successCallback) {
contentType: 'application/json', contentType: 'application/json',
success: function(resp) { success: function(resp) {
entryList=resp.query_data.entry_list entryList=resp.query_data.entry_list
OPT.how_many=parseInt(OPT.how_many)
pageList=entryList.slice(0, OPT.how_many)
// put data back into booleans, ints, etc // put data back into booleans, ints, etc
OPT.folders=( OPT.folders == 'True' ) OPT.folders=( OPT.folders == 'True' )
OPT.how_many=parseInt(OPT.how_many)
$('.how_many_text').html( `&nbsp;${OPT.how_many} files&nbsp;` ) $('.how_many_text').html( `&nbsp;${OPT.how_many} files&nbsp;` )
OPT.size=parseInt(OPT.size) OPT.size=parseInt(OPT.size)
getPage(1,successCallback) getPage(1,successCallback)
@@ -752,11 +858,78 @@ function changeSize()
$('.svg_cap').width(sz); $('.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 // different context menu on files
$.contextMenu({ $.contextMenu({
selector: '.entry', selector: '.entry',
itemClickEvent: "click", itemClickEvent: "click",
build: function($triggerElement, e) { 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 // 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 ) if( NoSel() || e.ctrlKey || e.shiftKey )
{ {
@@ -818,7 +991,7 @@ $.contextMenu({
callback: function( key, options) { callback: function( key, options) {
if( key == "details" ) { DetailsDBox() } if( key == "details" ) { DetailsDBox() }
if( key == "view" ) { startViewing( $(this).attr('id') ) } if( key == "view" ) { startViewing( $(this).attr('id') ) }
if( key == "move" ) { MoveDBox(move_paths) } if( key == "move" ) { MoveDBox() }
if( key == "del" ) { DelDBox('Delete') } if( key == "del" ) { DelDBox('Delete') }
if( key == "undel") { DelDBox('Restore') } if( key == "undel") { DelDBox('Restore') }
if( key == "r90" ) { Transform(90) } if( key == "r90" ) { Transform(90) }

View File

@@ -1,47 +1,31 @@
function handleTransformFiles(data,id,job_id) // This function will remove the matching amendment for this entry (id)
// can only have 1 ammendment per image, its grayed out for other changes
function removeAmendment( id )
{ {
if( data.finished ) document.amendments=document.amendments.filter(obj => obj.eid !== id)
{
$('#s'+id).hide()
$('#'+id).find('img.thumb').attr('style', 'filter: color(100%);' );
$('#'+id).addClass('entry')
$('#'+id).find('.thumb').attr('src', 'data:image/jpeg;base64,'+data.thumbnail)
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 Transform job has finished then reset relevant document.entries
// if not, try again in 1 second... If it has finished then reset the thumbnail // with updated from DB, remove the amendment and redraw image
// to full colour, put it back to being an entry and reset the thumbnail to the function handleTransformImageJobCompleted(job, entry)
// newly created one that was sent back in the response to the POST
function handleTransformViewing(data,id,job_id)
{ {
if( data.finished ) removeAmendment( entry.id )
// update viewer if we are viewing an image
if( document.viewing )
{ {
// stop throbber, remove grayscale & then force reload with timestamped version of im.src // force reload with timestamped version of im.src
grayscale=0
throbber=0
im.src=im.src + '?t=' + new Date().getTime(); im.src=im.src + '?t=' + new Date().getTime();
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, // ALWAYS update files* div as we could go back to this from a viewer, and
// if not, try again in 1 second... If it has finished then reset the thumbnail // the thumbnail needs the updated data
// to full colour, put it back to being an entry and reset the thumbnail to the idx = entryList.indexOf(entry.id)
// newly created one that was sent back in the response to the POST // replace data for this entry now its been transformed
function CheckTransformJob(id,job_id,successCallback) document.entries[idx]=entry
{ // redraw into figure html in dom
CheckForJobs() html = createFigureHtml( entry )
$.ajax( { type: 'POST', data: '&job_id='+job_id, url: '/check_transform_job', success: function(res) { successCallback(res,id,job_id); } } ) $('#'+entry.id).replaceWith( html )
} }
// for each highlighted image, POST the transform with amt (90, 180, 270, // for each highlighted image, POST the transform with amt (90, 180, 270,
@@ -52,19 +36,24 @@ function CheckTransformJob(id,job_id,successCallback)
function Transform(amt) function Transform(amt)
{ {
// we are in the viewer with 1 image only... // 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_data = '&amt='+amt+'&id='+document.viewing.id
// send /transform for this image, grayscale the thumbmail, add color spinning wheel overlay, and start checking for job end // 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) { grayscale=1; throbber=1; DrawImg(); CheckTransformJob(document.viewing.id,data.job_id,handleTransformViewing); return false; } }) $.ajax({ type: 'POST', data: post_data, url: '/transform', success: function(data) {
processAmendments(data.job.amendments)
checkForAmendmentJobToComplete(data.job.id)
} })
} }
else else
{ {
$('.highlight').each(function( id, e ) { $('.highlight').each(function( cnt, e ) {
post_data = '&amt='+amt+'&id='+e.id post_data = '&amt='+amt+'&id='+e.id
// send /transform for this image, grayscale the thumbmail, add color spinning wheel overlay, and start checking for job end // 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){ $('#'+e.id).find('img.thumb').attr('style', 'filter: grayscale(100%);' ); $('#'+e.id).removeClass('entry'); $('#s'+e.id).show(); CheckTransformJob(e.id,data.job_id,handleTransformFiles); return false; } }) $.ajax({ type: 'POST', data: post_data, url: '/transform', success: function(data){
processAmendments(data.job.amendments)
checkForAmendmentJobToComplete(data.job.id)
} })
} ) } )
} }
} }

View File

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

View File

@@ -62,6 +62,11 @@ function DrawImg()
if( im.width == 0 ) if( im.width == 0 )
return return
// find any matching ammendment
am=document.amendments.filter(obj => obj.eid === document.viewing.id)
if( am.length )
am=am[0]
canvas.width=NewWidth(im) canvas.width=NewWidth(im)
canvas.height=NewHeight(im) canvas.height=NewHeight(im)
@@ -69,14 +74,32 @@ function DrawImg()
$('#img-cap').width(canvas.width) $('#img-cap').width(canvas.width)
// actually draw the pixel images to the canvas at the right size // actually draw the pixel images to the canvas at the right size
if( grayscale ) if (!Array.isArray(am))
context.filter='grayscale(1)' context.filter='grayscale(1)'
context.drawImage(im, 0, 0, canvas.width, canvas.height ) context.drawImage(im, 0, 0, canvas.width, canvas.height )
// -50 is a straight up hack, no idea why this works, but its good enough for me // -50 is a straight up hack, no idea why this works, but its good enough for me
if( throbber ) if (!Array.isArray(am))
$('#throbber').attr('style', 'display:show; position:absolute; left:'+canvas.width/2+'px; top:'+(canvas.height/2-50)+'px' ) {
else $('#throbber').show()
$('#throbber').hide(); $('#white-circle').show()
if(am.type.which == 'img' )
{
$('#inside-img').attr('src', '/internal/'+am.type.what );
$('#inside-img').show()
}
else
{
$('#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()
$('#white-circle').hide()
$('#inside-img').hide()
$('#inside-icon').hide()
}
// show (or not) the whole figcaption with fname in it - based on state of fname_toggle // show (or not) the whole figcaption with fname in it - based on state of fname_toggle
if( $('#fname_toggle').prop('checked' ) ) if( $('#fname_toggle').prop('checked' ) )
@@ -166,7 +189,7 @@ function ViewImageOrVideo()
if( ! document.viewing ) return if( ! document.viewing ) return
if( document.viewing.type.name == 'Image' ) if( document.viewing.type.name == 'Image' )
{ {
im.src='../' + document.viewing.FullPathOnFS im.src='../' + document.viewing.FullPathOnFS + '?t=' + new Date().getTime();
$('#video_div').hide() $('#video_div').hide()
if( $('#fname_toggle').prop('checked' ) ) if( $('#fname_toggle').prop('checked' ) )
$('#img-cap').show() $('#img-cap').show()
@@ -603,6 +626,8 @@ function goOutOfViewer()
// hide viewer div, then show files_div // hide viewer div, then show files_div
$('#viewer_div').addClass('d-none') $('#viewer_div').addClass('d-none')
$('#files_div').removeClass('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) // change the viewer to the previous entry (handle page change too)
@@ -678,13 +703,11 @@ function addViewerKeyHandler() {
{ {
case "Left": // IE/Edge specific value case "Left": // IE/Edge specific value
case "ArrowLeft": case "ArrowLeft":
if( $('#la').prop('disabled') == false ) $('#la').click()
$('#la').click()
break; break;
case "Right": // IE/Edge specific value case "Right": // IE/Edge specific value
case "ArrowRight": case "ArrowRight":
if( $('#ra').prop('disabled') == false ) $('#ra').click()
$('#ra').click()
break; break;
case "d": case "d":
$('#distance').click() $('#distance').click()
@@ -724,7 +747,6 @@ function nextImageInViewer()
getNextEntry() getNextEntry()
setDisabledForViewingNextPrevBttons() setDisabledForViewingNextPrevBttons()
ViewImageOrVideo() ViewImageOrVideo()
} }
// wrapper func to start the viewer - needed as we have a dbl-click & View file // wrapper func to start the viewer - needed as we have a dbl-click & View file

BIN
internal/white-circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

37
job.py
View File

@@ -4,11 +4,13 @@ from flask import request, render_template, redirect, make_response, jsonify, ur
from settings import Settings from settings import Settings
from main import db, app, ma from main import db, app, ma
from sqlalchemy import Sequence, func, select from sqlalchemy import Sequence, func, select
from sqlalchemy.orm import joinedload
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytz import pytz
import socket import socket
from shared import PA, PA_JOB_MANAGER_HOST, PA_JOB_MANAGER_PORT, NEWEST_LOG_LIMIT, OLDEST_LOG_LIMIT from shared import PA, PA_JOB_MANAGER_HOST, PA_JOB_MANAGER_PORT, NEWEST_LOG_LIMIT, OLDEST_LOG_LIMIT
from amend import EntryAmendment, inAmendmentTypes
from flask_login import login_required, current_user from flask_login import login_required, current_user
from sqlalchemy.dialects.postgresql import INTERVAL from sqlalchemy.dialects.postgresql import INTERVAL
from sqlalchemy.sql.functions import concat from sqlalchemy.sql.functions import concat
@@ -57,10 +59,12 @@ class Job(db.Model):
extra = db.relationship( "JobExtra") extra = db.relationship( "JobExtra")
logs = db.relationship( "Joblog") logs = db.relationship( "Joblog")
amendments = db.relationship("EntryAmendment", back_populates="job")
def __repr__(self): 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) 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) # 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 # the job manager can send a message back to the front end (this code) via the
@@ -82,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 # Used in main html to show a red badge of # jobs to draw attention there are
# active jobs being processed in the background # 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() ret=Job.query.filter(Job.pa_job_state != 'Completed').with_entities(func.count(Job.id).label('count') ).first()
return ret[0] return ret[0]
@@ -114,8 +118,23 @@ def NewJob(name, num_files="0", wait_for=None, jex=None, desc="No description pr
db.session.add(job) db.session.add(job)
db.session.commit() db.session.commit()
SetFELog( message=f'Created <a class="link-light" href="/job/{job.id}">Job #{job.id}</a> to {desc}', level="success" )
# if this job changes an eid we store that in DB and client shows until it finishes the job
at_id = inAmendmentTypes(job)
if at_id:
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 )
db.session.add(ea)
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, job_id=job.id, amend_type=at_id )
db.session.add(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) WakePAJobManager(job.id)
return job return job
@@ -309,14 +328,22 @@ def joblog_search():
@app.route("/check_for_jobs", methods=["POST"]) @app.route("/check_for_jobs", methods=["POST"])
@login_required @login_required
def check_for_jobs(): def check_for_jobs():
num=GetNumActiveJobs() from files import job_schemas
num=getNumActiveJobs()
messages = PA_JobManager_Message.query.all()
sts=[] sts=[]
for msg in PA_JobManager_Message.query.all(): for msg in messages:
u='' u=''
if 'Job #' not in msg.message and msg.job_id: 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>: ' 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 } ) 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> # /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_GROUP_DN'] = 'ou=groups'
app.config['LDAP_USER_RDN_ATTR'] = 'uid' app.config['LDAP_USER_RDN_ATTR'] = 'uid'
app.config['LDAP_USER_LOGIN_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_GROUP_OBJECT_FILTER'] = '(objectclass=posixGroup)'
app.config['LDAP_BIND_USER_DN'] = None app.config['LDAP_BIND_USER_DN'] = None
app.config['LDAP_BIND_USER_PASSWORD'] = None app.config['LDAP_BIND_USER_PASSWORD'] = None

View File

@@ -1,4 +1,3 @@
# #
# This file controls the 'external' job control manager, that (periodically # # This file controls the 'external' job control manager, that (periodically #
# looks / somehow is pushed an event?) picks up new jobs, and processes them. # looks / somehow is pushed an event?) picks up new jobs, and processes them.
@@ -15,7 +14,7 @@
### SQLALCHEMY IMPORTS ### ### SQLALCHEMY IMPORTS ###
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, Sequence, Float, ForeignKey, DateTime, LargeBinary, Boolean, func, text from sqlalchemy import Column, Integer, String, Sequence, Float, ForeignKey, DateTime, LargeBinary, Boolean, func, text, select
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -23,7 +22,7 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import scoped_session from sqlalchemy.orm import scoped_session
### LOCAL FILE IMPORTS ### ### LOCAL FILE IMPORTS ###
from shared import DB_URL, PA_JOB_MANAGER_HOST, PA_JOB_MANAGER_PORT, THUMBSIZE, SymlinkName, GenThumb, SECS_IN_A_DAY, PA_EXIF_ROTATER from shared import DB_URL, PA_JOB_MANAGER_HOST, PA_JOB_MANAGER_PORT, THUMBSIZE, SymlinkName, GenThumb, SECS_IN_A_DAY, PA_EXIF_ROTATER, PA
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
### PYTHON LIB IMPORTS ### ### PYTHON LIB IMPORTS ###
@@ -46,6 +45,8 @@ import re
import sys import sys
import ffmpeg import ffmpeg
import subprocess import subprocess
# FIXME: remove this
import time
# global debug setting # global debug setting
@@ -512,6 +513,15 @@ class PA_JobManager_FE_Message(Base):
def __repr__(self): def __repr__(self):
return "<id: {}, job_id: {}, level: {}, message: {}".format(self.id, self.job_id, self.level, self.message) return "<id: {}, job_id: {}, level: {}, message: {}".format(self.id, self.job_id, self.level, self.message)
################################################################################
# Class describing which Entry has a pending Amendment in the DB (via sqlalchemy)
################################################################################
class EntryAmendment(PA,Base):
__tablename__ = "entry_amendment"
eid = Column(Integer, ForeignKey("entry.id"), primary_key=True )
job_id = Column(Integer, ForeignKey("job.id"), primary_key=True )
# don't over think this, we just use eid to delete this entry anyway
amend_type = Column(Integer)
############################################################################## ##############################################################################
# PAprint(): convenience function to prepend a timestamp to a printed string # PAprint(): convenience function to prepend a timestamp to a printed string
@@ -1863,9 +1873,22 @@ def JobRunAIOn(job):
FinishJob(job, "Finished Processesing AI") FinishJob(job, "Finished Processesing AI")
return 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) # JobTransformImage(): transform an image by the amount requested (can also flip horizontal or vertical)
# TODO: should be JobTransformImage() ;)
#################################################################################################################################### ####################################################################################################################################
def JobTransformImage(job): def JobTransformImage(job):
JobProgressState( job, "In Progress" ) JobProgressState( job, "In Progress" )
@@ -1874,6 +1897,11 @@ def JobTransformImage(job):
amt=[jex.value for jex in job.extra if jex.name == "amt"][0] amt=[jex.value for jex in job.extra if jex.name == "amt"][0]
e=session.query(Entry).join(File).filter(Entry.id==id).first() e=session.query(Entry).join(File).filter(Entry.id==id).first()
PAprint( f"JobTransformImage: job={job.id}, id={id}, amt={amt}" ) 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": if amt == "fliph":
AddLogForJob(job, f"INFO: Flipping {e.FullPathOnFS()} horizontally" ) AddLogForJob(job, f"INFO: Flipping {e.FullPathOnFS()} horizontally" )
@@ -1897,6 +1925,10 @@ def JobTransformImage(job):
e.file_details.hash = md5( job, e ) e.file_details.hash = md5( job, e )
PAprint( f"JobTransformImage DONE thumb: job={job.id}, id={id}, amt={amt}" ) PAprint( f"JobTransformImage DONE thumb: job={job.id}, id={id}, amt={amt}" )
session.add(e) session.add(e)
# 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") FinishJob(job, "Finished Processesing image rotation/flip")
return return
@@ -2174,6 +2206,7 @@ def JobMoveFiles(job):
if 'eid-' in jex.name: if 'eid-' in jex.name:
move_me=session.query(Entry).get(jex.value) move_me=session.query(Entry).get(jex.value)
MoveEntriesToOtherFolder( job, move_me, dst_storage_path, f"{prefix}{suffix}" ) 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" ) 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)") FinishJob(job, f"Finished move selected file(s)")
return return
@@ -2188,6 +2221,7 @@ def JobDeleteFiles(job):
if 'eid-' in jex.name: if 'eid-' in jex.name:
del_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first() del_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first()
MoveFileToRecycleBin(job,del_me) 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" ) 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)") FinishJob(job, f"Finished deleting selected file(s)")
return return
@@ -2202,6 +2236,7 @@ def JobRestoreFiles(job):
if 'eid-' in jex.name: if 'eid-' in jex.name:
restore_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first() restore_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first()
RestoreFile(job,restore_me) 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" ) 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)") FinishJob(job, f"Finished restoring selected file(s)")
return return
@@ -2707,7 +2742,6 @@ def ScheduledJobs():
created_jobs=True created_jobs=True
return created_jobs return created_jobs
#################################################################################################################################### ####################################################################################################################################
# MAIN - start with validation, then grab any jobs in the DB to process, then # MAIN - start with validation, then grab any jobs in the DB to process, then
# go into waiting on a socket to be woken up (and then if woken, back into HandleJobs() # go into waiting on a socket to be woken up (and then if woken, back into HandleJobs()

View File

@@ -1,4 +1,4 @@
ALTER DATABASE pa SET TIMEZONE TO 'aUSTRALIA/vICTORIA'; ALTER DATABASE pa SET TIMEZONE TO 'Australia/Victoria';
CREATE SEQUENCE pa_user_id_seq; CREATE SEQUENCE pa_user_id_seq;
CREATE SEQUENCE pa_user_state_id_seq; CREATE SEQUENCE pa_user_state_id_seq;
@@ -21,8 +21,8 @@ CREATE SEQUENCE query_id_seq;
-- these are hard-coded at present, not sure I can reflexively find models from API? -- these are hard-coded at present, not sure I can reflexively find models from API?
CREATE TABLE ai_model ( id INTEGER, name VARCHAR(24), description VARCHAR(80), CONSTRAINT pk_ai_model PRIMARY KEY(id) ); CREATE TABLE ai_model ( id INTEGER, name VARCHAR(24), description VARCHAR(80), CONSTRAINT pk_ai_model PRIMARY KEY(id) );
INSERT INTO ai_model VALUES ( 1, 'HOG', 'NORMAL' ); INSERT INTO ai_model VALUES ( 1, 'hog', 'normal' );
INSERT INTO ai_model VALUES ( 2, 'CNN', 'MORE ACCURATE / MUCH SLOWER' ); INSERT INTO ai_model VALUES ( 2, 'cnn', 'more accurate / much slower' );
CREATE TABLE settings( CREATE TABLE settings(
id INTEGER, id INTEGER,
@@ -122,10 +122,10 @@ CREATE TABLE face_refimg_link( face_id INTEGER, refimg_id INTEGER, face_distance
CONSTRAINT fk_frl_refimg_id FOREIGN KEY (refimg_id) REFERENCES refimg(id) ); CONSTRAINT fk_frl_refimg_id FOREIGN KEY (refimg_id) REFERENCES refimg(id) );
CREATE TABLE face_override_type ( id INTEGER, name VARCHAR UNIQUE, CONSTRAINT pk_face_override_type_id PRIMARY KEY(id) ); CREATE TABLE face_override_type ( id INTEGER, name VARCHAR UNIQUE, CONSTRAINT pk_face_override_type_id PRIMARY KEY(id) );
INSERT INTO face_override_type VALUES ( (SELECT NEXTVAL('face_override_type_id_seq')), 'mANUAL MATCH TO EXISTING PERSON' ); INSERT INTO face_override_type VALUES ( (SELECT NEXTVAL('face_override_type_id_seq')), 'Manual match to existing person' );
INSERT INTO face_override_type VALUES ( (SELECT NEXTVAL('face_override_type_id_seq')), 'nOT A FACE' ); INSERT INTO face_override_type VALUES ( (SELECT NEXTVAL('face_override_type_id_seq')), 'Not a face' );
INSERT INTO face_override_type VALUES ( (SELECT NEXTVAL('face_override_type_id_seq')), 'tOO YOUNG' ); INSERT INTO face_override_type VALUES ( (SELECT NEXTVAL('face_override_type_id_seq')), 'Too young' );
INSERT INTO face_override_type VALUES ( (SELECT NEXTVAL('face_override_type_id_seq')), 'iGNORE FACE' ); INSERT INTO face_override_type VALUES ( (SELECT NEXTVAL('face_override_type_id_seq')), 'Ignore face' );
-- keep non-redundant FACE because, when we rebuild data we may have a null FACE_ID, but still want to connect to this override -- keep non-redundant FACE because, when we rebuild data we may have a null FACE_ID, but still want to connect to this override
-- from a previous AI pass... (would happen if we delete a file and then reimport/scan it), OR, more likely we change (say) a threshold, etc. -- from a previous AI pass... (would happen if we delete a file and then reimport/scan it), OR, more likely we change (say) a threshold, etc.
@@ -165,20 +165,36 @@ CREATE TABLE joblog ( id INTEGER, job_id INTEGER, log_date TIMESTAMPTZ, log VARC
CONSTRAINT pk_jl_id PRIMARY KEY(id), CONSTRAINT fk_jl_job_id FOREIGN KEY(job_id) REFERENCES job(id) ); CONSTRAINT pk_jl_id PRIMARY KEY(id), CONSTRAINT fk_jl_job_id FOREIGN KEY(job_id) REFERENCES job(id) );
CREATE TABLE pa_job_manager_fe_message ( id INTEGER, job_id INTEGER, level VARCHAR(16), message VARCHAR(8192), persistent BOOLEAN, cant_close BOOLEAN, CREATE TABLE pa_job_manager_fe_message ( id INTEGER, job_id INTEGER, level VARCHAR(16), message VARCHAR(8192), persistent BOOLEAN, cant_close BOOLEAN,
CONSTRAINT pa_job_manager_fe_acks_id PRIMARY KEY(id), CONSTRAINT pk_pa_job_manager_fe_acks_id PRIMARY KEY(id),
CONSTRAINT fk_pa_job_manager_fe_message_job_id FOREIGN KEY(job_id) REFERENCES job(id) ); CONSTRAINT fk_pa_job_manager_fe_message_job_id FOREIGN KEY(job_id) REFERENCES job(id) );
CREATE TABLE amendment_type ( id INTEGER, job_name VARCHAR(64), which VARCHAR(8), what VARCHAR(32), colour VARCHAR(32),
CONSTRAINT pk_amendment_type_id PRIMARY KEY(id) );
INSERT INTO amendment_type ( id, job_name, which, what, colour ) VALUES ( 1, 'delete_files', 'icon', 'trash', 'var(--bs-danger)' );
INSERT INTO amendment_type ( id, job_name, which, what, colour ) VALUES ( 2, 'restore_files', 'icon', 'trash', 'var(--bs-success)' );
INSERT INTO amendment_type ( id, job_name, which, what, colour ) VALUES ( 3, 'transform_image:90', 'img', 'rot90.png', '#009EFF' );
INSERT INTO amendment_type ( id, job_name, which, what, colour ) VALUES ( 4, 'transform_image:180', 'img', 'rot180.png', '#009EFF' );
INSERT INTO amendment_type ( id, job_name, which, what, colour ) VALUES ( 5, 'transform_image:270', 'img', 'rot270.png', '#009EFF' );
INSERT INTO amendment_type ( id, job_name, which, what, colour ) VALUES ( 6, 'transform_image:fliph', 'icon', 'flip_h', '#009EFF' );
INSERT INTO amendment_type ( id, job_name, which, what, colour ) VALUES ( 7, 'transform_image:flipv', 'icon', 'flip_v', '#009EFF' );
INSERT INTO amendment_type ( id, job_name, which, what, colour ) VALUES ( 8, 'move_files', 'icon', 'folder_plus', 'var(--bs-primary)' );
CREATE TABLE entry_amendment ( amend_type INTEGER, eid INTEGER, job_id INTEGER,
CONSTRAINT pk_entry_amendment_eid_job_id PRIMARY KEY(eid,job_id),
CONSTRAINT fk_entry_amendment_amendment_type FOREIGN KEY(amend_type) REFERENCES amendment_type(id),
CONSTRAINT fk_entry_amendment_job_id FOREIGN KEY(job_id) REFERENCES job(id) );
-- default data for types of paths -- default data for types of paths
INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'iMPORT' ); INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'Import' );
INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'sTORAGE' ); INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'Storage' );
INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'bIN' ); INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'Bin' );
INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'mETADATA' ); INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'Metadata' );
-- default data for types of files -- default data for types of files
INSERT INTO file_type VALUES ( (SELECT NEXTVAL('file_type_id_seq')), 'iMAGE' ); INSERT INTO file_type VALUES ( (SELECT NEXTVAL('file_type_id_seq')), 'Image' );
INSERT INTO file_type VALUES ( (SELECT NEXTVAL('file_type_id_seq')), 'vIDEO' ); INSERT INTO file_type VALUES ( (SELECT NEXTVAL('file_type_id_seq')), 'Video' );
INSERT INTO file_type VALUES ( (SELECT NEXTVAL('file_type_id_seq')), 'dIRECTORY' ); INSERT INTO file_type VALUES ( (SELECT NEXTVAL('file_type_id_seq')), 'Directory' );
INSERT INTO file_type VALUES ( (SELECT NEXTVAL('file_type_id_seq')), 'uNKNOWN' ); INSERT INTO file_type VALUES ( (SELECT NEXTVAL('file_type_id_seq')), 'Unknown' );
-- fake data only for making testing easier -- fake data only for making testing easier
--INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'dad', 'Damien', 'De Paoli' ); --INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'dad', 'Damien', 'De Paoli' );
@@ -186,7 +202,7 @@ INSERT INTO file_type VALUES ( (SELECT NEXTVAL('file_type_id_seq')), 'uNKNOWN' )
--INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'cam', 'Cameron', 'De Paoli' ); --INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'cam', 'Cameron', 'De Paoli' );
--INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'mich', 'Michelle', 'De Paoli' ); --INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'mich', 'Michelle', 'De Paoli' );
-- DEV(ddp): -- DEV(ddp):
INSERT INTO settings ( ID, BASE_PATH, IMPORT_PATH, STORAGE_PATH, RECYCLE_BIN_PATH, METADATA_PATH, AUTO_ROTATE, DEFAULT_REFIMG_MODEL, DEFAULT_SCAN_MODEL, DEFAULT_THRESHOLD, FACE_SIZE_LIMIT, SCHEDULED_IMPORT_SCAN, SCHEDULED_STORAGE_SCAN, SCHEDULED_BIN_CLEANUP, BIN_CLEANUP_FILE_AGE, JOB_ARCHIVE_AGE ) VALUES ( (SELECT NEXTVAL('settings_id_seq')), '/HOME/DDP/SRC/PHOTOASSISTANT/', 'IMAGES_TO_PROCESS/', 'PHOTOS/', '.PA_BIN/', '.PA_METADATA/', TRUE, 1, 1, '0.55', 43, 1, 1, 7, 30, 3 ); INSERT INTO settings ( id, base_path, import_path, storage_path, recycle_bin_path, metadata_path, auto_rotate, default_refimg_model, default_scan_model, default_threshold, face_size_limit, scheduled_import_scan, scheduled_storage_scan, scheduled_bin_cleanup, bin_cleanup_file_age, job_archive_age ) VALUES ( (SELECT NEXTVAL('settings_id_seq')), '/home/ddp/src/photoassistant/', 'images_to_process/', 'photos/', '.pa_bin/', '.pa_metadata/', true, 1, 1, '0.55', 43, 1, 1, 7, 30, 3 );
-- DEV(cam): -- DEV(cam):
--INSERT INTO settings ( id, base_path, import_path, storage_path, recycle_bin_path, metadata_path, auto_rotate, default_refimg_model, default_scan_model, default_threshold, face_size_limit, scheduled_import_scan, scheduled_storage_scan, scheduled_bin_cleanup, bin_cleanup_file_age, job_archive_age ) VALUES ( (select nextval('SETTINGS_ID_SEQ')), 'c:/Users/cam/Desktop/code/python/photoassistant/', 'c:\images_to_process', 'photos/', '.pa_bin/', '.pa_metadata/', TRUE, 1, 1, '0.55', 43, 1, 1, 7, 30, 3 ); --INSERT INTO settings ( id, base_path, import_path, storage_path, recycle_bin_path, metadata_path, auto_rotate, default_refimg_model, default_scan_model, default_threshold, face_size_limit, scheduled_import_scan, scheduled_storage_scan, scheduled_bin_cleanup, bin_cleanup_file_age, job_archive_age ) VALUES ( (select nextval('SETTINGS_ID_SEQ')), 'c:/Users/cam/Desktop/code/python/photoassistant/', 'c:\images_to_process', 'photos/', '.pa_bin/', '.pa_metadata/', TRUE, 1, 1, '0.55', 43, 1, 1, 7, 30, 3 );
-- PROD: -- PROD:

View File

@@ -137,9 +137,17 @@
<use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#prev"/></svg> <use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#prev"/></svg>
</button> </button>
</div> </div>
<figure class="col col-auto border border-info rounded m-0 p-1" id="figure"> <figure style="position: relative;" class="col col-auto border border-info rounded m-0 p-1" id="figure">
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>
<img id="throbber" src="{{url_for('internal', filename='throbber.gif')}}?v={{js_vers[th]}}" style="display:none;"> <!-- 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;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> <script>
var im=new Image(); var im=new Image();
im.onload=DrawImg im.onload=DrawImg
@@ -251,6 +259,10 @@
// this implies no content in the Path at all // this implies no content in the Path at all
OPT.root_eid = {{ query_data.root_eid }}; OPT.root_eid = {{ query_data.root_eid }};
// amendment types are stable per code release, store them once and use as
// needed when we amend entrys in Transforms, removes, etc.
document.amendTypes = {{ query_data.amendTypes|tojson }};
// get items out of query_data into convenience javascript vars... // get items out of query_data into convenience javascript vars...
var move_paths = {{ query_data.move_paths|tojson }}; var move_paths = {{ query_data.move_paths|tojson }};
var NMO={{query_data.NMO|tojson}} var NMO={{query_data.NMO|tojson}}
@@ -264,10 +276,8 @@
// force pageList to set pageList for & render the first page // force pageList to set pageList for & render the first page
getPage(1,getPageFigures) 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 gap=0.8
var grayscale=0
var throbber=0
function PrettyFname(fname) function PrettyFname(fname)
{ {
@@ -317,5 +327,11 @@
$('#viewer_del').on('click', function() { DelDBox('Restore') } ) $('#viewer_del').on('click', function() { DelDBox('Restore') } )
} }
if( isMobile() )
{
$('#shift-key').css('visibility', 'visible');
$('#ctrl-key').css('visibility', 'visible');
}
</script> </script>
{% endblock script_content %} {% 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 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 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 # 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 # we will keep the container can login by hand and check the issue/error
sleep 99999 sleep 99999