Compare commits

...

67 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
97e738dc13 implemented a quick cache for flat view 2025-10-12 16:29:39 +11:00
b61f048dec reverted partial client side back button logic, but also now tested / validated if somehow we are in a flat view and ask for entries and dont get all of them back, or we are in folder view and we try to go into a folder or back up a folder and we get no data as someone deleted it since we made the view, so then show appropriate client-side errors 2025-10-12 16:02:21 +11:00
e3f6b416ce forgot to add js_vers for search, fixed 2025-10-12 13:24:53 +11:00
0ac0eedef9 handle going into Dirs and back from Dirs by doing logic of parent dir on server side, only return eids, and get normal code to handle rendering, no specific get_dir_entries route -> its not get_dir_eids, and the rest is normal code. Much cleaner/simpler. At this point I think the major rewrite it functional, commiting before more testing and then merge code and removed firstEntryOnPage bug (its now pageList[0]) 2025-10-12 13:14:53 +11:00
cb5ff7e985 comments for clarity of root_eid=0 meaning no entries 2025-10-12 13:14:13 +11:00
517b5c6167 made more of the javascript hopefulyl honour versions, sort of works, not fully deployed consistently but works well enough for home use in dev. Also tweaked how the layout looks on a phone so that la / ra buttons on beside the image. Still oddly needs me to shrink that page, but it is now visually right - odd 2025-10-11 15:02:58 +11:00
16d28bc02e first pass of versioning, working for files.html only for now 2025-10-11 12:47:53 +11:00
da0019ecdc adding a get_version function that gets the mtime of the jscript file and then lets us embed that into the
<script src=>
Note, accidentally committed, partially tested
2025-10-11 12:27:59 +11:00
e4bf9606b9 move functions to appropriate file location for files/view support js, commented them better, removed some dead code 2025-10-11 12:13:44 +11:00
3a053bea49 found new todo around going back in folder view and sort order 2025-10-11 12:13:17 +11:00
1e421c3f22 move jscript logic out of template/html, into jscript so we can keep all logic into the included file, then I can force version on it and stop caching issues in mobiles, and a shift-reload will also get new code, rather than a container restart even on desktops 2025-10-11 11:11:51 +11:00
346defde8b added a functional (small) up button in the viewer, all works 2025-10-11 09:19:51 +11:00
6419e20d7e updated as I have fixed the restore/del from the viewer 2025-10-10 23:14:15 +11:00
b51d9e1776 make delete/restore button in viewer be the right colour, and go back to / when the success for the delete or restore finishes 2025-10-10 23:13:03 +11:00
fa2197adbe fix up bug where empty file_rbp did not work and displayed in the wrong div 2025-10-10 23:03:23 +11:00
66344e146e added a health check to see if this can help with the odd startup lag, it didnt 2025-10-10 23:02:47 +11:00
ee1c9b5494 all override add and remove now use new datastructures, close to be able to test / augment as per TODO 2025-10-09 23:56:27 +11:00
846bdc4e52 fix up linking between face to refimg when we add that face to a person from right-click 2025-10-07 23:52:06 +11:00
541cbec0de update adding refimg to new or existing person via right-click on a face to use new data structures 2025-10-07 23:43:07 +11:00
071164d381 remove dead code 2025-10-07 23:42:17 +11:00
1adb1bc7af no longer have pa_user_state data really, ditch the table 2025-10-07 23:42:02 +11:00
747f524343 remove more dead code 2025-10-07 23:41:38 +11:00
4feae96511 remove comments/format 2025-10-07 23:31:37 +11:00
e6c9429466 remove search term from DB 2025-10-07 23:30:28 +11:00
005b5be2b9 remove search_term from DB fields we want 2025-10-07 23:28:54 +11:00
f369b6d796 ticked off a couple of items around no content being better displayed 2025-10-07 00:01:30 +11:00
eb7bb84e09 add some padding, and set root_eid - Its 0 when nothing at all in Path (first run/empty). Also, started override for marshmallow, but overrides wont work yet 2025-10-07 00:00:50 +11:00
7f13d78700 deal with completely empty directories, remove ChangeSize and move that content into changeSize 2025-10-06 21:41:35 +11:00
e5d6ce9b73 move MoveDBox into full javascript and fold it into internal/js/files_support.js, also remove unused parameter for MoveDBox, and use marshmallow to pass people in query_data - overall just cleaner more consistent code for existing functionality 2025-10-05 23:20:17 +11:00
e0654edd21 alter move_paths from a specific var passed in the render_template, to be folded into query_data var 2025-10-05 22:36:49 +11:00
24c2922f74 actually set NMO var based on marshmallow data 2025-10-05 22:21:57 +11:00
24c3762c61 remove PathDetails*, and use marshmallow schemas with methods to get icon_url, and "path" renamed to "root_dir", updated move* code to use new data structure 2025-10-05 22:19:58 +11:00
22 changed files with 1397 additions and 956 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-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)...
- confirmed this is when person has 2 or more refimgs:
- 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-118: can move files from Bin path, but it leaves the del_file entry for it - need to remove it
BUG-117: when search returns files that can be deleted and/or restored, the icon stays as delete and tries to delete!
BUG-106: cant add trudy /pat? as refimgs via FaceDBox
- seems the cropped trudy face is not sufficient to find a face, how odd...
(it came from a face bbox, BUT, I have grown the face seln by 10%?)
BUG-117: when search returns files that can be deleted and/or restored, the icon stays as delete and tries to delete!
BUG-118: can move files from Bin path, but it leaves the del_file entry for it - need to remove it
BUG-119: "Uncaught (in promise) Error: A listener indicated an asynchronous
response by returning true, but the message channel closed before a response
was received"
investigate this (possible I'm calling check_for_jobs and maybe not doing the async right?)
BUG-123: pa_job_manager crashed with timeout on connection (probably when I turned off traefik for a bit?). Regardless, should be more fault tolerant --> maybe offer to restart pa_job_manager IF its crashed?
this definitely happened also, when I shutdown the DB back-end mid job, and it was able to be restarted, so could get f/e to at least suggest a restart of the contianer, or auto-restart job_mgr?
BUG-125: when an image is highlighted, then post the contextmenu on a different image - the highlight does not move to the new image
and the selected menu function processes the original or the new depending on the way the code works.
There is a chance we need to change the document on click to a mouse down (or whatever the context menu
uses for default), rather than just fix the highlight
BUG-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..."
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

43
TODO
View File

@@ -1,31 +1,13 @@
###
# get override data into view
# should start with an empty DB and test
# definitely no dirs in storage_sp I now pass root_eid=0 for this
# BUT - need GUI to work - may even be good to put an alert up - its so odd to have not root dir ONLY happens when no data
# empty directories (2017/20171015-test/...) showing "No matches for: 'undefined'" <- should only comes up for search in URL???
# 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...
# also all the add ref img/add override, etc are non-functional - FIX the override* stuff first to get table/naming consistency as that is half the problem
# convert move_paths to a json setup
# ALSO revisit this move_paths to be as safe as possible ultimately, triple-check there are no leading / or .. 's
# TEST everything (don't forget keybindings,e.g. delete)
###
### 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
* jobs for AI should show path name
* rm dups job should show progress bar
* jobs for AI should show path name
* rm dups job should show progress bar
* 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
- 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:
- 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'.
@@ -36,9 +18,7 @@
(is there a library for this???)
* sqlalchemy 2 migration:
* fix unmapped (in fact make all the code properly sqlachemy 2.0 compliant)
-- path.py has the __allow_unmapped__ = True
* remove all '.execute' from *.py
- get AI to help
* allow actions for wrong person:
-> someone else? OR override no match for this person ever for this image?
@@ -67,10 +47,6 @@
- rename (does this work already somehow? see issue below)
- 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:
job.py:@app.route("/jobs", methods=["GET", "POST"])
job.py:@app.route("/job/<id>", methods=["GET","POST"])
@@ -78,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...
- 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?
* 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?)
@@ -114,7 +90,6 @@
* 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
Admin
-> do I want to have admin roles/users?
-> 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

View File

@@ -31,6 +31,8 @@ class Face(PA,db.Model):
refimg_lnk = db.relationship("FaceRefimgLink", uselist=False, viewonly=True )
facefile_lnk = db.relationship("FaceFileLink", uselist=False, viewonly=True )
refimg =db.relationship("Refimg", secondary="face_refimg_link", uselist=False)
fnmo = db.relationship("FaceNoMatchOverride", back_populates="face")
ffmo = db.relationship("FaceForceMatchOverride", back_populates="face")
################################################################################
@@ -104,6 +106,7 @@ class FaceNoMatchOverride(PA, db.Model):
face_id = db.Column(db.Integer, db.ForeignKey("face.id"), primary_key=True )
type_id = db.Column(db.Integer, db.ForeignKey("face_override_type.id"))
type = db.relationship("FaceOverrideType")
face = db.relationship("Face", back_populates="fnmo")
################################################################################
@@ -123,3 +126,4 @@ class FaceForceMatchOverride(PA, db.Model):
face_id = db.Column(db.Integer, db.ForeignKey("face.id"), primary_key=True )
person_id = db.Column(db.Integer, db.ForeignKey("person.id"), primary_key=True )
person = db.relationship("Person")
face = db.relationship("Face", back_populates="ffmo")

350
files.py
View File

@@ -1,10 +1,11 @@
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 main import db, app, ma
from sqlalchemy import Sequence, text, select, union
from sqlalchemy import Sequence, text, select, union, or_
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload
import numbers
import os
import glob
import json
@@ -22,17 +23,19 @@ import pytz
import html
from flask_login import login_required, current_user
from types import SimpleNamespace
from amend import EntryAmendment, AmendmentType
# Local Class imports
################################################################################
from states import States, PA_UserState
from query import Query
from job import Job, JobExtra, Joblog, NewJob, SetFELog
from path import PathType, Path, MovePathDetails
from path import PathType, Path
from person import Refimg, Person, PersonRefimgLink
from settings import Settings, SettingsIPath, SettingsSPath, SettingsRBPath
from shared import SymlinkName
from shared import SymlinkName, ICON, PA
from dups import Duplicates
from amend import getAmendments
from face import Face, FaceFileLink, FaceRefimgLink, FaceOverrideType, FaceNoMatchOverride, FaceForceMatchOverride
# 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)
# connects the entry (dir) with a path
################################################################################
class PathDirLink(db.Model):
class PathDirLink(PA,db.Model):
__tablename__ = "path_dir_link"
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 )
def __repr__(self):
return f"<path_id: {self.path_id}, dir_eid: {self.dir_eid}>"
################################################################################
# Class describing EntryDirLInk and in the DB (via sqlalchemy)
# 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"
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 )
def __repr__(self):
return f"<entry_id: {self.entry_id}, dir_eid: {self.dir_eid}>"
################################################################################
# Class describing Dir and in the DB (via sqlalchemy)
# rel_path: rest of dir after path, e.g. if path = /..../storage, then
# 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
################################################################################
class Dir(db.Model):
class Dir(PA,db.Model):
__tablename__ = "dir"
eid = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True )
rel_path = db.Column(db.String, unique=True )
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)
# 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)
# FullPathOnFS(): method to get path on the FS for this Entry
################################################################################
class Entry(db.Model):
class Entry(PA,db.Model):
__tablename__ = "entry"
id = db.Column(db.Integer, db.Sequence('file_id_seq'), primary_key=True )
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
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)
# 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)
# faces: convenience field to show connected face(s) for this file
################################################################################
class File(db.Model):
class File(PA,db.Model):
__tablename__ = "file"
eid = db.Column(db.Integer, db.ForeignKey("entry.id"), primary_key=True )
size_mb = db.Column(db.Integer, unique=False, nullable=False)
@@ -129,22 +120,15 @@ class File(db.Model):
woy = db.Column(db.Integer)
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)
# pre-defined list of file types (image, dir, etc.)
################################################################################
class FileType(db.Model):
class FileType(PA,db.Model):
__tablename__ = "file_type"
id = db.Column(db.Integer, db.Sequence('file_type_id_seq'), primary_key=True )
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
# access *order_map.get(OPT.noo)
@@ -170,24 +154,36 @@ class PathSchema(ma.SQLAlchemyAutoSchema):
class Meta: model = Path
load_instance = True
type = ma.Nested(PathType)
root_dir = fields.Method("get_root_dir")
icon_url = fields.Method("get_icon_url")
def get_icon_url(self, obj):
return url_for("internal", filename="icons.svg") + "#" + ICON[obj.type.name]
def get_root_dir(self, obj):
parts = obj.path_prefix.split('/')
return ''.join(parts[2:])
class FileTypeSchema(ma.SQLAlchemyAutoSchema):
class Meta: model = FileType
class Meta:
model = FileType
load_instance = True
class DirSchema(ma.SQLAlchemyAutoSchema):
class Meta: model = Dir
class Meta:
model = Dir
load_instance = True
eid = ma.auto_field() # Explicitly include eid
in_path = ma.Nested(PathSchema)
class FaceFileLinkSchema(ma.SQLAlchemyAutoSchema):
class Meta: model = FaceFileLink
model_used = ma.auto_field()
class Meta:
model = FaceFileLink
load_instance = True
model_used = ma.auto_field()
class PersonSchema(ma.SQLAlchemyAutoSchema):
class Meta: model=Person
class Meta:
model=Person
load_instance = True
class RefimgSchema(ma.SQLAlchemyAutoSchema):
@@ -198,9 +194,27 @@ class RefimgSchema(ma.SQLAlchemyAutoSchema):
person = ma.Nested(PersonSchema)
class FaceRefimgLinkSchema(ma.SQLAlchemyAutoSchema):
class Meta: model = FaceRefimgLink
class Meta:
model = FaceRefimgLink
load_instance = True
class FaceOverrideTypeSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = FaceOverrideType
load_instance = True
class FaceNoMatchOverrideSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = FaceNoMatchOverride
load_instance = True
type = ma.Nested(FaceOverrideTypeSchema)
class FaceForceMatchOverrideSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = FaceForceMatchOverride
load_instance = True
person = ma.Nested(PersonSchema)
class FaceSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model=Face
@@ -210,18 +224,35 @@ class FaceSchema(ma.SQLAlchemyAutoSchema):
# faces have to come with a file connection
facefile_lnk = ma.Nested(FaceFileLinkSchema)
refimg_lnk = ma.Nested(FaceRefimgLinkSchema,allow_none=True)
fnmo = ma.Nested( FaceNoMatchOverrideSchema, allow_none=True, many=True )
ffmo = ma.Nested( FaceForceMatchOverrideSchema, allow_none=True, many=True )
class FileSchema(ma.SQLAlchemyAutoSchema):
class Meta: model = File
class Meta:
model = File
load_instance = 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
################################################################################
class EntrySchema(ma.SQLAlchemyAutoSchema):
# gives id, name, type_id
class Meta: model = Entry
class Meta:
model = Entry
load_instance = True
type = ma.Nested(FileTypeSchema)
@@ -236,27 +267,31 @@ class EntrySchema(ma.SQLAlchemyAutoSchema):
def get_full_path(self, obj):
return obj.FullPathOnFS()
class JobExtraSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = JobExtra
load_instance = True
name = ma.auto_field()
value = ma.auto_field()
class JobSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Job
load_instance = True
id = ma.auto_field()
name = ma.auto_field()
extra = ma.Nested(JobExtraSchema, many=True)
amendments = ma.Nested(EntryAmendmentSchema, many=True)
# global - this will be use more than once below, so do it once for efficiency
entries_schema = EntrySchema(many=True)
################################################################################
# util function to just update the current/first/last positions needed for
# viewing / using pa_user_state DB table
################################################################################
def UpdatePref( pref, OPT ):
last_used=datetime.now(pytz.utc)
if OPT.current>0:
pref.current=OPT.current
if OPT.first_eid>0:
pref.first_eid=OPT.first_eid
if OPT.last_eid>0:
pref.last_eid=OPT.last_eid
if OPT.num_entries>0:
pref.num_entries=OPT.num_entries
pref.last_used=last_used
db.session.add(pref)
db.session.commit()
return
FOT_Schema = FaceOverrideTypeSchema(many=True)
path_Schema = PathSchema(many=True)
person_Schema = PersonSchema(many=True)
et_schema = AmendmentTypeSchema(many=True)
ea_schema = EntryAmendmentSchema(many=True)
job_schema = JobSchema(many=False)
job_schemas = JobSchema(many=True)
################################################################################
# /get_entries_by_ids -> route where we supply list of entry ids (for next/prev
@@ -275,6 +310,8 @@ def process_ids():
joinedload(Entry.file_details).joinedload(File.faces).joinedload(Face.refimg).joinedload(Refimg.person),
joinedload(Entry.file_details).joinedload(File.faces).joinedload(Face.refimg_lnk),
joinedload(Entry.file_details).joinedload(File.faces).joinedload(Face.facefile_lnk),
joinedload(Entry.file_details).joinedload(File.faces).joinedload(Face.fnmo).joinedload(FaceNoMatchOverride.type),
joinedload(Entry.file_details).joinedload(File.faces).joinedload(Face.ffmo).joinedload(FaceForceMatchOverride.person),
)
.where(Entry.id.in_(ids))
)
@@ -289,44 +326,87 @@ def process_ids():
# Sort the entries according to the order of ids
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)
################################################################################
# /get_dir_entries -> show thumbnail view of files from import_path(s)
# /get_dir_entries:
# -> if back is false - returns list of eids inside this dir
# -> if back is true - returns list of eids inside the parent of this dir
################################################################################
@app.route("/get_dir_entries", methods=["POST"])
@app.route("/get_dir_eids", methods=["POST"])
@login_required
def get_dir_entries():
data = request.get_json() # Parse JSON body
dir_id = data.get('dir_id', []) # Extract list of ids
back = data.get('back', False) # Extract back boolean
noo = data.get('noo', "A to Z") # Extract noo ordering
# if we are going back, find the parent id and use that instead
if back:
stmt=( select(EntryDirLink.dir_eid).filter(EntryDirLink.entry_id==dir_id) )
dir_id = db.session.execute(stmt).scalars().all() [0]
# get parent of this dir, to go back
stmt=select(EntryDirLink.dir_eid).filter(EntryDirLink.entry_id==dir_id)
dir_id = db.session.execute(stmt).scalars().one_or_none()
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 jsonify( valid=False, entry_list=[] )
# 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)
ent_id = db.session.execute(stmt).scalars().one_or_none()
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 jsonify( valid=False, entry_list=[] )
# get content of dir_id
stmt=( select(Entry.id).join(EntryDirLink).filter(EntryDirLink.dir_eid==dir_id) )
ids=db.session.execute(stmt).scalars().all()
entries_schema = EntrySchema(many=True)
entries = Entry.query.filter(Entry.id.in_(ids)).all()
return jsonify(entries_schema.dump(entries))
stmt=select(Entry.id).join(EntryDirLink).filter(EntryDirLink.dir_eid==dir_id)
stmt=stmt.order_by(*order_map.get(noo) )
return jsonify( valid=True, entry_list=db.session.execute(stmt).scalars().all() )
# get Face override details
def getFOT():
stmt = select(FaceOverrideType)
fot=db.session.execute(stmt).scalars().all()
return FOT_Schema.dump(fot)
# get import/storage path details for move dbox
def getMoveDetails():
stmt = select(Path).where( or_( Path.type.has(name="Import"), Path.type.has(name="Storage")))
mp=db.session.execute(stmt).scalars().all()
return path_Schema.dump(mp)
# get people data for the menu for AI matching (of person.tag)
def getPeople():
stmt = select(Person)
people=db.session.execute(stmt).scalars().all()
return person_Schema.dump(people)
def initQueryData():
query_data={}
query_data['entry_list']=None
query_data['root_eid']=0
query_data['NMO'] = getFOT()
query_data['move_paths'] = getMoveDetails()
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={}
query_data['entry_list']=None
query_data['root_eid']=0
query_data=initQueryData()
search_term = OPT.search_term
# turn * wildcard into sql wildcard of %
search_term = search_term.replace('*', '%')
if 'AI:' in search_term:
if 'AI:' in OPT.search_term:
search_term = search_term.replace('AI:', '')
# AI searches are for specific ppl/joins in the DB AND we do them for ALL types of searches, define this once
@@ -337,7 +417,7 @@ def GetSearchQueryData(OPT):
.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()
else:
# match name of File
@@ -345,20 +425,20 @@ def GetSearchQueryData(OPT):
# 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))
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
combined_query = union( file_query, dir_query, ai_query )
all_entries = db.session.execute(combined_query).scalars().all()
all_entries = list(dict.fromkeys(ai_entries + dir_entries + file_entries))
query_data['entry_list']=all_entries
return query_data
#################################################################################
# Get all relevant Entry.ids based on files_ip/files_sp/files_rbp and OPT visuals
#################################################################################
def GetQueryData( OPT ):
query_data={}
query_data['entry_list']=None
query_data=initQueryData()
# always get the top of the (OPT.prefix) Path's eid and keep it for OPT.folders toggling/use
dir_stmt=(
@@ -377,7 +457,7 @@ def GetQueryData( OPT ):
if OPT.folders:
# 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:
# get every File that is in the OPT.prefix Path
stmt=(
@@ -388,7 +468,6 @@ def GetQueryData( OPT ):
stmt=stmt.order_by(*order_map.get(OPT.noo) )
query_data['entry_list']=db.session.execute(stmt).scalars().all()
return query_data
################################################################################
@@ -406,8 +485,11 @@ def change_file_opts():
else:
OPT.folders=False
# so create a new entryList, and handle that on the client
if 'search' in request.referrer:
query_data = GetSearchQueryData( OPT )
else:
query_data = GetQueryData( OPT )
return make_response( jsonify( query_data=query_data ) )
return jsonify( query_data=query_data )
################################################################################
@@ -418,7 +500,8 @@ def change_file_opts():
def file_list_ip():
OPT=States( request )
query_data = GetQueryData( OPT )
return render_template("file_list.html", page_title='View File Details (Import Path)', query_data=query_data, OPT=OPT )
js_vers = getVersions()
return render_template("file_list.html", page_title='View File Details (Import Path)', query_data=query_data, OPT=OPT, js_vers=js_vers )
################################################################################
# /files -> show thumbnail view of files from import_path(s)
@@ -427,10 +510,9 @@ def file_list_ip():
@login_required
def files_ip():
OPT=States( request )
people = Person.query.all()
move_paths = MovePathDetails()
query_data = GetQueryData( OPT )
return render_template("files.html", page_title=f"View Files ({OPT.path_type} Path)", OPT=OPT, people=people, move_paths=move_paths, query_data=query_data )
js_vers = getVersions()
return render_template("files.html", page_title=f"View Files ({OPT.path_type} Path)", OPT=OPT, query_data=query_data, js_vers=js_vers )
################################################################################
# /files -> show thumbnail view of files from storage_path
@@ -439,10 +521,9 @@ def files_ip():
@login_required
def files_sp():
OPT=States( request )
people = Person.query.all()
move_paths = MovePathDetails()
query_data = GetQueryData( OPT )
return render_template("files.html", page_title=f"View Files ({OPT.path_type} Path)", OPT=OPT, people=people, move_paths=move_paths, query_data=query_data )
js_vers = getVersions()
return render_template("files.html", page_title=f"View Files ({OPT.path_type} Path)", OPT=OPT, query_data=query_data, js_vers=js_vers )
################################################################################
@@ -452,10 +533,9 @@ def files_sp():
@login_required
def files_rbp():
OPT=States( request )
people = Person.query.all()
move_paths = MovePathDetails()
query_data = GetQueryData( OPT )
return render_template("files.html", page_title=f"View Files ({OPT.path_type} Path)", OPT=OPT, people=people, move_paths=move_paths, query_data=query_data )
js_vers = getVersions()
return render_template("files.html", page_title=f"View Files ({OPT.path_type} Path)", OPT=OPT, query_data=query_data, js_vers=js_vers )
################################################################################
# search -> GET version -> has search_term in the URL and is therefore able to
@@ -467,11 +547,9 @@ def files_rbp():
def search(search_term):
OPT=States( request )
OPT.search_term = search_term
OPT.folders = False
query_data=GetSearchQueryData( OPT )
move_paths = MovePathDetails()
return render_template("files.html", page_title='View Files', search_term=search_term, query_data=query_data, OPT=OPT, move_paths=move_paths )
js_vers = getVersions()
return render_template("files.html", page_title='View Files', search_term=search_term, query_data=query_data, OPT=OPT, js_vers=js_vers )
################################################################################
# /files/scan_ip -> allows us to force a check for new files
@@ -570,7 +648,7 @@ def restore_files():
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) )
job=NewJob( name="restore_files", num_files=0, wait_for=None, jex=jex, desc="to restore selected file(s)" )
return redirect("/jobs")
return jsonify( job=job_schema.dump(job) )
################################################################################
# /delete_files -> create a job to delete files for the b/e to process
@@ -583,7 +661,7 @@ def delete_files():
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) )
job=NewJob( name="delete_files", num_files=0, wait_for=None, jex=jex, desc="to delete selected file(s)" )
return redirect("/jobs")
return jsonify( job=job_schema.dump(job) )
################################################################################
# /move_files -> create a job to move files for the b/e to process
@@ -596,8 +674,7 @@ def move_files():
for el in request.form:
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) )
job=NewJob( name="move_files", num_files=0, wait_for=None, jex=jex, desc="to move selected file(s)" )
# data is not used, but send response to trigger CheckForJobs()
return make_response( jsonify( job_id=job.id ) )
return jsonify( job=job_schema.dump(job) )
@login_required
@app.route("/view/", methods=["POST"])
@@ -624,32 +701,9 @@ def view():
data=db.session.execute(stmt).unique().scalars().all()
return jsonify(entries_schema.dump(data))
####
"""
# process any overrides
for face in e.file_details.faces:
# now get any relevant override and store it in objs...
fnmo = FaceNoMatchOverride.query.filter(FaceNoMatchOverride.face_id==face.id).first()
if fnmo:
face.no_match_override=fnmo
mo = FaceForceMatchOverride.query.filter(FaceForceMatchOverride.face_id==face.id).first()
if mo:
mo.type = FaceOverrideType.query.filter( FaceOverrideType.name== 'Manual match to existing person' ).first()
face.manual_override=mo
NMO_data = FaceOverrideType.query.all()
setting = Settings.query.first()
imp_path = setting.import_path
st_path = setting.storage_path
bin_path = setting.recycle_bin_path
# print( f"BUG-DEBUG: /view/id GET route - OPT={OPT}, eids={eids}, current={int(id)} ")
return render_template("viewer.html", current=int(id), eids=eids, objs=objs, OPT=OPT, NMO_data=NMO_data, imp_path=imp_path, st_path=st_path, bin_path=bin_path )
"""
# route called from front/end - if multiple images are being transformed, each transorm == a separate call
# to this route (and therefore a separate transorm job. Each reponse allows the f/e to check the
# specific transorm job is finished (/check_transform_job) which will be called (say) every 1 sec. from f/e
# specific transorm job is finished (/check_amend_job_status) which will be called (say) every 1 sec. from f/e
# with a spinning wheel, then when pa_job_mgr has finished it will return the transformed thumb
@app.route("/transform", methods=["POST"])
@login_required
@@ -662,25 +716,29 @@ def transform():
jex.append( JobExtra( name=f"{el}", value=str(request.form[el]) ) )
job=NewJob( name="transform_image", num_files=0, wait_for=None, jex=jex, desc="to transform selected file(s)" )
return 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
# b/e to finish the transform job. Once done, the new / now
# transformed image's thumbnail is returned so the f/e can
# update with it
# /check_amend_job_status -> URL that is called repeatedly by front-end waiting
# for the b/e to finish the amendment job (delete/restore/move file).
# Once done, return "ok"
################################################################################
@app.route("/check_transform_job", methods=["POST"])
@app.route("/check_amend_job_status", methods=["POST"])
@login_required
def check_transform_job():
def check_amend_job_status():
job_id = request.form['job_id']
job = Job.query.get(job_id)
j=jsonify( finished=False )
if job.pa_job_state == 'Completed':
id=[jex.value for jex in job.extra if jex.name == "id"][0]
e=Entry.query.join(File).filter(Entry.id==id).first()
j=jsonify( finished=True, thumbnail=e.file_details.thumbnail )
return make_response( j )
stmt = select(Job).options(joinedload(Job.amendments)).where(Job.id == job_id)
job=db.session.execute(stmt).unique().scalars().first()
# FIXME: should validate job_id is real from UI
if job.name == 'transform_image':
eid=[jex.value for jex in job.extra if jex.name == "id"][0]
stmt=select(Entry).where(Entry.id==eid)
ent=db.session.execute(stmt).scalars().all()
ent_data=entries_schema.dump(ent)
j=jsonify(finished=(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
@@ -726,7 +784,7 @@ def get_existing_paths(dt):
except:
# this is not a date, so we cant work out possible dirs, just
# return an empty set
return make_response( '[]' )
return jsonify( '[]' )
new_dt=new_dtime.strftime('%Y%m%d')
# find dirs named with this date
dirs_arr+=Dir.query.filter(Dir.rel_path.ilike('%'+new_dt+'%')).all();
@@ -740,8 +798,8 @@ def get_existing_paths(dt):
ret='[ '
first_dir=1
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
# regardless, it cant be used for a existpath button in the F/E, ignore it
# 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 existing path button in the F/E, ignore it
if dir.rel_path == '':
continue
if not first_dir:
@@ -759,4 +817,18 @@ def get_existing_paths(dt):
ret+= ' } '
first_dir=0
ret+= ' ]'
return make_response( ret )
return jsonify ( ret )
# quick helper func to return timestamps of jscript files
# we use this as a quick/hacky way of versioning them
def getVersions():
js_vers={}
js_vers['fs'] = int(os.path.getmtime( "."+url_for( 'internal', filename='js/files_support.js') ))
js_vers['vs'] = int(os.path.getmtime( "."+url_for( 'internal', filename='js/view_support.js') ))
js_vers['ft'] = int(os.path.getmtime( "."+url_for( 'internal', filename='js/files_transform.js') ))
js_vers['ic'] = int(os.path.getmtime( "."+url_for( 'internal', filename='icons.svg') ))
js_vers['r180'] = int(os.path.getmtime( "."+url_for( 'internal', filename='rot180.png') ))
js_vers['r270'] = int(os.path.getmtime( "."+url_for( 'internal', filename='rot270.png') ))
js_vers['r90'] = int(os.path.getmtime( "."+url_for( 'internal', filename='rot90.png') ))
js_vers['th'] = int(os.path.getmtime( "."+url_for( 'internal', filename='throbber.gif') ))
return js_vers

View File

@@ -161,13 +161,13 @@
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>
</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'/>
</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'/>
</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"/>
</svg>
<svg id="unknown_ftype" fill="grey" viewBox="0 0 16 16">
@@ -208,4 +208,7 @@
<path d="M4.406 1.342A5.53 5.53 0 0 1 8 0c2.69 0 4.923 2 5.166 4.579C14.758 4.804 16 6.137 16 7.773 16 9.569 14.502 11 12.687 11H10a.5.5 0 0 1 0-1h2.688C13.979 10 15 8.988 15 7.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 2.825 10.328 1 8 1a4.53 4.53 0 0 0-2.941 1.1c-.757.652-1.153 1.438-1.153 2.055v.448l-.445.049C2.064 4.805 1 5.952 1 7.318 1 8.785 2.23 10 3.781 10H6a.5.5 0 0 1 0 1H3.781C1.708 11 0 9.366 0 7.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383z"/>
<path d="M7.646 15.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 14.293V5.5a.5.5 0 0 0-1 0v8.793l-2.146-2.147a.5.5 0 0 0-.708.708l3 3z"/>
</svg>
<svg id="back" viewBox="0 0 16 16">
<path d="m7.247 4.86-4.796 5.481c-.566.647-.106 1.659.753 1.659h9.592a1 1 0 0 0 .753-1.659l-4.796-5.48a1 1 0 0 0-1.506 0z"/>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because it is too large Load Diff

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 )
{
$('#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 );
}
document.amendments=document.amendments.filter(obj => obj.eid !== id)
}
// POST to a check URL, that will tell us if the transformation has completed,
// if not, try again in 1 second... If it has finished then reset the thumbnail
// to full colour, put it back to being an entry and reset the thumbnail to the
// newly created one that was sent back in the response to the POST
function handleTransformViewing(data,id,job_id)
// If Transform job has finished then reset relevant document.entries
// with updated from DB, remove the amendment and redraw image
function handleTransformImageJobCompleted(job, entry)
{
if( data.finished )
removeAmendment( entry.id )
// update viewer if we are viewing an image
if( document.viewing )
{
// stop throbber, remove grayscale & then force reload with timestamped version of im.src
grayscale=0
throbber=0
// force reload with timestamped version of im.src
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,
// if not, try again in 1 second... If it has finished then reset the thumbnail
// to full colour, put it back to being an entry and reset the thumbnail to the
// newly created one that was sent back in the response to the POST
function CheckTransformJob(id,job_id,successCallback)
{
CheckForJobs()
$.ajax( { type: 'POST', data: '&job_id='+job_id, url: '/check_transform_job', success: function(res) { successCallback(res,id,job_id); } } )
// ALWAYS update files* div as we could go back to this from a viewer, and
// the thumbnail needs the updated data
idx = entryList.indexOf(entry.id)
// replace data for this entry now its been transformed
document.entries[idx]=entry
// redraw into figure html in dom
html = createFigureHtml( entry )
$('#'+entry.id).replaceWith( html )
}
// for each highlighted image, POST the transform with amt (90, 180, 270,
@@ -52,19 +36,24 @@ function CheckTransformJob(id,job_id,successCallback)
function Transform(amt)
{
// we are in the viewer with 1 image only...
if( document.viewing )
if( $('#viewer_div').length && ! $('#viewer_div').hasClass('d-none') )
{
post_data = '&amt='+amt+'&id='+document.viewing.id
// send /transform for this image, grayscale the thumbmail, add color spinning wheel overlay, and start checking for job end
$.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; } })
// 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) {
processAmendments(data.job.amendments)
checkForAmendmentJobToComplete(data.job.id)
} })
}
else
{
$('.highlight').each(function( id, e ) {
$('.highlight').each(function( cnt, e ) {
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
$.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; } })
// 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){
processAmendments(data.job.amendments)
checkForAmendmentJobToComplete(data.job.id)
} })
} )
}
}

View File

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

View File

@@ -25,6 +25,8 @@ function NewHeight()
return im.height*gap / (im.width/window.innerWidth)
}
// draw 'str' as a label above the bounding box of the face (with a white
// transparent background to enhance readability of str)
function DrawLabelOnFace(str)
{
// finish face box, need to clear out new settings for // transparent backed-name tag
@@ -60,6 +62,11 @@ function DrawImg()
if( im.width == 0 )
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.height=NewHeight(im)
@@ -67,14 +74,32 @@ function DrawImg()
$('#img-cap').width(canvas.width)
// actually draw the pixel images to the canvas at the right size
if( grayscale )
if (!Array.isArray(am))
context.filter='grayscale(1)'
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
if( throbber )
$('#throbber').attr('style', 'display:show; position:absolute; left:'+canvas.width/2+'px; top:'+(canvas.height/2-50)+'px' )
if (!Array.isArray(am))
{
$('#throbber').show()
$('#white-circle').show()
if(am.type.which == 'img' )
{
$('#inside-img').attr('src', '/internal/'+am.type.what );
$('#inside-img').show()
}
else
$('#throbber').hide();
{
$('#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
if( $('#fname_toggle').prop('checked' ) )
@@ -117,10 +142,13 @@ function DrawImg()
context.lineWidth = 2
// this face has an override so diff colour
if( faces[i].override )
if( faces[i].fnmo.length || faces[i].ffmo.length )
{
context.strokeStyle = 'blue'
DrawLabelOnFace( faces[i].override.who )
if( faces[i].ffmo.length )
DrawLabelOnFace( faces[i].ffmo[0].person.tag )
else
DrawLabelOnFace( faces[i].fnmo[0].type.name )
}
else
{
@@ -161,7 +189,7 @@ function ViewImageOrVideo()
if( ! document.viewing ) return
if( document.viewing.type.name == 'Image' )
{
im.src='../' + document.viewing.FullPathOnFS
im.src='../' + document.viewing.FullPathOnFS + '?t=' + new Date().getTime();
$('#video_div').hide()
if( $('#fname_toggle').prop('checked' ) )
$('#img-cap').show()
@@ -192,6 +220,8 @@ function ViewImageOrVideo()
var offsetX,offsetY;
// find the edge of the canvas, so when we have a PAGE event with x,y we can see
// where we clicked in it (PAGE.x - canvas.x to see where in canvas, etc)
function reOffset()
{
var BB=$('#canvas').get(0).getBoundingClientRect();
@@ -199,23 +229,27 @@ function reOffset()
offsetY=BB.top;
}
window.onscroll=function(e){ reOffset(); }
window.onresize=function(e){ reOffset(); }
// when we are ready,
$(document).ready( function()
{
var cw=$('#canvas').width;
var ch=$('#canvas').height;
reOffset();
// if we scroll or resize the window, the canvas moves on the page, reset the offsets
window.onscroll=function(e){ reOffset(); }
window.onresize=function(e){ reOffset(); }
// clicking in the viewer canvas gets its own handlers to handle faces (or not)
$.contextMenu({
selector: '#canvas',
trigger: 'left',
// trigger: 'none',
hideOnSecondTrigger: true,
// go through each face, and add appropriate 'left-click' menu.
// e.g if known face, say name, offer add refimg to person, etc.
// this is a bit complex, the item_list var has a key (which is what we
// will do if we are chosen from the menu), and data to process the action
build: function($triggerElement, e) {
reOffset();
// get mouse position relative to the canvas (left-click uses page*)
@@ -234,7 +268,7 @@ $(document).ready( function()
if( x >= fx && x <= fx+fw && y >= fy && y <= fy+fh )
{
if( faces[i].override )
if( faces[i].ffmo.length || faces[i].fnmo.length )
{
item_list['remove_force_match_override']={ 'name': 'Remove override for this face', 'which_face': i, 'id': faces[i].id }
}
@@ -250,7 +284,7 @@ $(document).ready( function()
item_list['no_match_new_person']={ 'name': 'Add as reference image to NEW person', 'which_face': i, 'id': faces[i].id }
item_list['no_match_new_refimg']={ 'name': 'Add as reference image to EXISTING person', 'which_face': i, 'id': faces[i].id }
for( var el in NMO ) {
item_list['NMO_'+el]={'type_id': NMO[el].type_id, 'name': 'Override: ' + NMO[el].name, 'which_face': i, 'id': faces[i].id }
item_list['NMO_'+el]={'type_id': NMO[el].id, 'name': 'Override: ' + NMO[el].name, 'which_face': i, 'id': faces[i].id }
}
}
delete item_list['not_a_face']
@@ -270,26 +304,15 @@ $(document).ready( function()
} )
} );
// quick wrapper function to make calling this ajax code simpler in SearchForPerson
// POST to the server to force a match for this face to person_id
// FIXME: could I not pass person_id, and use // ...[item[key].which_face].refimg.person.id
function OverrideForceMatch( person_id, key )
{
// we have type_id passed in, so dig the NMO out, and use that below (its really just for name, but in case we change that in the DB)
for( el in NMO )
{
if( NMO[el].type_id == item[key].type_id )
{
fm_idx=el
break
}
}
ofm='&person_id='+person_id+'&face_id='+item[key].id
$.ajax({ type: 'POST', data: ofm, url: '/add_force_match_override', success: function(data) {
document.viewing.file_details.faces[item[key].which_face].override={}
document.viewing.file_details.faces[item[key].which_face].override.who=data.person_tag
document.viewing.file_details.faces[item[key].which_face].override.distance='N/A'
document.viewing.file_details.faces[item[key].which_face].override.type_id=NMO[fm_idx].id
document.viewing.file_details.faces[item[key].which_face].override.type_name=NMO[fm_idx].name
document.viewing.file_details.faces[item[key].which_face].ffmo=[]
document.viewing.file_details.faces[item[key].which_face].ffmo[0]={}
document.viewing.file_details.faces[item[key].which_face].ffmo[0].person=data.person
$('#dbox').modal('hide')
$('#faces').prop('checked',true)
DrawImg()
@@ -298,6 +321,23 @@ function OverrideForceMatch( person_id, key )
} )
}
// function that handles the POSTed data that comes back when we add a
// reference image to a new or existing person (right-click on a face)
// used in success callbacks from CreatePersonAndRefimg() and AddRefimgTo()
function handleAddRefimgData(key, data)
{
document.viewing.file_details.faces[item[key].which_face].refimg=data.refimg
document.viewing.file_details.faces[item[key].which_face].refimg_lnk={}
// if we used this img, for now set distance to 0 - it is an exact match!
document.viewing.file_details.faces[item[key].which_face].refimg_lnk.face_distance=0.0
$('#dbox').modal('hide')
$('#faces').prop('checked',true)
DrawImg()
CheckForJobs()
}
// when we right-click a face and make a new person, this code creates and
// associates the face
function CreatePersonAndRefimg( key )
{
d='&face_id='+item[key].id
@@ -306,29 +346,17 @@ function CreatePersonAndRefimg( key )
+'&surname='+$('#surname').val()
+'&refimg_data='+item[key].refimg_data
$.ajax({ type: 'POST', data: d, url: '/match_with_create_person',
success: function(data) {
document.viewing.file_details.faces[item[key].which_face].refimg.person.tag=data.who
document.viewing.file_details.faces[item[key].which_face].facefile_lnk.face_distance=data.distance
$('#dbox').modal('hide')
$('#faces').prop('checked',true)
DrawImg()
CheckForJobs()
}
success: function(data) { handleAddRefimgData(key, data ) },
})
}
// when we right-click a face and connect to an existing person, this connects
// the refimg and associates the face
function AddRefimgTo( person_id, key, search )
{
d='&face_id='+item[key].id+'&person_id='+person_id+'&refimg_data='+item[key].refimg_data+'&search='+search
$.ajax({ type: 'POST', data: d, url: '/add_refimg_to_person',
success: function(data) {
document.viewing.file_details.faces[item[key].which_face].refimg.person.tag=data.who
document.viewing.file_details.faces[item[key].which_face].facefile_lnk.face_distance=data.distance
$('#dbox').modal('hide')
$('#faces').prop('checked',true)
DrawImg()
CheckForJobs()
}
success: function(data) { handleAddRefimgData(key, data ) },
})
}
@@ -345,8 +373,7 @@ function SearchForPerson(content, key, face_id, face_pos, type_id)
for( var el in data ) {
content+='<div class="row">'
var person = data[el];
// NMO_1 is a non-match-override type_id==1 (or force match to existing person)
if( key == "NMO_1" )
if( item[key].name == "Override: Manual match to existing person" )
{
func='OverrideForceMatch('+person.id+',\''+key+'\' )'
content+= '<div class="col">' + person.tag + ' (' + person.firstname+' '+person.surname+ ') </div>'
@@ -369,17 +396,19 @@ function SearchForPerson(content, key, face_id, face_pos, type_id)
return false
}
// if we force a match, this func allows us to POST to the server to remove the override
function RemoveOverrideForceMatch(face_pos)
{
if( document.viewing.file_details.faces[face_pos].override )
who=document.viewing.file_details.faces[face_pos].override.who
if( document.viewing.file_details.faces[face_pos].ffmo.length )
who=document.viewing.file_details.faces[face_pos].ffmo[0].person.tag
else
who=document.viewing.file_details.faces[face_pos].refimg.person.tag
d='&face_id='+document.viewing.file_details.faces[face_pos].id+'&person_tag='+document.viewing.file_details.faces[face_pos].refimg.person.tag+'&file_eid='+document.viewing.id
d='&face_id='+document.viewing.file_details.faces[face_pos].id+'&person_tag='+who+'&file_eid='+document.viewing.id
$.ajax({ type: 'POST', data: d, url: '/remove_force_match_override',
success: function(data) {
delete document.viewing.file_details.faces[face_pos].override
// force/delete the ffmo cleanly
document.viewing.file_details.faces[face_pos].ffmo.length=0
$('#dbox').modal('hide')
DrawImg()
CheckForJobs()
@@ -389,12 +418,13 @@ function RemoveOverrideForceMatch(face_pos)
return false
}
// if we force NO match, this func allows us to POST to the server to remove the override
function RemoveOverrideNoMatch(face_pos, type_id)
{
d='&face_id='+document.viewing.file_details.faces[face_pos].id+'&type_id='+type_id
$.ajax({ type: 'POST', data: d, url: '/remove_no_match_override',
success: function(data) {
delete document.viewing.file_details.faces[face_pos].override
document.viewing.file_details.faces[face_pos].fnmo.length=0
$('#dbox').modal('hide')
DrawImg()
CheckForJobs()
@@ -404,16 +434,13 @@ function RemoveOverrideNoMatch(face_pos, type_id)
return false
}
// POST to the server to force NO match for this face
function AddNoMatchOverride(type_id, face_id, face_pos, type_id)
{
d='&type_id='+type_id+'&face_id='+face_id
$.ajax({ type: 'POST', data: d, url: '/add_no_match_override',
success: function(data) {
document.viewing.file_details.faces[face_pos].override={}
document.viewing.file_details.faces[face_pos].override.who=NMO[type_id].name
document.viewing.file_details.faces[face_pos].override.distance='N/A'
document.viewing.file_details.faces[face_pos].override.type_id=type_id
document.viewing.file_details.faces[face_pos].override.type_name=NMO[type_id].name
document.viewing.file_details.faces[face_pos].fnmo[0]=data
$('#dbox').modal('hide')
$('#faces').prop('checked',true)
DrawImg()
@@ -422,6 +449,9 @@ function AddNoMatchOverride(type_id, face_id, face_pos, type_id)
} )
}
// generate html for the appropriate content to search for a person when adding
// override DBox. has a button that when clicked calls SeachForPerson() which
// POSTs to the server, and fills in the 'search_person_results' div with content
function AddSearch( content, key, face_pos )
{
html='<h5>search for existing person:</h5>'
@@ -461,17 +491,17 @@ function FaceDBox(key, item)
div+='</div><div class="col-6">'
if ( key == 'remove_force_match_override' )
{
if( document.viewing.file_details.faces[face_pos].override.type_name == 'Manual match to existing person' )
div+='<div class="row col-12">remove this override (force match to: ' + document.viewing.file_details.faces[face_pos].override.who + ')</div>'
if( document.viewing.file_details.faces[face_pos].ffmo.length )
div+='<div class="row col-12">remove this override (force match to: ' + document.viewing.file_details.faces[face_pos].ffmo[0].person.tag + ')</div>'
else
div+='<div class="row col-12">remove this override (no match)</div>'
div+='<div class="row col-12">remove this override (' + document.viewing.file_details.faces[face_pos].fnmo[0].type.name + ')</div>'
div+='<div class="row">'
div+='<button class="btn btn-outline-info col-6" type="button" onClick="$(\'#dbox\').modal(\'hide\'); return false">Cancel</button>'
div+='<button class="btn btn-outline-danger col-6" type="button" '
if( document.viewing.file_details.faces[face_pos].override.type_name == 'Manual match to existing person' )
if( document.viewing.file_details.faces[face_pos].ffmo.length )
div+='onClick="RemoveOverrideForceMatch(' +face_pos+ ')">Remove</button>'
else
div+='onClick="RemoveOverrideNoMatch(' +face_pos+','+document.viewing.file_details.faces[face_pos].override.type_id+ ')">Remove</button>'
div+='onClick="RemoveOverrideNoMatch(' +face_pos+','+document.viewing.file_details.faces[face_pos].fnmo[0].type.id+ ')">Remove</button>'
div+='</div>'
}
if ( key == 'no_match_new_person' )
@@ -505,7 +535,6 @@ function FaceDBox(key, item)
func='AddRefimgTo('+item[key]['person_id']+',\''+key+'\''
func_sn=func+ ', true )'
func_ao=func+ ', false )'
div+=`<script>console.log( "AddExistingFaceAsRefimgToMatchedPerson()" )</script>`
div+="Confirm you wish to add this face as a reference image for " + item[key]['who']
div+= '<div class="col">' + item[key]['who'] + '</div><div class="col input-group">'
div+= '<button onClick="'+func_sn+'" class="btn btn-success py-1 input-group-prepend">Add & search now</button>&nbsp;'
@@ -564,7 +593,167 @@ function JoblogSearch()
})
}
// helper func to resert the src on the video div
function setVideoSource(newSrc) {
$('#videoSource').attr('src', newSrc);
$('#video')[0].load();
}
// function called when we get another page from inside the viewer
function getPageViewer(res, viewingIdx)
{
document.viewing=document.entries[viewingIdx]
// update viewing, arrows and image/video too
ViewImageOrVideo()
}
// handler used when we double click an entry to show it in the viewer
function dblClickToViewEntry(id) {
$('#files_div').addClass('d-none')
$('#viewer_div').removeClass('d-none')
setEntryById( id )
ViewImageOrVideo()
}
// quick function that allows us to go out of the viewer and back, the viewercomes from files_ip/sp
// so just redraw the page with drawPageOfFigures() as we have all the data
function goOutOfViewer()
{
// if this returns -1, we have used arrows to go onto a new page(s)
if( getPageNumberForId( $('#figures').find('.figure').first().prop('id') ) == -1 )
drawPageOfFigures()
// hide viewer div, then show files_div
$('#viewer_div').addClass('d-none')
$('#files_div').removeClass('d-none')
// no longer viewing an image too
document.viewing=null
}
// change the viewer to the previous entry (handle page change too)
function getPreviousEntry() {
var currentIndex = entryList.indexOf(document.viewing.id);
oldPageOffset=Math.floor(currentIndex / OPT.how_many)
if (currentIndex > 0) {
currentIndex--;
pageOffset=Math.floor(currentIndex / OPT.how_many)
currentIndex=currentIndex-(pageOffset*OPT.how_many)
// pref page, load it
if( oldPageOffset != pageOffset )
// pref page is pageOffset+1 now
getPage(pageOffset+1,getPageViewer,currentIndex)
else
document.viewing=document.entries[currentIndex]
}
}
// change the viewer to the next entry (handle page change too)
function getNextEntry() {
var currentIndex = entryList.indexOf(document.viewing.id);
oldPageOffset=Math.floor(currentIndex / OPT.how_many)
if (currentIndex < entryList.length - 1) {
currentIndex++
pageOffset=Math.floor(currentIndex / OPT.how_many)
currentIndex=currentIndex-(pageOffset*OPT.how_many)
// next page, load it
if( oldPageOffset != pageOffset )
// next page is pageOffset+1 now
getPage(pageOffset+1,getPageViewer,currentIndex)
else
document.viewing=document.entries[currentIndex]
}
}
// check if we are viewing the very first entry (helps to disable la)
function entryIsAtStart() {
return document.viewing.id === entryList[0];
}
// check if we are viewing the very last entry (helps to disable ra)
function entryIsAtEnd() {
return document.viewing.id === entryList[entryList.length - 1];
}
// helper func to ensure document.viewing is the right entry from document.entries array
function setEntryById(id) {
var currentIndex = entryList.indexOf(parseInt(id));
// if we are on a different page, adjust as document.entries only has <= how_many
pageOffset=Math.floor(currentIndex / OPT.how_many)
currentIndex = currentIndex-(pageOffset*OPT.how_many)
document.viewing=document.entries[currentIndex]
}
// disable la button if we are viewing first entry and/or ra button if we are viewing last entry
function setDisabledForViewingNextPrevBttons()
{
$('#la').attr('disabled', entryIsAtStart());
$('#ra').attr('disabled', entryIsAtEnd());
}
// when we go into the view, the keybindings are set here for items like 'f' for face box/name
function addViewerKeyHandler() {
// allow a keypress on the viewer_div
$(document).keydown(function(event) {
// if dbox is visible, dont process this hot-key, we are inputting text into inputs instead
if( $("#dbox").is(':visible') )
return
switch (event.key)
{
case "Left": // IE/Edge specific value
case "ArrowLeft":
$('#la').click()
break;
case "Right": // IE/Edge specific value
case "ArrowRight":
$('#ra').click()
break;
case "d":
$('#distance').click()
break;
case "f":
$('#faces').click()
break;
case "n":
$('#fname_toggle').click()
break;
case "F":
fullscreen=!document.fullscreen
ViewImageOrVideo()
break;
case "l":
JoblogSearch()
break;
case "Delete":
$('#del').click()
default:
return; // Quit when this doesn't handle the key event.
}
});
}
// left arrow onclick handler to go to prev image from inside the viewer
function prevImageInViewer()
{
getPreviousEntry()
setDisabledForViewingNextPrevBttons()
ViewImageOrVideo()
}
// right arrow onclick handler to go to next image from inside the viewer
function nextImageInViewer()
{
getNextEntry()
setDisabledForViewingNextPrevBttons()
ViewImageOrVideo()
}
// wrapper func to start the viewer - needed as we have a dbl-click & View file
// to start the viewer
function startViewing(eid)
{
dblClickToViewEntry( eid );
setDisabledForViewingNextPrevBttons();
addViewerKeyHandler()
}

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 main import db, app, ma
from sqlalchemy import Sequence, func, select
from sqlalchemy.orm import joinedload
from sqlalchemy.exc import SQLAlchemyError
from datetime import datetime, timedelta
import pytz
import socket
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 sqlalchemy.dialects.postgresql import INTERVAL
from sqlalchemy.sql.functions import concat
@@ -57,10 +59,12 @@ class Job(db.Model):
extra = db.relationship( "JobExtra")
logs = db.relationship( "Joblog")
amendments = db.relationship("EntryAmendment", back_populates="job")
def __repr__(self):
return "<id: {}, start_time: {}, last_update: {}, name: {}, state: {}, num_files: {}, current_file_num: {}, current_file: {}, pa_job_state: {}, wait_for: {}, extra: {}, logs: {}>".format(self.id, self.start_time, self.last_update, self.name, self.state, self.num_files, self.current_file_num, self.current_file, self.pa_job_state, self.wait_for, self.extra, self.logs)
################################################################################
# Class describing PA_JobManager_Message and in the DB (via sqlalchemy)
# the job manager can send a message back to the front end (this code) via the
@@ -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
# active jobs being processed in the background
################################################################################
def GetNumActiveJobs():
def getNumActiveJobs():
ret=Job.query.filter(Job.pa_job_state != 'Completed').with_entities(func.count(Job.id).label('count') ).first()
return ret[0]
@@ -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.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)
return job
@@ -309,14 +328,22 @@ def joblog_search():
@app.route("/check_for_jobs", methods=["POST"])
@login_required
def check_for_jobs():
num=GetNumActiveJobs()
from files import job_schemas
num=getNumActiveJobs()
messages = PA_JobManager_Message.query.all()
sts=[]
for msg in PA_JobManager_Message.query.all():
for msg in messages:
u=''
if 'Job #' not in msg.message and msg.job_id:
u='<a class="link-light" href="' + url_for('joblog', id=msg.job_id) + '">Job #' + str(msg.job_id) + '</a>: '
sts.append( { 'id': msg.id, 'message': u+msg.message, 'level': msg.level, 'job_id': msg.job_id, 'persistent': msg.persistent, 'cant_close': msg.cant_close } )
return make_response( jsonify( num_active_jobs=num, sts=sts ) )
# get jobs mentioned in messages as we may need to process the by client for UI
job_list=[obj.job_id for obj in messages]
stmt = select(Job).options(joinedload(Job.amendments)).where(Job.id.in_(job_list))
jobs=db.session.execute(stmt).unique().scalars().all()
return make_response( jsonify( num_active_jobs=num, sts=sts, jobs=job_schemas.dump(jobs) ) )
###############################################################################
# /clear_msg -> POST -> clears out a F/E message based on passed in <id>

View File

@@ -66,8 +66,6 @@ app.config['LDAP_USER_DN'] = 'ou=users'
app.config['LDAP_GROUP_DN'] = 'ou=groups'
app.config['LDAP_USER_RDN_ATTR'] = 'uid'
app.config['LDAP_USER_LOGIN_ATTR'] = 'uid'
app.config['LDAP_BIND_USER_DN'] = None
app.config['LDAP_BIND_USER_PASSWORD'] = None
app.config['LDAP_GROUP_OBJECT_FILTER'] = '(objectclass=posixGroup)'
app.config['LDAP_BIND_USER_DN'] = None
app.config['LDAP_BIND_USER_PASSWORD'] = None
@@ -246,6 +244,10 @@ def logout():
logout_user()
return redirect('/login')
# quick health route so traefik knows we are up
@app.route('/health')
def health():
return {"status": "ok"}, 200
###############################################################################
# main to be called via Flask/Gunicorn

View File

@@ -1,4 +1,3 @@
#
# This file controls the 'external' job control manager, that (periodically #
# looks / somehow is pushed an event?) picks up new jobs, and processes them.
@@ -15,7 +14,7 @@
### SQLALCHEMY IMPORTS ###
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.orm import relationship
from sqlalchemy import create_engine
@@ -23,7 +22,7 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import scoped_session
### 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
### PYTHON LIB IMPORTS ###
@@ -46,6 +45,8 @@ import re
import sys
import ffmpeg
import subprocess
# FIXME: remove this
import time
# global debug setting
@@ -512,6 +513,15 @@ class PA_JobManager_FE_Message(Base):
def __repr__(self):
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
@@ -1863,9 +1873,22 @@ def JobRunAIOn(job):
FinishJob(job, "Finished Processesing AI")
return
################################################################################
# removeEntryAmendment(): helper routine to remove an Etnry Amendment for a
# given job and eid (called after Transform or Delete/Restore/Move files
################################################################################
def removeEntryAmendment( job, eid ):
# now remove the matching amendment for the transform job
stmt=select(EntryAmendment).where(EntryAmendment.eid==eid)
ea=session.execute(stmt).scalars().one_or_none()
if ea:
session.delete(ea)
else:
AddLogForJob( job, f"ERROR: failed to remove entry amendment in DB for this transformation? (eid={id})" )
PAprint( f"ERROR: failed to remove entry amendment in DB for this transformation? (eid={id}, job={job} )" )
####################################################################################################################################
# JobTransformImage(): transform an image by the amount requested (can also flip horizontal or vertical)
# TODO: should be JobTransformImage() ;)
####################################################################################################################################
def JobTransformImage(job):
JobProgressState( job, "In Progress" )
@@ -1874,6 +1897,11 @@ def JobTransformImage(job):
amt=[jex.value for jex in job.extra if jex.name == "amt"][0]
e=session.query(Entry).join(File).filter(Entry.id==id).first()
PAprint( f"JobTransformImage: job={job.id}, id={id}, amt={amt}" )
# cant transfer non-image, but may get here if multi-select includes non-Image
if e.type.name != 'Image':
removeEntryAmendment( job, id )
FinishJob(job, "Cannot rotate file as it is not an Image","Failed")
return
if amt == "fliph":
AddLogForJob(job, f"INFO: Flipping {e.FullPathOnFS()} horizontally" )
@@ -1897,6 +1925,10 @@ def JobTransformImage(job):
e.file_details.hash = md5( job, e )
PAprint( f"JobTransformImage DONE thumb: job={job.id}, id={id}, amt={amt}" )
session.add(e)
# any faces in this file are no longer valid, remove them
session.query(FaceFileLink).filter(FaceFileLink.file_eid==e.id).delete()
removeEntryAmendment( job, id )
FinishJob(job, "Finished Processesing image rotation/flip")
return
@@ -2174,6 +2206,7 @@ def JobMoveFiles(job):
if 'eid-' in jex.name:
move_me=session.query(Entry).get(jex.value)
MoveEntriesToOtherFolder( job, move_me, dst_storage_path, f"{prefix}{suffix}" )
removeEntryAmendment( job, move_me.id )
NewJob( name="check_dups", num_files=0, wait_for=None, jex=None, parent_job=None, desc="check for duplicate files" )
FinishJob(job, f"Finished move selected file(s)")
return
@@ -2188,6 +2221,7 @@ def JobDeleteFiles(job):
if 'eid-' in jex.name:
del_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first()
MoveFileToRecycleBin(job,del_me)
removeEntryAmendment(job,del_me.id)
NewJob( name="check_dups", num_files=0, wait_for=None, jex=None, parent_job=None, desc="check for duplicate files" )
FinishJob(job, f"Finished deleting selected file(s)")
return
@@ -2202,6 +2236,7 @@ def JobRestoreFiles(job):
if 'eid-' in jex.name:
restore_me=session.query(Entry).join(File).filter(Entry.id==jex.value).first()
RestoreFile(job,restore_me)
removeEntryAmendment(job,restore_me.id)
NewJob( name="check_dups", num_files=0, wait_for=None, jex=None, parent_job=None, desc="check for duplicate files" )
FinishJob(job, f"Finished restoring selected file(s)")
return
@@ -2707,7 +2742,6 @@ def ScheduledJobs():
created_jobs=True
return created_jobs
####################################################################################################################################
# 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()

50
path.py
View File

@@ -42,53 +42,3 @@ class Path(db.Model):
def __repr__(self):
return f"<id: {self.id}, path_prefix: {self.path_prefix}, num_files={self.num_files}, type={self.type}>"
################################################################################
# Class describing PathDetail (quick connvenence class for MovePathDetails())
################################################################################
class PathDetail(PA):
"""Class describing details of a Path [internal class used in MovePathDetais()]"""
def __init__(self,ptype,path):
"""Initialisation function for PathDetail class
Args:
id (int): database id of row in PathDetail table / primary key
ptype (int): database id of row in PathType table / foreign key
"""
self.type:int=ptype
self.path:str=path
# construct icon_url based on type of storage path (icons.svg contains icons for each)
self.icon_url:str=url_for("internal", filename="icons.svg") + "#" + ICON[self.type]
def to_dict(self):
return {key: value for key, value in vars(self).items()}
################################################################################
# helper function to find path details for move destinations - used in html
# for move DBox to show potential storage paths to move files into
################################################################################
def MovePathDetails():
"""helper function to find path details for move destinations
used in html/javascript for move Dialog Box to show potential storage paths to move files into
Args:
None
Returns:
ret (List[PathDetail]): a list of Path Details for where files can be moved
"""
ret=[]
sps=Path.query.join(PathType).filter(PathType.name=="Storage").all()
for p in sps:
obj = PathDetail( ptype="Storage", path=p.path_prefix.replace("static/Storage/","") )
ret.append( obj.to_dict() )
ips=Path.query.join(PathType).filter(PathType.name=="Import").all()
for p in ips:
obj = PathDetail( ptype="Import", path=p.path_prefix.replace("static/Import/","") )
ret.append( obj.to_dict() )
return ret

View File

@@ -3,8 +3,9 @@ from flask_wtf import FlaskForm
from flask import request, render_template, redirect, url_for, make_response, jsonify
from main import db, app, ma
from settings import Settings, AIModel
from sqlalchemy import Sequence, func
from sqlalchemy import Sequence, func, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import joinedload
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from shared import GenFace, GenThumb, PA
@@ -114,7 +115,7 @@ def AddRefimgToPerson( filename, person ):
SetFELog( f"<b>Failed to add Refimg:</b>&nbsp;{e.orig}", "danger" )
except Exception as e:
SetFELog( f"<b>Failed to modify Refimg:</b>&nbsp;{e}", "danger" )
return
return refimg
################################################################################
# TempRefimgFile: helper function that takes data POST'd (from dialog box to
@@ -182,9 +183,12 @@ def match_with_create_person():
p = Person( tag=request.form["tag"], surname=request.form["surname"], firstname=request.form["firstname"] )
# add this fname (of temp refimg) to person
fname=TempRefimgFile( request.form['refimg_data'], p.tag )
AddRefimgToPerson( fname, p )
r=AddRefimgToPerson( fname, p )
SetFELog( f"Created person: {p.tag}" )
return make_response( jsonify( who=p.tag, distance='0.0' ) )
refimg_schema=RefimgSchema(many=False)
r_data=refimg_schema.dump(r)
return make_response( jsonify( refimg=r_data, who=p.tag, distance='0.0' ) )
################################################################################
# /person/<id> -> GET/POST(save or delete) -> shows/edits/delets a single person
@@ -267,7 +271,7 @@ def add_refimg():
except Exception as e:
SetFELog( f"<b>Failed to load reference image:</b>&nbsp;{e}", "danger" )
AddRefimgToPerson( fname, person )
r=AddRefimgToPerson( fname, person )
return redirect( url_for( 'person', id=person.id) )
################################################################################
@@ -289,6 +293,29 @@ def find_persons(who):
return make_response( resp )
class FaceRefimgLinkSchema(ma.SQLAlchemyAutoSchema):
class Meta: model = FaceRefimgLink
face_distance = ma.auto_field() # Explicitly include face_distance
load_instance = True
class PersonSchema(ma.SQLAlchemyAutoSchema):
class Meta: model=Person
load_instance = True
class RefimgSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Refimg
exclude = ('face',)
load_instance = True
person = ma.Nested(PersonSchema)
class FaceSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model=Face
exclude = ('face',)
load_instance = True
refimg = ma.Nested(RefimgSchema,allow_none=True)
refimg_lnk = ma.Nested(FaceRefimgLinkSchema,allow_none=True)
################################################################################
# /add_refimg_to_person/ -> POST
@@ -296,12 +323,19 @@ def find_persons(who):
@app.route("/add_refimg_to_person", methods=["POST"])
@login_required
def add_refimg_to_person():
f = Face.query.get( request.form['face_id'] )
p = Person.query.get( request.form['person_id'] )
stmt = select(Face).options( joinedload(Face.refimg_lnk) ).where(Face.id == request.form['face_id'])
f=db.session.execute(stmt).scalars().first()
stmt = select(Person).options( joinedload(Person.refimg) ).where(Person.id == request.form['person_id'])
p=db.session.execute(stmt).scalars().first()
# add this fname (of temp refimg) to person
fname=TempRefimgFile( request.form['refimg_data'], p.tag )
AddRefimgToPerson( fname, p )
r=AddRefimgToPerson( fname, p )
# connect the refimg to the face in the db, now we have added this refimg to the person
frl=FaceRefimgLink( face_id=f.id, refimg_id=r.id, face_distance=0 )
db.session.add(frl)
db.session.commit()
if request.form['search'] == "true":
jex=[]
@@ -316,7 +350,12 @@ def add_refimg_to_person():
jex.append( JobExtra( name=f"path_type", value=str(ptype.id) ) )
job=NewJob( name="run_ai_on_path", num_files=0, wait_for=None, jex=jex, desc="Look for face(s) in storage path(s)" )
return make_response( jsonify( who=p.tag, distance='0.0' ) )
refimg_schema=RefimgSchema(many=False)
r_data=refimg_schema.dump(r)
frl_schema=FaceRefimgLinkSchema(many=False)
frl_data=refimg_schema.dump(r)
return make_response( jsonify( refimg=r_data, frl=frl_data ) )
################################################################################
# /add_force_match_override -> POST
@@ -346,7 +385,9 @@ def add_force_match_override():
NewJob( "metadata", num_files=0, wait_for=None, jex=jex, desc="create metadata for adding forced match" )
# this will reply to the Ajax / POST, and cause the page to re-draw with new face override to person_tag
return make_response( jsonify( person_tag=p.tag ) )
person_schema = PersonSchema(many=False)
p_data = person_schema.dump(p)
return make_response( jsonify( person=p_data ) )
################################################################################
# /remove_force_match_override -> POST
@@ -397,6 +438,11 @@ def remove_no_match_override():
return make_response( jsonify( face_id=face_id ) )
class FaceOverrideTypeSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = FaceOverrideType
load_instance = True
################################################################################
# /add_no_match_override -> POST
################################################################################
@@ -424,5 +470,6 @@ def add_no_match_override():
# dont do status update here, the F/E is in the middle of a dbox, just send metadata through to the B/E
NewJob( "metadata", num_files=0, wait_for=None, jex=jex, desc="create metadata for adding forced non-match" )
# this will reply to the Ajax / POST, and cause the page to re-draw with new face override to person_tag
return make_response( jsonify( type=t.name ) )
fot_schema = FaceOverrideTypeSchema(many=False)
t_data=fot_schema.dump(t)
return make_response( jsonify( type_id=t.id, type=t_data ) )

View File

@@ -27,10 +27,9 @@ class PA_UserState(db.Model):
folders = db.Column(db.Boolean, unique=False, nullable=False )
root = db.Column(db.String, unique=False, nullable=False )
cwd = db.Column(db.String, unique=False, nullable=False )
search_term = db.Column(db.String, unique=False, nullable=False )
def __repr__(self):
return f"<pa_user_dn: {self.pa_user_dn}, path_type: {self.path_type}, noo: {self.noo}, grouping: {self.grouping}, how_many: {self.how_many}, size: {self.size}, folders: {self.folders}, root: {self.root}, cwd: {self.cwd}, search_term: {self.orig_search_term}>"
return f"<pa_user_dn: {self.pa_user_dn}, path_type: {self.path_type}, noo: {self.noo}, grouping: {self.grouping}, how_many: {self.how_many}, size: {self.size}, folders: {self.folders}, root: {self.root}, cwd: {self.cwd}>"
################################################################################

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_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?
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 ( 2, 'CNN', 'MORE ACCURATE / MUCH SLOWER' );
INSERT INTO ai_model VALUES ( 1, 'hog', 'normal' );
INSERT INTO ai_model VALUES ( 2, 'cnn', 'more accurate / much slower' );
CREATE TABLE settings(
id INTEGER,
@@ -50,11 +50,10 @@ CREATE TABLE pa_user(
default_storage_folders BOOLEAN,
CONSTRAINT pk_pa_user_id PRIMARY KEY(id) );
-- this is totally not 3rd normal form, but when I made it that, it was so complex, it was stupid
-- so for the little data here, I'm deliberately doing a redundant data structure
-- FIXME: NEED TO RETHINK THIS, not sure this even needs to be in the DB
CREATE TABLE pa_user_state ( id INTEGER, pa_user_dn VARCHAR(128), path_type VARCHAR(16),
noo VARCHAR(16), grouping VARCHAR(16), how_many INTEGER, size INTEGER, folders BOOLEAN,
root VARCHAR, cwd VARCHAR, search_term VARCHAR,
root VARCHAR, cwd VARCHAR,
CONSTRAINT fk_pa_user_dn FOREIGN KEY (pa_user_dn) REFERENCES pa_user(dn),
CONSTRAINT pk_pa_user_states_id PRIMARY KEY(id ) );
@@ -123,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) );
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')), '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')), 'iGNORE FACE' );
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')), 'Too young' );
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
-- 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.
@@ -166,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) );
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) );
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
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')), 'bIN' );
INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'mETADATA' );
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')), 'Bin' );
INSERT INTO path_type VALUES ( (SELECT NEXTVAL('path_type_id_seq')), 'Metadata' );
-- 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')), 'vIDEO' );
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')), 'Image' );
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')), 'Unknown' );
-- fake data only for making testing easier
--INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'dad', 'Damien', 'De Paoli' );
@@ -187,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')), 'mich', 'Michelle', 'De Paoli' );
-- 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):
--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:

View File

@@ -1,5 +1,5 @@
{% extends "base.html" %} {% block main_content %}
<script src="{{ url_for( 'internal', filename='js/files_support.js')}}"></script>
<script src="{{ url_for( 'internal', filename='js/files_support.js')}}?v={{js_vers['fs']}}"></script>
<div class="container-fluid">
<h3 class="offset-2">{{page_title}}</h3>
@@ -9,28 +9,28 @@
{{CreateSelect( "how_many", OPT.how_many|string, ["10", "25", "50", "75", "100", "150", "200", "500"], "changeOPT(getPageFileList); return false", "rounded-end py-1 my-1" )|safe }}
<div class="mb-1 col my-auto d-flex justify-content-center">
<button id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary" onClick="prevPage(getPageFileList)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#prev"/></svg>
</button>
<span class="how_many_text sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
<button id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage(getPageFileList)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#next"/></svg>
</button>
</div class="col...">
</div class="input-group...">
</div class="col col-auto">
</div class="container">
<div id="file_list_div" class="container-fluid">
<div id="file_list_div" class="container-fluid pt-2">
</div class="container">
<div class="container-fluid">
<input type="hidden" name="cwd" id="cwd" value="{{OPT.cwd}}">
<div class="row">
<div class="col my-auto d-flex justify-content-center">
<button aria-label="prev" id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary disabled" onClick="prevPage(getPageFileList)" disabled>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#prev"/></svg>
</button>
<span class="how_many_text sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
<button aria-label="next" id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage(getPageFileList)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#next"/></svg>
</button>
</div class="col my-auto"> </div class="row">
</div class="container-fluid">
@@ -40,6 +40,9 @@
// this is the list of entry ids for the images for ALL matches for this query
var entryList={{query_data.entry_list}}
var OPT = {{ OPT.to_dict()|tojson }};
// set from query data and stored in OPT for convenience. It can be 0 -
// this implies no content in the Path at all
OPT.root_eid = {{ query_data.root_eid }};
// pageList is just those entries shown on this page from the full entryList
var pageList=[]

View File

@@ -2,6 +2,11 @@
{% block main_content %}
<style>
@media (max-width: 576px) {
#la, #ra {
padding: 5% !important;
}
}
.norm-txt { font-size: 1.0rem }
.form-check-input:checked {
background-color: #39C0ED;
@@ -15,9 +20,9 @@
}
#tst90:hover,#tst90:focus { filter: invert(73%) sepia(27%) saturate(3970%) hue-rotate(146deg) brightness(94%) contrast(100%); }
</style>
<script src="{{ url_for( 'internal', filename='js/files_transform.js')}}"></script>
<script src="{{ url_for( 'internal', filename='js/files_support.js')}}"></script>
<script src="{{ url_for( 'internal', filename='js/view_support.js')}}"></script>
<script src="{{ url_for( 'internal', filename='js/files_transform.js')}}?v={{ js_vers['ft'] }}"></script>
<script src="{{ url_for( 'internal', filename='js/files_support.js')}}?v={{ js_vers['fs'] }}"></script>
<script src="{{ url_for( 'internal', filename='js/view_support.js')}}?v={{ js_vers['vs'] }}"></script>
<div id="files_div">
<div class="container-fluid">
@@ -26,13 +31,13 @@
<div class="my-auto col col-auto">
<span class="alert alert-primary py-2">
{% if "files_ip" in request.url %}
<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#import"/></svg>
<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#import"/></svg>
{% set tmp_path=OPT.cwd | replace( "static/Import", "" ) + "/" %}
{% elif "files_sp" in request.url %}
<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#db"/></svg>
<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#db"/></svg>
{% set tmp_path=OPT.cwd | replace( "static/Storage", "" ) + "/" %}
{% elif "files_rbp" in request.url %}
<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#trash"/></svg>
<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#trash"/></svg>
{% set tmp_path=OPT.cwd | replace( "static/Bin", "" ) + "/" %}
{% endif %}
{{tmp_path}}</span>
@@ -58,21 +63,21 @@
{% endif %}
<div class="col flex-grow-1 my-auto d-flex justify-content-center w-100">
<button aria-label="prev" id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary disabled" onClick="prevPage(getPageFigures)" disabled>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#prev"/></svg>
</button>
<span class="how_many_text sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
<button aria-label="next" id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage(getPageFigures)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#next"/></svg>
</button>
<button aria-label="move" id="move" disabled name="move" class="sm-txt btn btn-outline-primary ms-4" onClick="MoveDBox(move_paths,'{{url_for('internal', filename='icons.svg')}}'); return false;">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#folder_plus"/></svg>
<button aria-label="move" id="move" disabled name="move" class="sm-txt btn btn-outline-primary ms-4" onClick="MoveDBox(move_paths); return false;">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#folder_plus"/></svg>
</button>
{% if "files_rbp" in request.url %}
<button aria-label="delete" id="del" disabled name="del" class="sm-txt btn btn-outline-success mx-1" onClick="DelDBox('Restore'); return false;">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#trash-fill"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#trash-fill"/></svg>
{% else %}
<button aria-label="delete" id="del" disabled name="del" class="sm-txt btn btn-outline-danger mx-1" onClick="DelDBox('Delete'); return false;">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#trash-fill"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#trash-fill"/></svg>
{% endif %}
</button>
<button style="visibility:hidden" class="btn btn-outline-secondary" aria-label="shift-key" id="shift-key" onclick="document.fake_shift=1-document.fake_shift; event.stopPropagation(); return false">shift</button>
@@ -104,11 +109,11 @@
<div class="row">
<div class="col my-auto d-flex justify-content-center">
<button aria-label="prev" id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary disabled" onClick="prevPage(getPageFigures)" disabled>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#prev"/></svg>
</button>
<span class="how_many_text sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
<button aria-label="next" id="next" name="next" class="next sm-txt btn btn-outline-secondary" onClick="nextPage(getPageFigures)">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#next"/></svg>
</button>
</div class="col my-auto">
</div class="row">
@@ -116,20 +121,33 @@
</div id="files_div">
<div id="viewer_div" class="d-none">
<div id="viewer" class="container-fluid">
<div class="row">
<button title="Show previous image" class="col-auto btn btn-outline-info px-2"
style="padding: 10%" id="la"
onClick="
getPreviousEntry()
setDisabledForViewingNextPrevBttons()
ViewImageOrVideo()
">
<div class="row flex-nowrap">
<!-- Left Buttons Column -->
<div class="col-auto d-flex flex-column min-width-0">
<!-- Up Button (Small) -->
<button title="Back to list" class="btn btn-outline-info btn-sm p-1 mb-1" onclick="goOutOfViewer()">
<svg width="16" height="16" fill="currentColor">
<use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
<use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#back"></use>
</svg>
</button>
<figure class="col col-auto border border-info rounded m-0 p-1" id="figure">
<!-- Left Button (large/flex-grow-1) -->
<button title="Show previous image" class="btn btn-outline-info px-2 flex-grow-1 overflow-hidden"
style="padding: 10%" id="la" onClick="prevImageInViewer()">
<svg width="16" height="16" fill="currentColor">
<use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#prev"/></svg>
</button>
</div>
<figure style="position: relative;" class="col col-auto border border-info rounded m-0 p-1" id="figure">
<canvas id="canvas"></canvas>
<img id="throbber" src="{{url_for('internal', filename='throbber.gif')}}" 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>
var im=new Image();
im.onload=DrawImg
@@ -146,21 +164,27 @@
<figcaption id="vid-cap" class="figure-caption text-center text-wrap text-break">
<span id="fname_v"></span></figcaption>
</div>
<button title="Show next image" class="col-auto btn btn-outline-info px-2" style="padding: 10%" id="ra"
onClick="
getNextEntry()
setDisabledForViewingNextPrevBttons()
ViewImageOrVideo()
">
<!-- Right-hand Buttons Column -->
<div class="col-auto d-flex flex-column min-width-0">
<!-- Up Button (Small) -->
<button title="Back to list" class="btn btn-outline-info btn-sm p-1 mb-1" onclick="goOutOfViewer()">
<svg width="16" height="16" fill="currentColor">
<use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
<use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#back"></use>
</svg>
</button>
<!-- Right Button (large/flex-grow-1) -->
<button title="Show next image" class="btn btn-outline-info px-2 flex-grow-1 overflow-hidden"
style="padding: 10%" id="ra" onClick="nextImageInViewer()">
<svg width="16" height="16" fill="currentColor">
<use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#next"/></svg>
</button>
</div>
</div class="row">
<div class="row">
{# this whole div, just takes up the same space as the left button and is hidden for alignment only #}
<div class="col-auto px-0">
<button class="btn btn-outline-info px-2 invisible" disabled>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#next"/></svg>
</button>
</div>
<span class="col-auto my-auto">Show:</span>
@@ -183,35 +207,34 @@
</div>
<div class="col col-auto pt-1">
<button class="btn btn-outline-info p-1" title="Rotate by 90 degrees" onClick="Transform(90)">
<img src="{{url_for('internal', filename='rot90.png')}}" width="32" height="32" onMouseOver="this.src='{{url_for('internal', filename='rot90-invert.png')}}'"
onMouseOut="this.src='{{url_for('internal', filename='rot90.png')}}'" />
<img src="{{url_for('internal', filename='rot90.png')}}?v={{js_vers['r90']}}" width="32" height="32" onMouseOver="this.src='{{url_for('internal', filename='rot90-invert.png')}}'"
onMouseOut="this.src='{{url_for('internal', filename='rot90.png')}}?v={{js_vers['r90']}}'" />
</button>
<button class="btn btn-outline-info p-1" title="Rotate by 180 degrees" onClick="Transform(180)">
<img src="{{url_for('internal', filename='rot180.png')}}" width="32" height="32" onMouseOver="this.src='{{url_for('internal', filename='rot180-invert.png')}}'"
onMouseOut="this.src='{{url_for('internal', filename='rot180.png')}}'" />
<img src="{{url_for('internal', filename='rot180.png')}}?v={{js_vers['r180']}}" width="32" height="32" onMouseOver="this.src='{{url_for('internal', filename='rot180-invert.png')}}'"
onMouseOut="this.src='{{url_for('internal', filename='rot180.png')}}?v={{js_vers['r180']}}'" />
</button>
<button class="btn btn-outline-info p-1" title="Rotate by 270 degrees" onClick="Transform(270)">
<img src="{{url_for('internal', filename='rot270.png')}}" width="32" height="32" onMouseOver="this.src='{{url_for('internal', filename='rot270-invert.png')}}'"
onMouseOut="this.src='{{url_for('internal', filename='rot270.png')}}'" />
<img src="{{url_for('internal', filename='rot270.png')}}?v={{js_vers['r270']}}" width="32" height="32" onMouseOver="this.src='{{url_for('internal', filename='rot270-invert.png')}}'"
onMouseOut="this.src='{{url_for('internal', filename='rot270.png')}}?v={{js_vers['r270']}}'" />
</button>
<button class="btn btn-outline-info p-1" title="Flip horizontally" onClick="Transform('fliph')">
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#flip_h"/></svg>
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#flip_h"/></svg>
</button>
<button class="btn btn-outline-info p-1" title="Flip vertically" onClick="Transform('flipv')">
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#flip_v"/></svg>
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#flip_v"/></svg>
</button>
<button class="btn btn-outline-info p-1" title="View in Fullscreen mode (hotkey: F)" onClick="fullscreen=true; ViewImageOrVideo()">
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#fullscreen"/></svg>
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#fullscreen"/></svg>
</button>
<button class="btn btn-outline-info p-1" title="Show logs relating to this filename (hotkey: l)" onClick="JoblogSearch()">
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#log"/></svg>
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#log"/></svg>
</button>
<button class="btn btn-outline-info p-1" title="View Original" onClick="window.location='/'+document.viewing.FullPathOnFS">
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#download"/></svg>
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#download"/></svg>
</button>
<button id="del" class="btn btn-outline-danger p-1" title="Delete (hotkey: Del)"
onClick="$.ajax({ type: 'POST', data: '&eid-0='+document.viewing.id, url: '/delete_files', success: function(data){ window.location='/'; return false; } })">
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#trash"/></svg>
<button id="viewer_del" class="btn btn-outline-danger p-1" title="Delete (hotkey: Del)" onClick="DelDBox('Delete')">
<svg id="viewer_bin" width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#trash"/></svg>
</button>
</div>
</div class="row">
@@ -221,19 +244,30 @@
{% block script_content %}
<script>
// GLOBALS
document.fake_shift=0
document.fake_ctrl=0
// FIXME: used by viewer code - should probably get rid of this?
var fullscreen=false;
var move_paths = {{ move_paths|tojson }};
// GLOBALS
// this is the current entry (object) we are viewing - an image/video (used when we dbl-click to view & then in next/prev in view)
document.viewing=null;
var OPT = {{ OPT.to_dict()|tojson }};
// set from query data and stored in OPT for convenience. It can be 0 -
// this implies no content in the Path at all
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...
var move_paths = {{ query_data.move_paths|tojson }};
var NMO={{query_data.NMO|tojson}}
var people={{query_data.people|tojson}}
// this is the list of entry ids for the images for ALL matches for this query
var entryList={{query_data.entry_list}}
@@ -242,140 +276,62 @@
// force pageList to set pageList for & render the first page
getPage(1,getPageFigures)
// FIXME: doco, but also gather all globals together, many make them all document. to be obviously global (and add fullscreen)
// gap is used to keep some space around video in viewer - tbh, not sure why anymore
var gap=0.8
var grayscale=0
var throbber=0
var NMO=[]
var imp_path="static/Import/{{imp_path}}"
var st_path="static/Storage/{{st_path}}"
var bin_path="static/Bin/{{bin_path}}"
function PrettyFname(fname)
{
s='<span class="alert alert-secondary py-2">'
if( fname.indexOf( "static/Import" ) == 0 )
{
s+='<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#import"/></svg>'
tmp_path=fname.replace(imp_path,"" )
s+='<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#import"/></svg>'
tmp_path=fname.replace("statuc/Import","" )
}
if( fname.indexOf( "static/Storage" ) == 0 )
{
s+='<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#db"/></svg>'
s+='<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#db"/></svg>'
tmp_path=fname.replace("static/Storage","" )
}
if( fname.indexOf( "static/Bin" ) == 0 )
{
s+='<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#trash-fill"/></svg>'
s+='<svg width="20" height="20" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}?v={{js_vers['ic']}}#trash-fill"/></svg>'
tmp_path=fname.replace("static/Bin","" )
}
s+=tmp_path+'</span>'
return s
}
// different context menu on files
$.contextMenu({
selector: '.entry',
itemClickEvent: "click",
build: function($triggerElement, e) {
// 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 )
{
DoSel(e, e.currentTarget )
SetButtonState();
}
if( FiguresOrDirsOrBoth() == "figure" )
{
item_list = {
details: { name: "Details..." },
view: { name: "View File" },
sep: "---",
}
if( e.currentTarget.getAttribute('type') == 'Image' )
{
item_list['transform'] = {
name: "Transform",
items: {
"r90": { "name" : "Rotate 90 degrees" },
"r180": { "name" : "Rotate 180 degrees" },
"r270": { "name" : "Rotate 270 degrees" },
"fliph": { "name" : "Flip horizontally" },
"flipv": { "name" : "Flip vertically" }
}
}
}
item_list['move'] = { name: "Move selected file(s) to new folder" }
item_list['sep2'] = { sep: "---" }
}
else
item_list = {
move: { name: "Move selection(s) to new folder" }
}
item_list['ai'] = {
name: "Scan file for faces",
items: {
"ai-all": {"name": "all"},
{% for p in people %}
"ai-{{p.tag}}": {"name": "{{p.tag}}"},
{% endfor %}
}
}
if( SelContainsBinAndNotBin() ) {
item_list['both']= { name: 'Cannot delete and restore at same time', disabled: true }
} else {
if (e.currentTarget.getAttribute('path_type') == 'Bin' )
item_list['undel']= { name: "Restore selected file(s)" }
else if( e.currentTarget.getAttribute('type') != 'Directory' )
item_list['del']= { name: "Delete Selected file(s)" }
}
return {
callback: function( key, options) {
if( key == "details" ) { DetailsDBox() }
if( key == "view" ) { dblClickToViewEntry( $(this).attr('id') ) }
if( key == "move" ) { MoveDBox(move_paths, "{{url_for('internal', filename='icons.svg')}}") }
if( key == "del" ) { DelDBox('Delete') }
if( key == "undel") { DelDBox('Restore') }
if( key == "r90" ) { Transform(90) }
if( key == "r180" ) { Transform(180) }
if( key == "r270" ) { Transform(270) }
if( key == "fliph" ) { Transform("fliph") }
if( key == "flipv" ) { Transform("flipv") }
if( key.startsWith("ai")) { RunAIOnSeln(key) }
// dont flow this event through the dom
e.stopPropagation()
},
items: item_list
};
}
});
$( document ).keydown(function(event) { switch (event.key)
{
case "Delete":
{% if "files_rbp" in request.url %}
if( ! NoSel() ) DelDBox('Restore');
{% else %}
if( ! NoSel() ) DelDBox('Delete');
{% endif %}
break;
} })
if( isMobile() )
{
$('#shift-key').css('visibility', 'visible');
$('#ctrl-key').css('visibility', 'visible');
}
// check the size radiobutton
$(`input[name="size"][value="${OPT.size}"]`).prop('checked', true)
window.addEventListener('resize', DrawImg, false);
window.addEventListener('resize', ResizeVideo, false);
// when we are in recycle bin, change colours to green & func to restore
if( window.location.href.includes('files_rbp') )
{
$('#viewer_bin').attr('fill', 'var(--bs-success)')
// fill with bg-success colour
$('#viewer_bin use').attr('fill', 'var(--bs-success)')
$('#viewer_del').removeClass('btn-outline-danger').addClass('btn-outline-success')
$('#viewer_del').on('mouseenter', function() {
// Set the SVG fill to white
$('#viewer_bin use').attr('fill', 'white');
});
// When mouse leaves the button
$('#viewer_del').on('mouseleave', function() {
// Revert the SVG fill to the bg-success colour
$('#viewer_bin use').attr('fill', 'var(--bs-success)');
});
$('#viewer_del').on('click', function() { DelDBox('Restore') } )
}
if( isMobile() )
{
$('#shift-key').css('visibility', 'visible');
$('#ctrl-key').css('visibility', 'visible');
}
</script>
{% endblock script_content %}

View File

@@ -56,32 +56,6 @@
</div>
</div>
</div class="col-7">
<div class="row pt-5">
<alert class="alert alert-warning">The following values are based on the defaults above and subsequent changes as you navigate the application and are not set by hand. The following content is for checking/debugging only.</alert>
</div class="row">
<div class="row">
<table id="pa_user_state_tbl" class="table table-striped table-sm" data-toolbar="#toolbar" data-search="true">
<thead>
<tr class="table-primary"><th>Path</th><th>New or Oldest</th><th>How Many</th><th>Folders?</th><th>Group by</th><th>Thumb size</th><th>DB retrieve offset</th><th>Root</th><th>cwd</th></tr>
</thead>
<tbody>
{% for st in states %}
<tr>
<td>{{st.path_type}}</td>
<td>{{st.noo}}</td>
<td>{{st.how_many}}</td>
<td>{{st.folders}}</td>
<td>{{st.grouping}}</td>
<td>{{st.size}}</td>
<td>{{st.root}}</td>
<td>{{st.cwd}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div class="row">
</div class="container-fluid">
{% endblock main_content %}
{% block script_content %}

View File

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