Compare commits

116 Commits

Author SHA1 Message Date
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
40f0b5d369 refactor most javascript code, moved what can be moved into files_support.js, made keydown() items only apply when I go to the viewer code 2025-10-04 23:25:46 +10:00
2f5c6ec949 update for sqlalchemy v2 2025-10-04 23:23:39 +10:00
81ebf6fa01 use to_dict() here, so we can use |json in jscript 2025-10-04 22:45:28 +10:00
7a4f5ddb17 update z-index so we dont get buttons being higher than toast popup 2025-10-04 22:45:01 +10:00
00e42447fc make download link work with document.viewing rather than objs[current] 2025-10-04 10:43:00 +10:00
50f9fbee20 update to use new viewing data structures, consolidate to one set of transform functions with successCallback to cover the files vs viewer content. Updated TODO appropriately 2025-10-04 10:39:11 +10:00
1b9b4c9c4f update to use new viewing data structures, consolidate to one set of transform functions with successCallback to cover the files vs viewer content. Updated TODO appropriately 2025-10-04 10:39:06 +10:00
86761994df need to check length of faces, not just it exists - it comes back as an empty array when no faces 2025-10-04 10:37:29 +10:00
4138f392d3 clean up includes, double up of fullscreen var, and use of current still 2025-10-04 10:37:00 +10:00
525b823632 dont crash server if we have not scanned the Path yet 2025-10-04 10:34:54 +10:00
d019dc7960 more things to test/fix 2025-10-03 21:40:59 +10:00
a15fbd74d5 update for new db models/marshmallow object 2025-10-03 21:40:48 +10:00
3c8babb619 made basic viewer now handle new marshmallowed data - faces, model_used, face_distance, etc. 2025-10-03 21:28:25 +10:00
cd73c16545 include refimg_lnk and facefile_lnk into Face to be used in viewer 2025-10-03 21:10:52 +10:00
efaec00127 OMG, face_distance is float not int, how has this ever worked 2025-10-03 21:10:04 +10:00
0777bbe237 remove more dead code 2025-10-02 23:14:42 +10:00
b0c738fcc1 moved cFO() to changeOPT() into internal/js, transformed file_list_ip to work with new query_data model, made the Size buttons use modern bootstrap5 radio buttons and work again without the form for all file* routes. Removed a lot of dead code, moved code to resetNextPrev buttons to anytime we getPage, reducing code, handle setting OPT in jscript via a to_dict() for States. Overall, search and file_list_ip now work. Only main thing left is the override code 2025-10-02 23:11:14 +10:00
2b9e0e19a2 add a to_dict() for States, and then I can use a 1-liner to set a var in jscript 2025-10-02 18:20:31 +10:00
e526d99389 no longer maintain too much state per user, remove all need to update it 2025-10-02 17:54:51 +10:00
5a923359bc remove dead viewing code 2025-10-02 17:54:31 +10:00
a147308b64 removed fields from PA_USER_STATE, no longer needed with new viewing logic 2025-10-02 17:51:47 +10:00
9e943c7e1f removed fields from PA_USER_STATE, no longer needed with new viewing logic 2025-10-02 17:51:39 +10:00
87651e80a0 fix up capitalisation of bOOLEAN, and removed fields from PA_USER_STATE, no longer needed with new viewing logic 2025-10-02 17:51:25 +10:00
2e952deda0 added code to support changing noo/how_many/folders and do this with json data back and forth, update the UI, all works - only search is missing now. Lots of dead code can still be deleted 2025-10-01 23:48:19 +10:00
b9b7a24326 removing done items 2025-10-01 23:43:15 +10:00
a7ce8e66b5 change back to how_many, too hard to fix and the inconsistency broke the GUI change 2025-10-01 23:40:39 +10:00
6199c042e3 remove most of the code, just have States with default values and some derived values such as prefix 2025-10-01 23:40:05 +10:00
c32b99f53e augment CreateFoldersSelect to support supplying js 2025-10-01 23:39:17 +10:00
175e43c9bb make viewer work for files_sp, by resetting entryList and pageList for any new directory load 2025-09-30 18:38:54 +10:00
4bb99ce589 catch all to cover when we try to view an img/video and the dir contents is empty, so nothing to show 2025-09-30 18:38:20 +10:00
70ca93b14e convert files_rbp to new query_data model 2025-09-30 18:37:15 +10:00
a0e06717ac viewer now works for files_ip, still have broken bits everywhere - files_rbp, change_opts, do I want a back button? lots of dead/old code, probably cam move more js into *_support, and do I want to keep files_support separate to view_support 2025-09-30 00:29:11 +10:00
0851b79e16 remove debug 2025-09-30 00:26:45 +10:00
8e6342b627 quick add of files_div to be able to toggle beween files viewing and viewer itself, moved isMobile to support js, change eids to use query_data - for now, will remove when we consolidate to new approach 2025-09-28 21:48:11 +10:00
5b6dc1b3e9 seems I now need setuptools, this feels like a kludge - watch this space for further upstream changes 2025-09-28 21:45:19 +10:00
59bd2af15e update to reflect we no longer use sudo for docker commands 2025-09-28 21:44:26 +10:00
1ca5ca192c move isMobile() to support js, as it has no jinja2 in it 2025-09-28 21:44:02 +10:00
5f8c48ac18 fix highlighting issue - as expected, ecnt was wrong 2025-09-27 17:36:31 +10:00
b67f2d9dcb now allow files_sp paths to work (and support folders). Highlighting with folders is broken (likely dodgy ecnt). viewing still broken, but basic navigations is finally working with folders now 2025-09-27 12:42:01 +10:00
5842bf2ab8 set OPT values in jscript in files.html, added functions to draw figures on a page based on pageList (subset of entryList) & json data, and tweaked just grouping select as well to also use the draw figures func() in jscript. Needed to move out .figure click handler into the draw figures too. This is now semi-functional, images load, pages next/prev works, grouping works -- BUT only for files_ip -- folders wont work, search wont work, files_sp wont work, viewing a file wont work 2025-09-27 00:31:42 +10:00
be218c5049 first pass of using query data to allow display of files_ip only. Does use pass in list to do next/prev page (of the eids only). No images drawn at all. Definitely NOT, a usable version of PA at the moment 2025-09-26 19:32:09 +10:00
a28f016b8a when viewing, there is no path and not needed for viewing anyway 2025-09-26 19:30:29 +10:00
d2db7f6184 hash is a string not an Integer, not sure how this issue has been here so long. Also, first pass of using query data to allow display of files_ip only. Definitely NOT, a usable version of PA at the moment 2025-09-26 19:26:23 +10:00
9ec8195d0a first pass of supprot functions to allow query/entry_list to drive pagination, and do not go back to the server to calc next/prev page 2025-09-26 19:25:19 +10:00
2325dcd22a work out prefix and store it away in States so we can just use if in updated json data calc for files 2025-09-26 19:24:10 +10:00
e0b597c58c add entry_list to query table 2025-09-26 19:22:36 +10:00
0895268df2 more thoughts on change over to json, etc 2025-09-26 19:20:47 +10:00
efceef7e57 added BUG-140 - db restarts cause job mgr to fail 2025-09-26 14:57:32 +10:00
21059a6235 reformatted to be normal sql, added query table 2025-09-20 19:32:47 +10:00
4d80fa4e7c first pass at new query table 2025-09-20 19:32:21 +10:00
30 changed files with 2375 additions and 2114 deletions

43
BUGs
View File

@@ -1,40 +1,9 @@
### Next: 140
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
### 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-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

6
README
View File

@@ -95,13 +95,13 @@ To get back a 'working' but scanned set of data:
# pg_dump --user=pa -a -t person -t refimg -t person_refimg_link > /docker-entrypoint-initdb.d/users.sql
# export all content so we can upgrade versions of postgres
sudo docker exec -it padb bash
docker exec -it padb bash
# pg_dump --user=pa pa > /docker-entrypoint-initdb.d/bkup.sql
### check sql looks good
sudo mv /srv/docker/container/padb/docker-entrypoint-initdb.d/bkup.sql /srv/docker/container/padb/docker-entrypoint-initdb.d/tables.sql
sudo rm /srv/docker/container/padb/docker-entrypoint-initdb.d/users.sql
sudo docker-compose -f /srv/docker/config/docker-compose.yaml build padb
( cd /srv/docker/config/ ; sudo docker-compose stop padb ; yes | sudo docker-compose rm padb ; sudo rm -rf /srv/docker/container/padb/data/ ; sudo docker-compose up -d padb ; sudo docker-compose restart paweb )
docker-compose -f /srv/docker/config/docker-compose.yaml build padb
( cd /srv/docker/config/ ; docker-compose stop padb ; yes | docker-compose rm padb ; sudo rm -rf /srv/docker/container/padb/data/ ; docker-compose up -d padb ; docker-compose restart paweb )
HANDY SQLs/commands:

40
TODO
View File

@@ -1,32 +1,13 @@
### major fix - go to everywhere I call GetEntries(), and redo the logic totally...
* firstly, run the query as per normal, but get just the matched eids into an entry_lst
* make a unique query_id for this entry_lst, and store entry_ids into "query" table, with a unique query_id
* take most of pa_user_state that relates to query state and move it to the "query" table per query_id
* pa_user_state then becomes defaults for next query (so how_many, noo, etc)
* we can age out queries form the query_table after a few months?
* client side always has query_id. IF DB does not have query_id, then its really old? - just say so...
* client side takes query_id, entry_lst, current_eid, offset, first/last_eid, etc. as part of its first route / html creation.
* it then decides based on all this to GetEntryDetails( subset of entry_lst ) <- needs new route
* IN THEORY some of the subset of entry_lst don't exist -- BUT, we can handle that on response, e.g. say my query used to have 1,2,3, and since then another user/action deleted 2:
- I ask for details on 1,2,3 and get back details on 1,3 only.
- On client-side, I can say, since you ran this query, data in PA has changed - image#2 is no longer in PA.
Please run a new query (or bonus points, maybe have enough of the original query to note this and ask, do you want to ignore changes, or re-run query and get latest data?)
* client can go fwd or back in the entry_lst same as now (disabling buttons as needed), BUT as entry_lst is NOT recreated per page move, then no chance to get confused about first/last
* 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
* 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'.
@@ -37,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?
@@ -68,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"])
@@ -79,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?)
@@ -115,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")
################################################################################
@@ -62,13 +64,13 @@ class FaceRefimgLink(PA, db.Model):
Attributes:
face_id (int): face id of row in Face table / foreign key - part primary key
refimg_id (int): face id of row in Face table / foreign key - part primary key
face_distance (int): distance value (how similar matched Face was)
face_distance (float): distance value (how similar matched Face was)
"""
__tablename__ = "face_refimg_link"
face_id = db.Column(db.Integer, db.ForeignKey("face.id"), primary_key=True )
refimg_id = db.Column(db.Integer, db.ForeignKey("refimg.id"), primary_key=True )
face_distance = db.Column(db.Integer)
face_distance = db.Column(db.Float)
################################################################################
@@ -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")

869
files.py

File diff suppressed because it is too large Load Diff

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

View File

@@ -1,3 +1,16 @@
// GLOBAL ICON array
ICON={}
ICON["Import"]="import"
ICON["Storage"]="db"
ICON["Bin"]="trash"
// function called when we get another page from inside the files view
function getPageFigures(res, viewingIdx)
{
// add all the figures to files_div
drawPageOfFigures()
}
// grab all selected thumbnails and return a <div> containing the thumbnails
// with extra yr and date attached as attributes so we can set the default
// dir name for a move directory - not used in del, but no harm to include them
@@ -74,21 +87,9 @@ function GetExistingDirsAsDiv( dt, divname, ptype )
} )
}
// wrapper to do some clean up before POST to /move_files or /delete_files
// used to remove the highlighted item(s) && reset the numbering so highlighting continues to work
function MoveOrDelCleanUpUI()
{
// remove the images being moved (so UI immediately 'sees' the move)
$("[name^=eid-]").each( function() { $('#'+$(this).attr('value')).remove() } )
// reorder the images via ecnt again, so future highlighting can work
document.mf_id=0; $('.figure').each( function() { $(this).attr('ecnt', document.mf_id ); document.mf_id++ } )
$('#dbox').modal('hide')
}
// show the DBox for a move file, includes all thumbnails of selected files to move
// and a pre-populated folder to move them into, with text field to add a suffix
function MoveDBox(path_details, db_url)
function MoveDBox()
{
$('#dbox-title').html('Move Selected File(s) to new directory in Storage Path')
div =`
@@ -98,21 +99,21 @@ function MoveDBox(path_details, db_url)
<form id="mv_fm" class="form form-control-inline col-12">
<input id="move_path_type" name="move_path_type" type="hidden"
`
div += ' value="' + path_details[0].type + '"></input>'
div += ' value="' + move_paths[0].type.name + '"></input>'
div+=GetSelnAsDiv()
yr=$('.highlight').first().attr('yr')
dt=$('.highlight').first().attr('date')
div+='<div class="row">Use Existing Directory (in the chosen path):</div><div id="existing"></div>'
GetExistingDirsAsDiv( dt, "existing", path_details[0].type )
GetExistingDirsAsDiv( dt, "existing", 'Storage' )
div+=`
<div class="input-group my-3">
<alert class="alert alert-primary my-auto py-1">
`
// NB: alert-primary here is a hack to get the bg the same color as the alert primary by
div+= '<svg id="move_path_icon" width="20" height="20" fill="currentColor"><use xlink:href="' + path_details[0].icon_url + '"></svg>'
div+= '<svg id="move_path_icon" width="20" height="20" fill="currentColor"><use xlink:href="' + move_paths[0].icon_url + '"></svg>'
div+= '<select id="rp_sel" name="rel_path" class="text-primary alert-primary py-1 border border-primary rounded" onChange="change_rp_sel()">'
for(p of path_details) {
div+= '<option path_type="'+p.type+'" icon_url="'+p.icon_url+'">'+p.path+'</option>'
for(p of move_paths) {
div+= `<option path_type="${p.type.name}" icon_url="${p.icon_url}">${p.root_dir}</option>`
}
div+= '</select>'
div+=`
@@ -126,11 +127,26 @@ function MoveDBox(path_details, db_url)
</div>
<div class="form-row col-12 mt-2">
<button onClick="$('#dbox').modal('hide'); return false;" class="btn btn-outline-secondary offset-1 col-2">Cancel</button>
<button id="move_submit" onClick="MoveOrDelCleanUpUI(); $.ajax({ type: 'POST', data: $('#mv_fm').serialize(), url: '/move_files', success: function(data) {
if( $(location).attr('pathname').match('search') !== null ) { window.location='/' }; CheckForJobs() } }); return false" class="btn btn-outline-primary col-2">Ok</button>
<button onClick="
$.ajax({ type: 'POST', data: $('#mv_fm').serialize(), url: '/move_files',
success: function(data) {
processAmendments( data.job.amendments )
checkForAmendmentJobToComplete(data.job.id)
}
});
$('#dbox').modal('hide')
return false"
class="btn btn-outline-secondary col-2">Ok</button>
</div>
</form>
`
// force to Storage always - if in Import, liekly storing, if in Storage, likely moving, user can always override
div+=`
<script>
storage_rp = move_paths.find(item => item.type.name === "Storage")?.root_dir;
$('#rp_sel').val(storage_rp);change_rp_sel()
</script>
`
$('#dbox-content').html(div)
$('#dbox').modal('show')
@@ -138,36 +154,71 @@ function MoveDBox(path_details, db_url)
$("#suffix").keypress(function (e) { if (e.which == 13) { $("#move_submit").click(); return false; } } )
}
// This function is called anytime we have a job that returns amendments
// (visually we want to show this entry is being amended by a job)
// as we check for a job to end every second, we can call this multiple times
// during the runtime of a job, so only redraw/react to a new amendment
// NOTE: we update all views, as we might go into one via jscript before the job ends
function processAmendments( ams )
{
for (const am of ams)
{
// if we return anything here, we already have this amendment, so continue to next
if( document.amendments.filter(obj => obj.eid === am.eid).length > 0 )
continue
document.amendments.push(am)
if( document.viewing && document.viewing.id == am.eid )
{
im.src=im.src + '?t=' + new Date().getTime();
DrawImg()
}
// find where in the page this image is being viewed
idx = pageList.indexOf(am.eid)
// createFigureHtml uses matching document.amendments to show thobber, etc
html = createFigureHtml( document.entries[idx] )
$('#'+am.eid).replaceWith( html )
}
}
// function to add data for document.amendment based on id and amt
// used when we transform several images in files_*, or single image in viewer
// show the DBox for a delete/restore file, includes all thumbnails of selected files
// with appropriate coloured button to Delete or Restore files`
// with appropriate coloured button to Delete or Restore files
function DelDBox(del_or_undel)
{
to_del = GetSelnAsData()
$('#dbox-title').html(del_or_undel+' Selected File(s)')
div ='<div class="row col-12"><p class="col">' + del_or_undel + ' the following files?</p></div>'
div+=GetSelnAsDiv()
if( del_or_undel == "Delete" )
{
which="delete"
col="danger"
}
else
{
which="restore"
col="success"
}
document.ents_to_del=[]
$('.highlight').each(function( cnt ) { document.ents_to_del[cnt]=parseInt($(this).attr('id')) } )
div+=`<div class="row col-12 mt-3">
<button onClick="$('#dbox').modal('hide')" class="btn btn-outline-secondary col-2">Cancel</button>
`
div+=`
<button onClick="MoveOrDelCleanUpUI(); $.ajax({ type: 'POST', data: to_del, url:
`
if( del_or_undel == "Delete" )
div+=`
'/delete_files',
<button onClick="
$.ajax({ type: 'POST', data: to_del, url: '/${which}_files',
success: function(data) {
if( $(location).attr('pathname').match('search') !== null ) { window.location='/' }; CheckForJobs() } }); return false" class="btn btn-outline-danger col-2">Ok</button>
</div>
`
else
// just force page reload to / for now if restoring files from a search path -- a search (by name)
// would match the deleted/restored file, so it would be complex to clean up the UI (and can't reload, as DB won't be changed yet)
div+=`
'/restore_files',
success: function(data){
if( $(location).attr('pathname').match('search') !== null ) { window.location='/' }; CheckForJobs() } }); return false" class="btn btn-outline-success col-2">Ok</button>
</div>
`
processAmendments( data.job.amendments )
checkForAmendmentJobToComplete(data.job.id)
}
});
$('#dbox').modal('hide')
return false"
class="btn btn-outline-${col} col-2">Ok</button>
</div>`
$('#dbox-content').html(div)
$('#dbox').modal('show')
}
@@ -200,58 +251,82 @@ function DetailsDBox()
}
// function to change the size of thumbnails (and resets button bar to newly
// selected size)
function ChangeSize(clicked_button,sz)
{
$('.sz-but.btn-info').removeClass('btn-info text-white').addClass('btn-outline-info')
$(clicked_button).addClass('btn-info text-white').removeClass('btn-outline-info')
$('.thumb').attr( {height: sz, style: 'font-size:'+sz+'px' } )
$('#size').val(sz)
sz=sz-22
$('.svg').height(sz);
$('.svg').width(sz);
$('.svg_cap').width(sz);
}
// DoSel is called when a click event occurs, and sets the selection via adding
// 'highlight' to the class of the appropriate thumbnails
// e == event (can see if shift/ctrl held down while left-clicking
// el == element the click is on
// this allows single-click to select, ctrl-click to (de)select 1 item, and
// shift-click to add all elements between highlighted area and clicked area,
// whether you click after highlight or before
function DoSel(e, el)
{
if( e.ctrlKey || document.fake_ctrl === 1 )
{
$(el).toggleClass('highlight')
if( document.fake_ctrl === 1 )
document.fake_ctrl=0
return
}
if( e.shiftKey || document.fake_shift === 1 )
{
st=Number($('.highlight').first().attr('ecnt'))
end=Number($('.highlight').last().attr('ecnt'))
clicked=Number($(el).attr('ecnt'))
// if we shift-click first element, then st/end are NaN, so just highlightthe one clicked
if( isNaN(st) )
{
$('.entry').slice( clicked, clicked+1 ).addClass('highlight')
return
}
if( clicked > end )
$('.entry').slice( end, clicked+1 ).addClass('highlight')
else
$('.entry').slice( clicked, st ).addClass('highlight')
// shift-click to add all elements between highlighted area and clicked el,
// whether you click before highlight or after, or inside a gap and then back
// or forward to the closest higlighted entry - also, only works on entry class,
// so it ignores figures that we take entry off while we transform, etc it
function DoSel(e, el) {
const id = $(el).attr('id');
const entries = $('.entry');
if( document.fake_shift === 1 )
document.fake_shift=0
return
// Collect currently highlighted entries
const currentHighlights = $('.highlight');
const highlighted = new Set();
currentHighlights.each(function() {
highlighted.add($(this).attr('id'));
});
// Ctrl+click: toggle highlight for the clicked entry
if (e.ctrlKey || document.fake_ctrl === 1) {
$(el).toggleClass('highlight');
if (highlighted.has(id)) {
highlighted.delete(id);
} else {
highlighted.add(id);
}
if (document.fake_ctrl === 1) {
document.fake_ctrl = 0;
}
return;
}
// Shift+click: select a range
else if (e.shiftKey || document.fake_shift === 1) {
if (currentHighlights.length === 0) {
// If no highlights, just highlight the clicked entry
$(el).addClass('highlight');
highlighted.add(id);
} else {
// Find the nearest highlighted entry
const clickedIndex = entries.index($(el));
let nearestHighlightIndex = -1;
let minDistance = Infinity;
currentHighlights.each(function() {
const highlightIndex = entries.index($(this));
const distance = Math.abs(highlightIndex - clickedIndex);
if (distance < minDistance) {
minDistance = distance;
nearestHighlightIndex = highlightIndex;
}
});
// Highlight the range between the nearest highlighted entry and the clicked entry
const from = Math.min(clickedIndex, nearestHighlightIndex);
const to = Math.max(clickedIndex, nearestHighlightIndex);
for (let i = from; i <= to; i++) {
const entryId = entries.eq(i).attr('id');
highlighted.add(entryId);
entries.eq(i).addClass('highlight');
}
}
if (document.fake_shift === 1) {
document.fake_shift = 0;
}
return;
}
// Single click: clear all highlights and highlight the clicked entry
else {
$('.highlight').removeClass('highlight');
highlighted.clear();
$(el).addClass('highlight');
highlighted.add(id);
}
$('.highlight').removeClass('highlight')
$(el).addClass('highlight')
}
// if a selection exists, enable move & del/restore buttons otherwise disable them
@@ -316,3 +391,623 @@ function NoSel() {
else
return true
}
// quick wrapper to add a single <figure> to the #figures div
function addFigure( obj )
{
html=createFigureHtml( obj )
$('#figures').append( html )
}
/**
* Renders a group header or entry based on the object and options.
* obj - The object containing file/directory details.
* returns {string} - Generated HTML string.
*/
function createFigureHtml( obj )
{
// if am is null, no amendment for this obj, otherwise we have one
var am=null
for (const tmp of document.amendments)
if( tmp.eid == obj.id )
am=tmp
let html = "";
// Image/Video/Unknown entry
if (obj.type.name === "Image" || obj.type.name === "Video" || obj.type.name === "Unknown") {
const pathType = obj.in_dir.in_path.type.name;
const size = obj.file_details.size_mb;
const hash = obj.file_details.hash;
const inDir = `${obj.in_dir.in_path.path_prefix}/${obj.in_dir.rel_path}`;
const fname = obj.name;
const yr = obj.file_details.year;
const date = `${yr}${String(obj.file_details.month).padStart(2, '0')}${String(obj.file_details.day).padStart(2, '0')}`;
const prettyDate = `${obj.file_details.day}/${obj.file_details.month}/${obj.file_details.year}`;
const type = obj.type.name;
// if amendment for this obj, do not add entry class - prevents highlighting
if( am ) {
ent=""
gs="style='filter: grayscale(100%);'"
am_html ='<img class="position-absolute top-50 start-50 translate-middle" height="60" src="/internal/white-circle.png">'
am_html+='<img class="position-absolute top-50 start-50 translate-middle" height="64" src="/internal/throbber.gif">'
if( am.type.which == 'icon' )
am_html+=`<svg class="position-absolute top-50 start-50 translate-middle" height="32" style="color:${am.type.colour}" fill="${am.type.colour}"><use xlink:href="/internal/icons.svg#${am.type.what}"></use></svg>`
else
am_html+=`<img class="position-absolute top-50 start-50 translate-middle" src="/internal/${am.type.what}?v={{js_vers['r270']}}" height="32">`
} else {
ent="entry"
gs=""
am_html=""
}
html += `
<figure id="${obj.id}" class="col col-auto g-0 figure ${ent} m-1"
path_type="${pathType}" size="${size}" hash="${hash}" in_dir="${inDir}"
fname="${fname}" yr="${yr}" date="${date}" pretty_date="${prettyDate}" type="${type}">
${renderMedia(obj,gs,am_html)}
`
}
// Directory entry
else if (obj.type.name === "Directory" && OPT.folders) {
const dirname = obj.dir_details.rel_path.length
? `${obj.dir_details.in_path.path_prefix}/${obj.dir_details.rel_path}`
: obj.dir_details.in_path.path_prefix;
html += `
<figure class="col col-auto g-0 dir entry m-1" id="${obj.id}" dir="${dirname}" type="Directory">
<svg class="svg" width="${OPT.size - 22}" height="${OPT.size - 22}" fill="currentColor">
<use xlink:href="/internal/icons.svg#Directory"></use>
</svg>
<figcaption class="svg_cap figure-caption text-center text-wrap text-break">${obj.name}</figcaption>
`;
html += `<script>f=$('#${obj.id}'); w=f.find('svg').width(); f.find('figcaption').width(w);</script>`;
}
// moved the bindings to here as we need to reset them if we recreate this Figure (after a transform job)
html += `<script>
if( "${obj.type.name}" === "Directory" ) {
$("#${obj.id}").click( function(e) { document.back_id=this.id; getDirEntries(this.id,false) } )
} else {
$('#${obj.id}').click( function(e) { DoSel(e, this ); SetButtonState(); return false; });
$('#${obj.id}').dblclick( function(e) { startViewing( $(this).attr('id') ) } )
}
</script>
</figure>`
return html
}
// Helper function to render media (image/video/unknown)
function renderMedia(obj,gs,am_html) {
const isImageOrUnknown = obj.type.name === "Image" || obj.type.name === "Unknown";
const isVideo = obj.type.name === "Video";
const path = `${obj.in_dir.in_path.path_prefix}/${obj.in_dir.rel_path}/${obj.name}`;
const thumb = obj.file_details.thumbnail
? `<a href="${path}"><img alt="${obj.name}" ${gs} class="thumb" height="${OPT.size}" src="data:image/jpeg;base64,${obj.file_details.thumbnail}"></a>`
: `<a href="${path}"><svg width="${OPT.size}" height="${OPT.size}" fill="white"><use xlink:href="/internal/icons.svg#unknown_ftype"/></svg></a>`;
let mediaHtml = `<div style="position:relative; width:100%">${thumb}${am_html}`;
if (isVideo) {
mediaHtml += `
<div style="position:absolute; top: 0px; left: 2px;">
<svg width="16" height="16" fill="white"><use xlink:href="/internal/icons.svg#film"/></svg>
</div>
`;
}
if (OPT.search_term) {
mediaHtml += `
<div style="position:absolute; bottom: 0px; left: 2px;">
<svg width="16" height="16" fill="white"><use xlink:href="/internal/icons.svg#${getLocationIcon(obj)}"/></svg>
</div>
`;
}
mediaHtml += `</div>`;
return mediaHtml;
}
// Helper: Get location icon (placeholder)
function getLocationIcon(obj) {
return ICON[obj.in_dir.in_path.type.name]
}
// POST to get entry ids, and then getPage for a specified directory
function getDirEntries(dir_id, back)
{
data={}
data.dir_id=dir_id
data.back=back
data.noo=OPT.noo
$.ajax({
type: 'POST',
url: '/get_dir_eids',
data: JSON.stringify(data),
contentType: 'application/json',
dataType: 'json',
success: function(res) {
if( res.valid === false )
{
$('#figures').html( "<alert class='alert alert-danger'>ERROR! directory has changed since you loaded this view. You have to reload and reset your view (probably someone deleted the directory or its parent since you loaded this page)" )
return
}
entryList=res.entry_list
pageList=entryList.slice(0, OPT.how_many)
// now go get actual data/entries
getPage(1,getPageFigures)
},
error: function(xhr, status, error) {
console.error("Error:", error);
}
});
}
// this function draws all the figures from document.entries - called when we
// change pages, but also when we change say grouping/other OPTs
function drawPageOfFigures()
{
$('#figures').empty()
var last = { printed: null }
// something is up, let the user know
if( document.alert )
$('#figures').append( document.alert )
if( OPT.folders )
{
// it root_eid is 0, then no entries in this path - cant go up
if( OPT.root_eid == 0 || (document.entries.length && document.entries[0].in_dir.eid == OPT.root_eid ) )
{
gray="_gray"
back=""
cl=""
back_id=0
}
else
{
gray=""
back="Back"
cl="back"
if( document.entries.length > 0 )
back_id = document.entries[0].in_dir.eid
else
back_id = document.back_id
}
// back button, if gray/back decide if we see grayed out folder and/or the name of the folder we go back to
// with clas "back" this gets a different click handler which flags server to return data by 'going back/up' in dir tree
// we give the server the id of the first item on the page so it can work out how to go back
html=`<div class="col col-auto g-0 m-1">
<figure id="${back_id}" class="${cl} entry m-1" type="Directory">
<svg class="svg" width="${OPT.size-22}" height="${OPT.size-22}">
<use xlink:href="internal/icons.svg#folder_back${gray}"/>
</svg>
<figcaption class="figure-caption text-center">${back}</figcaption>
</figure>
</div>`
$('#figures').append(html)
}
for (const obj of document.entries) {
// Grouping logic
if (OPT.grouping === "Day") {
if (last.printed !== obj.file_details.day) {
$('#figures').append(`<div class="row ps-3"><h6>Day: ${obj.file_details.day} of ${obj.file_details.month}/${obj.file_details.year}</h6></div>` );
last.printed = obj.file_details.day;
}
} else if (OPT.grouping === "Week") {
if (last.printed !== obj.file_details.woy) {
$('#figures').append(`<div class="row ps-3"><h6>Week #: ${obj.file_details.woy} of ${obj.file_details.year}</h6></div>` );
last.printed = obj.file_details.woy;
}
} else if (OPT.grouping === "Month") {
if (last.printed !== obj.file_details.month) {
$('#figures').append(`<div class="row ps-3"><h6>Month: ${obj.file_details.month} of ${obj.file_details.year}</h6></div>` );
last.printed = obj.file_details.month;
}
}
addFigure( obj )
}
$(".back").click( function(e) { getDirEntries(this.id,true) } )
if( document.entries.length == 0 )
if( OPT.search_term )
$('#figures').append( `<span class="alert alert-danger p-2 col-auto"> No matches for: '${OPT.search_term}'</span>` )
else if( OPT.root_eid == 0 )
$('#figures').append( `<span class="alert alert-danger p-2 col-auto d-flex align-items-center">No files in Path!</span>` )
}
// emtpy out file_list_div, and repopulate it with new page of content
function getPageFileList(res, viewingIdx)
{
$('#file_list_div').empty()
// something is up, let the user know
if( document.alert )
$('#file_list_div').append( '<div class="row">' + document.alert + '</div>' )
if( OPT.root_eid == 0 )
{
$('#file_list_div').append( `<span class="alert alert-danger p-2">No files in Path!</span>` )
return
}
html='<table class="table table-striped table-sm col-12">'
html+='<thead><tr class="table-primary"><th>Name</th><th>Size (MB)</th><th>Path Prefix</th><th>Hash</th></tr></thead><tbody>'
for (const obj of res) {
html+=`<tr>
<td>
<div class="d-flex align-items-center">
<a href="${obj.in_dir.in_path.path_prefix}/${obj.in_dir.rel_path}/${obj.name}">
<img class="img-fluid me-2" style="max-width: 100px;"
src="data:image/jpeg;base64,${obj.file_details.thumbnail}"></img>
</a>
<span>${obj.name}</span>
</div>
<td>${obj.file_details.size_mb}</td>
<td>${obj.in_dir.in_path.path_prefix.replace("static/","")}/${obj.in_dir.rel_path}</td>
<td>${obj.file_details.hash}</td>
</tr>`
}
html+='</tbody></table>'
$('#file_list_div').append(html)
}
// wrapper function as we want to handle real DB query success, but also do the
// same when we just use cache
function getEntriesByIdSuccessHandler(res,pageNumber,successCallback,viewingIdx)
{
if( res.length != pageList.length )
document.alert="<alert class='alert alert-warning'>WARNING: something has changed since viewing this page (likely someone deleted content in another view), strongly suggest a page reload to get the latest data</alert>"
document.entries=res;
// cache this
document.page[pageNumber]=res
// FIXME: I want to remove successCallback, instead: if viewing, or files_*, or file_list, then call relevant draw routine
successCallback(res,viewingIdx)
resetNextPrevButtons()
// if search, disable folders
if( OPT.search_term )
$('#folders').prop('disabled', 'disabled').removeClass('border-info').addClass('border-secondary').removeClass('text-info').addClass('text-secondary');
else if( document.entries.length == 0 )
{
html=`<span class="alert alert-danger p-2 col-auto">No files in Path</span>`
$('#file_list_div').append(html)
$('#figures').append(html)
}
}
// Function to get the 'page' of entry ids out of entryList
function getPage(pageNumber, successCallback, viewingIdx=0)
{
// before we do anything, disabled left/right arrows on viewer to stop
// getting another event before we have the data for the page back
$('#la').prop('disabled', true)
$('#ra').prop('disabled', true)
const startIndex = (pageNumber - 1) * OPT.how_many;
const endIndex = startIndex + OPT.how_many;
pageList = entryList.slice(startIndex, endIndex);
// set up data to send to server to get the entry data for entries in pageList
data={}
data.ids = pageList
// assume nothing wrong, but if the data goes odd, then this will be non-null and displayed later (cant add here, as later code does .empty() of file divs)
document.alert=null
// see if we can use cache, and dont reload from DB
if( !OPT.folders && document.page.length && document.page[pageNumber] )
{
getEntriesByIdSuccessHandler( document.page[pageNumber], pageNumber, successCallback, viewingIdx )
return
}
$.ajax({
type: 'POST', url: '/get_entries_by_ids',
data: JSON.stringify(data), contentType: 'application/json',
dataType: 'json',
success: function(res) {
document.amendments=res.amendments;
// only called when an amendment is pending & we are viewing a page in files/list view
// so check for amendment job(s) ending...
for (const tmp of document.amendments)
checkForAmendmentJobToComplete(tmp.job_id)
getEntriesByIdSuccessHandler( res.entries, pageNumber, successCallback, viewingIdx )
},
error: function(xhr, status, error) { console.error("Error:", error); } });
return
}
// Quick Function to check if we are on the first page
function isFirstPage(pageNumber)
{
return pageNumber <= 1;
}
// Function to check if we are on the last page
function isLastPage(pageNumber)
{
const totalPages = Math.ceil(entryList.length / OPT.how_many);
return pageNumber >= totalPages;
}
// given an id in the list, return which page we are on (page 1 is first page)
function getPageNumberForId(id) {
const idx = entryList.indexOf(id);
// should be impossible but jic
if (idx === -1) { return -1 }
return Math.floor(idx / OPT.how_many) + 1;
}
// if we are on first page, disable prev, it not ensure next is enabled
// if we are on last page, disable next, it not ensure prev is enabled
function resetNextPrevButtons()
{
// no data, so disabled both
if( getPageNumberForId(pageList[0]) == -1 )
{
$('.prev').prop('disabled', true).addClass('disabled');
$('.next').prop('disabled', true).addClass('disabled');
return
}
if ( isFirstPage( getPageNumberForId(pageList[0]) ) )
$('.prev').prop('disabled', true).addClass('disabled');
else
$('.prev').prop('disabled', false).removeClass('disabled');
if ( isLastPage( getPageNumberForId(pageList[0]) ) )
$('.next').prop('disabled', true).addClass('disabled');
else
$('.next').prop('disabled', false).removeClass('disabled');
}
// get list of eids for the next page, also make sure next/prev buttons make sense for page we are on
function nextPage(successCallback)
{
// start with disabling more next presses until we are ready to process them
$('.prev').prop('disabled', true).addClass('disabled');
$('.next').prop('disabled', true).addClass('disabled');
// pageList[0] is the first entry on this page
const currentPage=getPageNumberForId( pageList[0] )
// should never happen / just return pageList unchanged
if ( currentPage === -1 || isLastPage( currentPage ) )
{
console.error( "WARNING: seems first on pg=" + pageList[0] + " of how many=" + OPT.how_many + " gives currentPage=" + currentPage + " and we cant go next page?" )
return
}
getPage( currentPage+1, successCallback )
return
}
// get list of eids for the prev page, also make sure next/prev buttons make sense for page we are on
function prevPage(successCallback)
{
// start with disabling more prev presses until we are ready to process them
$('.prev').prop('disabled', true).addClass('disabled');
$('.next').prop('disabled', true).addClass('disabled');
// pageList[0] is the first entry on this page
const currentPage=getPageNumberForId( pageList[0] )
// should never happen / just return pageList unchanged
if (currentPage === 1 || currentPage === -1 )
{
console.error( "WARNING: seems first on pg=" + pageList[0] + " of how many=" + OPT.how_many + " gives currentPage=" + currentPage + " and we cant go prev page?" )
return
}
getPage( currentPage-1, successCallback )
return
}
// function to see if we are on a phone or tablet (where we dont have ctrl or shift keys - helps to display fake buttons to allow multiselect on mobiles)
function isMobile() {
try{ document.createEvent("TouchEvent"); return true; }
catch(e){ return false; }
}
// when we change one of the options (noo, how_many, folders) - then update '{how_many} files' str,
// tweak noo menu for folders/flat view then reset the page contents based on current OPT values
function changeOPT(successCallback) {
OPT.how_many=$('#how_many').val()
// changes invalidate page cache so clear it out
document.page.length=0
new_f=$('#folders').val()
new_f=( new_f == 'True' )
// if change to/from folders, also fix the noo menu
if( new_f != OPT.folders )
{
if( new_f )
{
$('#noo option:lt(2)').prop('disabled', true);
$('#noo').val(OPT.default_folder_noo)
}
else
{
$('#noo option:lt(2)').prop('disabled', false);
$('#noo').val(OPT.default_flat_noo)
}
}
OPT.noo=$('#noo').val()
OPT.folders=new_f
OPT.folders=$('#folders').val()
OPT.grouping=$('#grouping').val()
OPT.size=$('input[name="size"]:checked').val();
$.ajax({
type: 'POST',
url: '/change_file_opts',
data: JSON.stringify(OPT),
contentType: 'application/json',
success: function(resp) {
entryList=resp.query_data.entry_list
OPT.how_many=parseInt(OPT.how_many)
pageList=entryList.slice(0, OPT.how_many)
// put data back into booleans, ints, etc
OPT.folders=( OPT.folders == 'True' )
$('.how_many_text').html( `&nbsp;${OPT.how_many} files&nbsp;` )
OPT.size=parseInt(OPT.size)
getPage(1,successCallback)
}
})
}
// function to change the size of thumbnails when user clicks xs/s/m/l/xl buttons
function changeSize()
{
sz=$('input[name="size"]:checked').val();
OPT.size=sz
$('.thumb').attr( {height: sz, style: 'font-size:'+sz+'px' } )
$('#size').val(sz)
sz=sz-22
$('.svg').height(sz);
$('.svg').width(sz);
$('.svg_cap').width(sz);
}
// when a delete or restore files job has completed successfullly, then get ids
// find the page we are on, remove amendments & ids from entryList and re-get page
// which will reset pageList and the UI of images for that page
function handleMoveOrDeleteOrRestoreFileJobCompleted(job)
{
// this grabs the values from the object attributes of eid-0, eid-1, etc.
const ids = job.extra.filter(item => item.name.startsWith("eid-")).map(item => item.value);
// find page number of first element to delete (this is the page we will return too)
pnum=getPageNumberForId( parseInt(ids[0]) )
// remove amendment data
for (const ent of ids)
{
id=parseInt(ent)
removeAmendment( id )
// remove the item in the entryList
index=entryList.indexOf(id);
if( index != -1 )
entryList.splice(index, 1); // Remove the element
else
{
return; // have to get out of here, or calling getPage() below will loop forever
}
}
// re-create pageList by reloading the page
getPage(pnum,getPageFigures)
}
// POST to a check URL, that will tell us if the amendment job has completed,
// it also calls CheckForJobs() which will fix up the Active Jobs badge,
function checkForAmendmentJobToComplete(job_id)
{
CheckForJobs()
$.ajax( { type: 'POST', data: '&job_id='+job_id, url: '/check_amend_job_status',
success: function(res) { handleCheckAmendmentJobStatus(res); } } )
}
// the status of a Amendment Job has been returned, finished is True/False
// if not finished try again in 1 second... If finished then invalidate page
// cache and based on job type call code correct func to update the UI appropriately
function handleCheckAmendmentJobStatus(data)
{
if( data.finished )
{
// invalidate the cache
document.page.length=0
// transforms contain the single transformed entry data for convenience
if( data.job.name == 'transform_image' )
handleTransformImageJobCompleted(data.job, data.entry)
else if ( data.job.name == 'delete_files' || data.job.name == 'restore_files' || data.job.name == 'move_files' )
handleMoveOrDeleteOrRestoreFileJobCompleted(data.job)
// if we are viewing this file, then just go up / back,b/c this file is "gone" from this view
if( document.viewing )
goOutOfViewer()
}
else { setTimeout( function() { checkForAmendmentJobToComplete(data.job.id) }, 1000 ); }
}
// different context menu on files
$.contextMenu({
selector: '.entry',
itemClickEvent: "click",
build: function($triggerElement, e) {
// if we are not in the highlight set, then move the highlight to this element
if( ! $(e.currentTarget).is('.highlight') )
{
$('.highlight').removeClass('highlight');
$(e.currentTarget).addClass('highlight')
}
// when right-clicking & no selection add one OR deal with ctrl/shift right-lick as it always changes seln
if( NoSel() || e.ctrlKey || e.shiftKey )
{
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" }
}
};
// Dynamically add entries for each person in the `people` array
people.forEach(person => {
item_list['ai'].items[`ai-${person.tag}`] = { name: person.tag };
});
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" ) { startViewing( $(this).attr('id') ) }
if( key == "move" ) { MoveDBox() }
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
};
}
});
// finally, for files_ip/files_sp/files_rbp - set click inside document (NOT an entry) to remove seln
$(document).on('click', function(e) { $('.highlight').removeClass('highlight') ; SetButtonState() });
document.page=[]

View File

@@ -1,27 +1,31 @@
// 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)
// 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 )
{
CheckForJobs()
$.ajax(
{
type: 'POST', data: '&job_id='+job_id, url: '/check_transform_job', success: function(data) {
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;
document.amendments=document.amendments.filter(obj => obj.eid !== id)
}
else
// If Transform job has finished then reset relevant document.entries
// with updated from DB, remove the amendment and redraw image
function handleTransformImageJobCompleted(job, entry)
{
setTimeout( function() { CheckTransformJob(id,job_id) }, 1000,id, job_id );
removeAmendment( entry.id )
// update viewer if we are viewing an image
if( document.viewing )
{
// force reload with timestamped version of im.src
im.src=im.src + '?t=' + new Date().getTime();
DrawImg()
}
},
} )
// 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,
@@ -31,9 +35,25 @@ function CheckTransformJob(id,job_id)
// to finish
function Transform(amt)
{
$('.highlight').each(function( id, e ) {
// we are in the viewer with 1 image only...
if( $('#viewer_div').length && ! $('#viewer_div').hasClass('d-none') )
{
post_data = '&amt='+amt+'&id='+document.viewing.id
// POST /transform for image, grayscale the image, add throbber, & start checking for end of job
$.ajax({ type: 'POST', data: post_data, url: '/transform', success: function(data) {
processAmendments(data.job.amendments)
checkForAmendmentJobToComplete(data.job.id)
} })
}
else
{
$('.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); 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' ) )
@@ -85,13 +110,13 @@ function DrawImg()
else
$('.figcaption').hide()
// if we have faces, the enable the toggles, otherwise disable them
// and reset model select too
if( objs[current].faces )
// if we have faces, the enable the toggles, otherwise disable them and reset model select too
if( document.viewing.file_details.faces.length )
{
$('#faces').attr('disabled', false)
$('#distance').attr('disabled', false)
$('#model').val( Number(objs[current].face_model) )
// first face is good enough as whole file has to have used same model
$('#model').val( document.viewing.file_details.faces[0].facefile_lnk.model_used )
}
else
{
@@ -102,33 +127,37 @@ function DrawImg()
}
// okay, we want faces drawn so lets do it
if( $('#faces').prop('checked') && objs[current].faces )
if( $('#faces').prop('checked') && document.viewing.file_details.faces )
{
faces=document.viewing.file_details.faces
// draw rect on each face
for( i=0; i<objs[current].faces.length; i++ )
for( i=0; i<faces.length; i++ )
{
x = objs[current].faces[i].x / ( im.width/canvas.width )
y = objs[current].faces[i].y / ( im.height/canvas.height )
w = objs[current].faces[i].w / ( im.width/canvas.width )
h = objs[current].faces[i].h / ( im.height/canvas.height )
x = faces[i].face_left / ( im.width/canvas.width )
y = faces[i].face_top / ( im.height/canvas.height )
w = faces[i].w / ( im.width/canvas.width )
h = faces[i].h / ( im.height/canvas.height )
context.beginPath()
context.rect( x, y, w, h )
context.lineWidth = 2
// this face has an override so diff colour
if( objs[current].faces[i].override )
if( faces[i].fnmo.length || faces[i].ffmo.length )
{
context.strokeStyle = 'blue'
DrawLabelOnFace( objs[current].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
{
context.strokeStyle = 'green'
if( objs[current].faces[i].who )
if( faces[i].refimg )
{
str=objs[current].faces[i].who
str=faces[i].refimg.person.tag
if( $('#distance').prop('checked') )
str += "("+objs[current].faces[i].distance+")"
str += "("+faces[i].refimg_lnk.face_distance.toFixed(2)+")"
DrawLabelOnFace( str )
}
}
@@ -156,13 +185,15 @@ function FaceToggle()
// also deals with fullsecreen if needed
function ViewImageOrVideo()
{
if( objs[current].type == 'Image' )
// can happen if no content to display
if( ! document.viewing ) return
if( document.viewing.type.name == 'Image' )
{
im.src='../' + objs[current].url
im.src='../' + document.viewing.FullPathOnFS + '?t=' + new Date().getTime();
$('#video_div').hide()
if( $('#fname_toggle').prop('checked' ) )
$('#img-cap').show()
$('#fname_i').html(PrettyFname(objs[current].url))
$('#fname_i').html(PrettyFname(document.viewing.FullPathOnFS))
$('#figure').show()
if( fullscreen )
$('#canvas').get(0).requestFullscreen()
@@ -170,11 +201,11 @@ function ViewImageOrVideo()
if( document.fullscreen )
document.exitFullscreen()
}
if( objs[current].type == 'Video' )
if( document.viewing.type.name == 'Video' )
{
$('#figure').hide()
$('#video').prop('src', '../' + objs[current].url )
$('#fname_v').html(PrettyFname(objs[current].url))
$('#video').prop('src', '../' + document.viewing.FullPathOnFS )
$('#fname_v').html(PrettyFname(document.viewing.FullPathOnFS))
if( $('#fname_toggle').prop('checked' ) )
$('#img-cap').hide()
ResizeVideo()
@@ -189,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();
@@ -196,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*)
@@ -221,32 +258,33 @@ $(document).ready( function()
item_list = { not_a_face: { name: "Not a face", which_face: '-1' } }
for( i=0; i<objs[current].faces.length; i++ )
faces=document.viewing.file_details.faces
for( i=0; i<faces.length; i++ )
{
fx = objs[current].faces[i].x / ( im.width/canvas.width )
fy = objs[current].faces[i].y / ( im.height/canvas.height )
fw = objs[current].faces[i].w / ( im.width/canvas.width )
fh = objs[current].faces[i].h / ( im.height/canvas.height )
fx = faces[i].face_left / ( im.width/canvas.width )
fy = faces[i].face_top / ( im.height/canvas.height )
fw = faces[i].w / ( im.width/canvas.width )
fh = faces[i].h / ( im.height/canvas.height )
if( x >= fx && x <= fx+fw && y >= fy && y <= fy+fh )
{
if( objs[current].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': objs[current].faces[i].id }
item_list['remove_force_match_override']={ 'name': 'Remove override for this face', 'which_face': i, 'id': faces[i].id }
}
else if( objs[current].faces[i].who )
else if( faces[i].refimg )
{
item_list['match']={ 'name': objs[current].faces[i].who, 'which_face': i, 'id': objs[current].faces[i].id }
item_list['match_add_refimg']={ 'name': 'Add this as refimg for ' + objs[current].faces[i].who,
'person_id': objs[current].faces[i].pid, 'who': objs[current].faces[i].who, 'which_face': i, 'id': objs[current].faces[i].id, }
item_list['wrong_person']={ 'name': 'wrong person', 'which_face': i, 'id': objs[current].faces[i].id }
item_list['match']={ 'name': faces[i].refimg.person.tag, 'which_face': i, 'id': faces[i].id }
item_list['match_add_refimg']={ 'name': 'Add this as refimg for ' + faces[i].refimg.person.tag,
'person_id': faces[i].refimg.person.id, 'who': faces[i].refimg.person.tag, 'which_face': i, 'id': faces[i].id, }
item_list['wrong_person']={ 'name': 'wrong person', 'which_face': i, 'id': faces[i].id }
}
else
{
item_list['no_match_new_person']={ 'name': 'Add as reference image to NEW person', 'which_face': i, 'id': objs[current].faces[i].id }
item_list['no_match_new_refimg']={ 'name': 'Add as reference image to EXISTING person', 'which_face': i, 'id': objs[current].faces[i].id }
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': objs[current].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']
@@ -266,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) {
objs[current].faces[item[key].which_face].override={}
objs[current].faces[item[key].which_face].override.who=data.person_tag
objs[current].faces[item[key].which_face].override.distance='N/A'
objs[current].faces[item[key].which_face].override.type_id=NMO[fm_idx].id
objs[current].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()
@@ -294,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
@@ -302,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) {
objs[current].faces[item[key].which_face].who=data.who
objs[current].faces[item[key].which_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) {
objs[current].faces[item[key].which_face].who=data.who
objs[current].faces[item[key].which_face].distance=data.distance
$('#dbox').modal('hide')
$('#faces').prop('checked',true)
DrawImg()
CheckForJobs()
}
success: function(data) { handleAddRefimgData(key, data ) },
})
}
@@ -341,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>'
@@ -365,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( objs[current].faces[face_pos].override )
who=objs[current].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=objs[current].faces[face_pos].who
who=document.viewing.file_details.faces[face_pos].refimg.person.tag
d='&face_id='+objs[current].faces[face_pos].id+'&person_tag='+who+'&file_eid='+current
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 objs[current].faces[face_pos].override
// force/delete the ffmo cleanly
document.viewing.file_details.faces[face_pos].ffmo.length=0
$('#dbox').modal('hide')
DrawImg()
CheckForJobs()
@@ -385,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='+objs[current].faces[face_pos].id+'&type_id='+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 objs[current].faces[face_pos].override
document.viewing.file_details.faces[face_pos].fnmo.length=0
$('#dbox').modal('hide')
DrawImg()
CheckForJobs()
@@ -400,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) {
objs[current].faces[face_pos].override={}
objs[current].faces[face_pos].override.who=NMO[type_id].name
objs[current].faces[face_pos].override.distance='N/A'
objs[current].faces[face_pos].override.type_id=type_id
objs[current].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()
@@ -418,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>'
@@ -457,17 +491,17 @@ function FaceDBox(key, item)
div+='</div><div class="col-6">'
if ( key == 'remove_force_match_override' )
{
if( objs[current].faces[face_pos].override.type_name == 'Manual match to existing person' )
div+='<div class="row col-12">remove this override (force match to: ' + objs[current].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( objs[current].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+','+objs[current].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' )
@@ -501,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;'
@@ -542,7 +575,7 @@ function FaceDBox(key, item)
// pops results up in a dbox
function JoblogSearch()
{
data="eid="+current
data="eid="+document.viewing.id
$.ajax({ type: 'POST', data: data, url: '/joblog_search', success: function(res) {
data = JSON.parse(res)
div ='<div><table class="table table-striped table-sm sm-txt">'
@@ -559,3 +592,168 @@ 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()
}

View File

@@ -1,37 +0,0 @@
// 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)
{
CheckForJobs()
$.ajax(
{
type: 'POST', data: '&job_id='+job_id, url: '/check_transform_job', success: function(data) {
if( data.finished )
{
// stop throbber, remove grayscale & then force reload with timestamped version of im.src
grayscale=0
throbber=0
im.src=im.src + '?t=' + new Date().getTime();
return false;
}
else
{
setTimeout( function() { CheckTransformJob(id,job_id) }, 1000,id, job_id );
}
},
} )
}
// for each highlighted image, POST the transform with amt (90, 180, 270,
// fliph, flipv) which will let the job manager know what to do to this file.
// we also grayscale the thumbnail out, remove the entry class for now, show
// the spinning wheel, and finally kick of the checking for the transform job
// to finish
function Transform(amt)
{
post_data = '&amt='+amt+'&id='+current
// 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(current,data.job_id); return false; } })
}

BIN
internal/white-circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

42
job.py
View File

@@ -3,12 +3,14 @@ from flask_wtf import FlaskForm
from flask import request, render_template, redirect, make_response, jsonify, url_for
from settings import Settings
from main import db, app, ma
from sqlalchemy import Sequence, func
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
@@ -280,7 +299,8 @@ def joblog_search():
from sqlalchemy import text
eid=request.form['eid']
ent=Entry.query.get(eid)
stmt = select(Entry).where(Entry.id == eid)
ent = db.session.scalars(stmt).one_or_none()
logs=Joblog.query.join(Job).filter(Joblog.log.ilike(text(f"'%%{ent.name}%%'"))).with_entities(Joblog.log, Job.id, Job.name, Job.state, Joblog.log_date).all()
# turn DB output into json and return it to the f/e
@@ -308,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

@@ -246,6 +246,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,42 +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 PA_UserState and in the DB (via sqlalchemy)
# the state for a User defines a series of remembered states for a user
# to optimise their viewing, etc. If we scan and fine new files, we need to
# invalidate these cached values, so we have this class here just for that
##############################################################################
class PA_UserState(Base):
__tablename__ = "pa_user_state"
id = Column(Integer, Sequence('pa_user_state_id_seq'), primary_key=True )
pa_user_dn = Column(String, ForeignKey('pa_user.dn'), primary_key=True )
last_used = Column(DateTime(timezone=True))
path_type = Column(String, primary_key=True, unique=False, nullable=False )
noo = Column(String, unique=False, nullable=False )
grouping = Column(String, unique=False, nullable=False )
how_many = Column(Integer, unique=False, nullable=False )
st_offset = Column(Integer, unique=False, nullable=False )
size = Column(Integer, unique=False, nullable=False )
folders = Column(Boolean, unique=False, nullable=False )
root = Column(String, unique=False, nullable=False )
cwd = Column(String, unique=False, nullable=False )
## for now being lazy and not doing a separate table until I settle on needed fields and when
# only used if ptype == View
view_eid = Column(Integer, unique=False, nullable=False )
orig_ptype = Column(String, unique=False, nullable=False )
# only used if view and orig_ptype was search
orig_search_term = Column(String, unique=False, nullable=False )
orig_url = Column(String, unique=False, nullable=False )
current = Column(Integer)
first_eid = Column(Integer)
last_eid = Column(Integer)
num_entries = Column(Integer)
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}, st_offset: {self.st_offset}, size: {self.size}, folders: {self.folders}, root: {self.root}, cwd: {self.cwd}, view_eid: {self.view_eid}, orig_ptype: {self.orig_ptype}, orig_search_term: {self.orig_search_term}, orig_url: {self.orig_url}, current={self.current}, first_eid={self.first_eid}, last_eid={self.last_eid}, num_entries={self.num_entries}>"
################################################################################
# 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
@@ -1131,7 +1105,6 @@ def DisconnectAllOverrides(job):
def JobForceScan(job):
JobProgressState( job, "In Progress" )
DisconnectAllOverrides(job)
session.query(PA_UserState).delete()
session.query(FaceFileLink).delete()
session.query(FaceRefimgLink).delete()
session.query(Face).delete()
@@ -1668,18 +1641,6 @@ def find_last_successful_ai_scan(job):
return ai_job.last_update.timestamp()
return 0
####################################################################################################################################
# when an import job actually finds new files, then the pa_user_state caches will become invalid (offsets are now wrong)
####################################################################################################################################
def DeleteOldPA_UserState(job):
# clear them out for now - this is 'dumb', just delete ALL. Eventually, can do this based on just the path &/or whether the last_used is
# newer than this delete moment (only would be a race condition between an import changing things and someone simultaneously viewing)
# path=[jex.value for jex in job.extra if jex.name == "path"][0]
session.query(PA_UserState).delete()
return
####################################################################################################################################
# JobImportDir(): job that scan import dir and processes entries in there - key function that uses os.walk() to traverse the
# file system and calls AddFile()/AddDir() as necessary
@@ -1788,8 +1749,6 @@ def JobImportDir(job):
if found_new_files:
job.extra.append( JobExtra( name="new_files", value=str(found_new_files) ) )
session.add(job)
# this will invalidate pa_user_state for this path's contents (offsets are now wrong), clear them out
DeleteOldPA_UserState(job)
rm_cnt=HandleAnyFSDeletions(job)
@@ -1914,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" )
@@ -1925,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" )
@@ -1948,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
@@ -2225,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
@@ -2239,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
@@ -2253,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
@@ -2758,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()

47
path.py
View File

@@ -42,50 +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]
################################################################################
# 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 )
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 )
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 ) )

41
query.py Normal file
View File

@@ -0,0 +1,41 @@
from flask_login import UserMixin, login_required
from main import db
#from sqlalchemy import Sequence
#from flask import request, redirect, make_response, jsonify
#from main import db, app, ma
#from sqlalchemy.exc import SQLAlchemyError
# pylint: disable=no-member
################################################################################
# Class describing Person in the database and DB via sqlalchemy
# id is unique id in DB
# dn is ldap distinguised name
# any entry in this DB is effectively a record you already authed successfully
# so acts as a session marker. If you fail ldap auth, you dont get a row here
################################################################################
class Query(UserMixin,db.Model):
__tablename__ = "query"
id = db.Column(db.Integer, db.Sequence('query_id_seq'), primary_key=True)
path_type = db.Column(db.String)
noo = db.Column(db.String)
grouping = db.Column(db.String)
q_offset = db.Column(db.Integer)
folder = db.Column(db.Boolean)
entry_list = db.Column(db.String)
root = db.Column(db.String)
cwd = db.Column(db.String)
search_term = db.Column(db.String)
current = db.Column(db.Integer)
created = db.Column(db.DateTime(timezone=True))
def __repr__(self):
str=f"<{self.__class__.__name__}("
for k, v in self.__dict__.items():
str += f"{k}={v!r}, "
str=str.rstrip(", ") + ")>"
return str
def get_id(self):
return self.dn

View File

@@ -1,4 +1,5 @@
numpy==1.26.4
setuptools
flask
flask_login
flask-ldap3-login

View File

@@ -93,8 +93,8 @@ def CreateSelect(name, selected, list, js="", add_class="", vals={} ):
# TODO: can this be collapsed into using above - probably if the 'selected' passed in was 'In Folder' or 'Flat View' -- but I think that isn't in a var???
# Helper function used in html files to create a bootstrap'd select with options. Same as CreateSelect() really, only contains
# hard-coded True/False around the if selected part, but with string based "True"/"False" in the vals={}, and list has "In Folders", "Flat View"
def CreateFoldersSelect(selected, add_class=""):
str = f'<select id="folders" name="folders" class="{add_class} sm-txt bg-white text-info border-info border-1 p-1" onChange="this.form.submit()">'
def CreateFoldersSelect(selected, js="", add_class=""):
str = f'<select id="folders" name="folders" class="{add_class} sm-txt bg-white text-info border-info border-1 p-1" onChange="{js};this.form.submit()">'
# if selected is true, then folders == true, so make this the selected option
if( selected ):
str += '<option selected value="True">In Folders</option>'

261
states.py
View File

@@ -1,10 +1,12 @@
from flask import request, render_template, redirect, url_for
from settings import Settings, SettingsIPath, SettingsSPath, SettingsRBPath
from flask_login import login_required, current_user
from main import db, app, ma
from shared import PA
from user import PAUser
from datetime import datetime
from job import SetFELog
from shared import SymlinkName
import pytz
import re
@@ -17,30 +19,17 @@ class PA_UserState(db.Model):
__tablename__ = "pa_user_state"
id = db.Column(db.Integer, db.Sequence('pa_user_state_id_seq'), primary_key=True )
pa_user_dn = db.Column(db.String, db.ForeignKey('pa_user.dn'), primary_key=True )
last_used = db.Column(db.DateTime(timezone=True))
path_type = db.Column(db.String, primary_key=True, unique=False, nullable=False )
noo = db.Column(db.String, unique=False, nullable=False )
grouping = db.Column(db.String, unique=False, nullable=False )
how_many = db.Column(db.Integer, unique=False, nullable=False )
st_offset = db.Column(db.Integer, unique=False, nullable=False )
size = db.Column(db.Integer, unique=False, nullable=False )
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 )
## for now being lazy and not doing a separate table until I settle on needed fields and when
# only used if ptype == View
view_eid = db.Column(db.Integer, unique=False, nullable=False )
orig_ptype = db.Column(db.String, unique=False, nullable=False )
# only used if view and orig_ptype was search
orig_search_term = db.Column(db.String, unique=False, nullable=False )
orig_url = db.Column(db.String, unique=False, nullable=False )
current = db.Column(db.Integer)
first_eid = db.Column(db.Integer)
last_eid = db.Column(db.Integer)
num_entries = db.Column(db.Integer)
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}, st_offset: {self.st_offset}, size: {self.size}, folders: {self.folders}, root: {self.root}, cwd: {self.cwd}, view_eid: {self.view_eid}, orig_ptype: {self.orig_ptype}, orig_search_term: {self.orig_search_term}, orig_url: {self.orig_url}, current={self.current}, first_eid={self.first_eid}, last_eid={self.last_eid}, num_entries={self.num_entries}>"
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}>"
################################################################################
@@ -50,156 +39,35 @@ class PA_UserState(db.Model):
################################################################################
class States(PA):
def __init__(self, request):
self.path_type=''
self.orig_search_term = ''
self.url = request.path
self.view_eid = None
self.current=0
self.first_eid=0
self.last_eid=0
self.num_entries=0
# this is any next/prev or noo, grouping, etc. change (so use referrer to work out what to do with this)
# because this can happen on a view, or files_up, etc. change this FIRST
if 'change_file_opts' in request.path:
base=request.base_url
base=base.replace("change_file_opts", "")
self.url = "/"+request.referrer.replace(base, "" )
# if view_list, then we really are a view, and view_eid should be in the form
if 'view_list' in request.path:
self.path_type = 'View'
self.view_eid = request.form['view_eid']
self.url = request.form['orig_url']
# this occurs ONLY when a POST to /view/<id> occurs (at this stage orig_url will be from an import, storage, bin or search)
elif 'view' in request.path:
self.path_type = 'View'
self.view_eid = self.url[6:]
# use orig url to define defaults/look up states for 'last' import/storage/bin/search
if request.method == "POST":
self.url = request.form['orig_url']
else:
# GET's occur on redirect, and we don't have a form, so get it from pref
st=self.url[8:]
if request.referrer and 'search' in request.referrer:
st=re.sub( '.+/search/', '', request.referrer )
else:
st=''
pref=PA_UserState.query.filter(PA_UserState.pa_user_dn==current_user.dn,PA_UserState.path_type==self.path_type,PA_UserState.view_eid==self.view_eid,PA_UserState.orig_search_term==st).first()
if not pref:
SetFELog( message=f"ERROR: pref not found - dn={current_user.dn}, st={st}, s={self}????" , level="danger", persistent=True, cant_close=False )
SetFELog( message=f"WARNING: I think this error occurred because you reloaded a page and the server had restarted between your original page load and this page reload, is that possible?" , level="warning", persistent=True, cant_close=False )
redirect("/")
else:
if not hasattr( pref, 'orig_url' ):
SetFELog( message=f"ERROR: orig_url not in pref - dn={current_user.dn}, st={st}, self={self}, pref={pref}????" , level="danger", persistent=True, cant_close=True )
redirect("/")
self.url = pref.orig_url
# set the prefix based on path
path=None
if 'files_ip' in self.url or 'file_list_ip' in self.url:
if self.path_type == "View":
self.orig_ptype = 'Import'
self.orig_url = self.url
else:
self.path_type = 'Import'
path = SettingsIPath()
elif 'files_sp' in self.url:
if self.path_type == "View":
self.orig_ptype = 'Storage'
self.orig_url = self.url
else:
self.path_type = 'Storage'
path = SettingsSPath()
elif 'files_rbp' in self.url:
if self.path_type == "View":
self.orig_ptype = 'Bin'
self.orig_url = self.url
else:
self.path_type = 'Bin'
path = SettingsRBPath()
elif 'search' in self.url:
# okay if we are a search, but came from a view then get last_search_state form prefs and use it
m=re.match( '.*search/(.+)$', self.url )
if m == None:
SetFELog( message=f"ERROR: DDP messed up, seems we are processing a search, but cant see the search term - is this even possible?" )
return
self.orig_search_term = m[1]
if self.path_type == "View":
self.orig_ptype = 'Search'
self.orig_url = self.url
else:
self.path_type = 'Search'
elif 'view' in self.url:
# use url to get eid of viewed entry
self.view_eid = self.url[6:]
# force this to be a search so rest of code won't totally die, but also not return anything
self.path_type="Search"
self.orig_url=self.url
elif 'change_file_opts' not in self.url:
SetFELog( message=f"ERROR: DDP messed up, failed to match URL {self.url} for settings this will fail, redirecting to home" , level="danger", persistent=True, cant_close=True )
SetFELog( message=f"referrer={request.referrer}" , level="danger", persistent=True, cant_close=True )
return
if self.path_type == 'View':
pref=PA_UserState.query.filter(PA_UserState.pa_user_dn==current_user.dn,PA_UserState.path_type==self.path_type,PA_UserState.view_eid==self.view_eid,PA_UserState.orig_search_term==self.orig_search_term).first()
if not hasattr( self, 'orig_ptype' ):
self.orig_ptype='View'
self.orig_url=''
SetFELog( message=f"ERROR: No orig ptype? s={self} - pref={pref}, redirecting to home" , level="danger", persistent=True, cant_close=True )
SetFELog( message=f"referrer={request.referrer}" , level="danger", persistent=True, cant_close=True )
redirect("/")
# should find original path or search for this view (if not a search, search_term='')
orig_pref=PA_UserState.query.filter(PA_UserState.pa_user_dn==current_user.dn,PA_UserState.path_type==self.orig_ptype,PA_UserState.orig_search_term==self.orig_search_term).first()
if not orig_pref:
SetFELog( message=f"ERROR: DDP messed up 2, failed to find orig_pref for a view pt={self.path_type} for search={self.orig_search_term}" , level="danger", persistent=True, cant_close=True )
SetFELog( message=f"referrer={request.referrer}" , level="danger", persistent=True, cant_close=True )
return
elif self.path_type == 'Search':
pref=PA_UserState.query.filter(PA_UserState.pa_user_dn==current_user.dn,PA_UserState.path_type==self.path_type,PA_UserState.orig_search_term==self.orig_search_term).first()
self.search_term = ''
else:
pref=PA_UserState.query.filter(PA_UserState.pa_user_dn==current_user.dn,PA_UserState.path_type==self.path_type).first()
self.path_type=''
if pref:
self.grouping=pref.grouping
self.how_many=pref.how_many
self.offset=pref.st_offset
self.size=pref.size
self.cwd=pref.cwd
self.orig_ptype=pref.orig_ptype
self.orig_search_term=pref.orig_search_term
self.orig_url = pref.orig_url
self.view_eid = pref.view_eid
self.current = pref.current
if self.path_type == "View":
self.root='static/' + self.orig_ptype
self.first_eid=orig_pref.first_eid
self.last_eid=orig_pref.last_eid
self.num_entries=orig_pref.num_entries
self.noo=orig_pref.noo
self.folders=orig_pref.folders
self.orig_search_term=orig_pref.orig_search_term
else:
self.root=pref.root
self.first_eid = pref.first_eid
self.last_eid = pref.last_eid
self.num_entries = pref.num_entries
self.noo=pref.noo
self.folders=pref.folders
if path:
self.prefix = SymlinkName(self.path_type,path,path+'/')
else:
self.prefix=None
# retreive defaults from 'PAUser' where defaults are stored
u=PAUser.query.filter(PAUser.dn==current_user.dn).one()
self.grouping=u.default_grouping
self.how_many=u.default_how_many
self.offset=0
self.size=u.default_size
if self.path_type == "View":
self.root='static/' + self.orig_ptype
self.first_eid=orig_pref.first_eid
self.last_eid=orig_pref.last_eid
self.num_entries=orig_pref.num_entries
self.noo=orig_pref.noo
self.folders=orig_pref.folders
self.orig_search_term=orig_pref.orig_search_term
else:
self.root='static/' + self.path_type
if self.path_type == 'Import':
self.noo = u.default_import_noo
@@ -212,102 +80,17 @@ class States(PA):
self.noo=u.default_search_noo
self.folders=False
self.default_flat_noo=u.default_import_noo
self.default_folder_noo=u.default_storage_noo
self.default_search_noo=u.default_search_noo
self.cwd=self.root
if not hasattr(self, 'orig_ptype'):
self.orig_ptype=None
if not hasattr(self, 'orig_search_term'):
self.orig_search_term=None
self.orig_url = self.url
# the above are defaults, if we are here, then we have current values, use them instead if they are set -- AI: searches dont set them so then we use those in the DB first
if request.method=="POST":
if self.path_type != "View" and 'noo' in request.form:
# we are changing values based on a POST to the form, if we changed the noo option, we need to reset things
if 'change_file_opts' in request.path and self.noo != request.form['noo']:
self.noo=request.form['noo']
self.first_eid=0
self.last_eid=0
self.offset=0
if 'how_many' in request.form:
self.how_many=request.form['how_many']
if 'offset' in request.form:
self.offset=int(request.form['offset'])
if 'grouping' in request.form:
self.grouping=request.form['grouping']
# this can be null if we come from view by details
if 'size' in request.form:
self.size = request.form['size']
# seems html cant do boolean, but uses strings so convert
if self.path_type != "View" and 'folders' in request.form:
# we are changing values based on a POST to the form, if we are in folder view and we changed the folders option, we need to reset things
if 'change_file_opts' in request.path:
if self.folders and self.folders != request.form['folders']:
self.num_entries=0
self.first_eid=0
self.last_eid=0
if request.form['folders'] == "False":
self.folders=False
else:
self.folders=True
# have to force grouping to None if we flick to folders from a flat view with grouping (otherwise we print out
# group headings for child content that is not in the CWD)
self.grouping=None
if 'orig_url' in request.form:
self.orig_url = request.form['orig_url']
# possible to not be set for an AI: search
if 'cwd' in request.form:
self.cwd = request.form['cwd']
if 'prev' in request.form:
self.offset -= int(self.how_many)
# just in case we hit prev too fast, stop this...
if self.offset < 0:
self.offset=0
if 'next' in request.form:
if (self.offset + int(self.how_many)) < self.num_entries:
self.offset += int(self.how_many)
else:
# tripping this still
SetFELog( message=f"WARNING: next image requested, but would go past end of list? - ignore this" , level="warning", persistent=True, cant_close=False )
if 'current' in request.form:
self.current = int(request.form['current'])
last_used=datetime.now(pytz.utc)
# now save pref
if not pref:
# insert new pref for this combo (might be a new search or view, or first time for a path)
pref=PA_UserState( pa_user_dn=current_user.dn, last_used=last_used, path_type=self.path_type, view_eid=self.view_eid,
noo=self.noo, grouping=self.grouping, how_many=self.how_many, st_offset=self.offset, size=self.size,
folders=self.folders, root=self.root, cwd=self.cwd, orig_ptype=self.orig_ptype, orig_search_term=self.orig_search_term,
orig_url=self.orig_url, current=self.current, first_eid=self.first_eid, last_eid=self.last_eid, num_entries=self.num_entries )
else:
# update this pref with the values calculated above (most likely from POST to form)
pref.pa_user_dn=current_user.dn
pref.path_type=self.path_type
pref.view_eid=self.view_eid
pref.noo=self.noo
pref.grouping=self.grouping
pref.how_many=self.how_many
pref.st_offset=self.offset
pref.size=self.size
pref.folders=self.folders
pref.root = self.root
pref.cwd = self.cwd
pref.orig_ptype = self.orig_ptype
pref.orig_search_term = self.orig_search_term
pref.orig_url = self.orig_url
pref.last_used = last_used
pref.first_eid = self.first_eid
pref.last_eid = self.last_eid
pref.num_entries = self.num_entries
# only passed in (at the moment) in view_list
pref.current = self.current
db.session.add(pref)
db.session.commit()
return
def to_dict(self):
# Automatically include all instance attributes
return {key: value for key, value in vars(self).items()}
################################################################################
# /states -> GET only -> prints out list of all prefs (simple for now)
################################################################################

View File

@@ -1,189 +1,209 @@
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;
create sequence FACE_ID_SEQ;
create sequence PATH_ID_SEQ;
create sequence PATH_TYPE_ID_SEQ;
create sequence FILE_ID_SEQ;
create sequence FILE_TYPE_ID_SEQ;
create sequence JOBEXTRA_ID_SEQ;
create sequence JOBLOG_ID_SEQ;
create sequence JOB_ID_SEQ;
create sequence PERSON_ID_SEQ;
create sequence REFIMG_ID_SEQ;
create sequence SETTINGS_ID_SEQ;
create sequence PA_JOB_MANAGER_ID_SEQ;
create sequence PA_JOB_MANAGER_FE_MESSAGE_ID_SEQ;
create sequence FACE_OVERRIDE_TYPE_ID_SEQ;
create sequence FACE_OVERRIDE_ID_SEQ;
CREATE SEQUENCE pa_user_id_seq;
CREATE SEQUENCE pa_user_state_id_seq;
CREATE SEQUENCE face_id_seq;
CREATE SEQUENCE path_id_seq;
CREATE SEQUENCE path_type_id_seq;
CREATE SEQUENCE file_id_seq;
CREATE SEQUENCE file_type_id_seq;
CREATE SEQUENCE jobextra_id_seq;
CREATE SEQUENCE joblog_id_seq;
CREATE SEQUENCE job_id_seq;
CREATE SEQUENCE person_id_seq;
CREATE SEQUENCE refimg_id_seq;
CREATE SEQUENCE settings_id_seq;
CREATE SEQUENCE pa_job_manager_id_seq;
CREATE SEQUENCE pa_job_manager_fe_message_id_seq;
CREATE SEQUENCE face_override_type_id_seq;
CREATE SEQUENCE face_override_id_seq;
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' );
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' );
create table SETTINGS(
ID integer,
BASE_PATH varchar, IMPORT_PATH varchar, STORAGE_PATH varchar, RECYCLE_BIN_PATH varchar, METADATA_PATH varchar,
AUTO_ROTATE Boolean,
DEFAULT_REFIMG_MODEL integer, DEFAULT_SCAN_MODEL integer, DEFAULT_THRESHOLD float,
FACE_SIZE_LIMIT integer,
SCHEDULED_IMPORT_SCAN integer, SCHEDULED_STORAGE_SCAN integer,
SCHEDULED_BIN_CLEANUP integer, BIN_CLEANUP_FILE_AGE integer,
JOB_ARCHIVE_AGE integer,
constraint PK_SETTINGS_ID primary key(ID),
constraint FK_DEFAULT_REFIMG_MODEL foreign key (DEFAULT_REFIMG_MODEL) references AI_MODEL(ID),
constraint FK_DEFAULT_SCAN_MODEL foreign key (DEFAULT_SCAN_MODEL) references AI_MODEL(ID) );
CREATE TABLE settings(
id INTEGER,
base_path VARCHAR, import_path VARCHAR, storage_path VARCHAR, recycle_bin_path VARCHAR, metadata_path VARCHAR,
auto_rotate BOOLEAN,
default_refimg_model INTEGER, default_scan_model INTEGER, default_threshold FLOAT,
face_size_limit INTEGER,
scheduled_import_scan INTEGER, scheduled_storage_scan INTEGER,
scheduled_bin_cleanup INTEGER, bin_cleanup_file_age INTEGER,
job_archive_age INTEGER,
CONSTRAINT pk_settings_id PRIMARY KEY(id),
CONSTRAINT fk_default_refimg_model FOREIGN KEY (default_refimg_model) REFERENCES ai_model(id),
CONSTRAINT fk_default_scan_model FOREIGN KEY (default_scan_model) REFERENCES ai_model(id) );
create table PA_USER(
ID integer,
DN varchar unique,
DEFAULT_IMPORT_NOO varchar,
DEFAULT_STORAGE_NOO varchar,
DEFAULT_SEARCH_NOO varchar,
DEFAULT_GROUPING varchar(16),
DEFAULT_HOW_MANY integer,
DEFAULT_SIZE integer,
DEFAULT_IMPORT_FOLDERS Boolean,
DEFAULT_STORAGE_FOLDERS Boolean,
constraint PK_PA_USER_ID primary key(ID) );
CREATE TABLE pa_user(
id INTEGER,
dn VARCHAR UNIQUE,
default_import_noo VARCHAR,
default_storage_noo VARCHAR,
default_search_noo VARCHAR,
default_grouping VARCHAR(16),
default_how_many INTEGER,
default_size INTEGER,
default_import_folders BOOLEAN,
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
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, ST_OFFSET integer, SIZE integer, FOLDERS Boolean,
ROOT varchar, CWD varchar,
ORIG_PTYPE varchar, ORIG_SEARCH_TERM varchar, ORIG_URL varchar,
VIEW_EID integer, CURRENT integer, FIRST_EID integer, LAST_EID integer, NUM_ENTRIES integer, LAST_USED timestamptz,
constraint FK_PA_USER_DN foreign key (PA_USER_DN) references PA_USER(DN),
constraint PK_PA_USER_STATES_ID primary key(ID ) );
-- 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,
CONSTRAINT fk_pa_user_dn FOREIGN KEY (pa_user_dn) REFERENCES pa_user(dn),
CONSTRAINT pk_pa_user_states_id PRIMARY KEY(id ) );
create table FILE_TYPE ( ID integer, NAME varchar(32) unique, constraint PK_FILE_TYPE_ID primary key(ID) );
create table PATH_TYPE ( ID integer, NAME varchar(16) unique, constraint PK_PATH_TYPE_ID primary key(ID) );
create table PATH ( ID integer, TYPE_ID integer, PATH_PREFIX varchar(1024), NUM_FILES integer,
constraint PK_PATH_ID primary key(ID),
constraint FK_PATH_TYPE_TYPE_ID foreign key (TYPE_ID) references PATH_TYPE(ID) );
create table ENTRY( ID integer, NAME varchar(128), TYPE_ID integer, EXISTS_ON_FS boolean,
constraint PK_ENTRY_ID primary key(ID),
constraint FK_FILE_TYPE_TYPE_ID foreign key (TYPE_ID) references FILE_TYPE(ID) );
create table FILE ( EID integer, SIZE_MB integer, HASH varchar(34), THUMBNAIL varchar, FACES_CREATED_ON float, LAST_HASH_DATE float, LAST_AI_SCAN float, YEAR integer, MONTH integer, DAY integer, WOY integer,
constraint PK_FILE_ID primary key(EID),
constraint FK_FILE_ENTRY_ID foreign key (EID) references ENTRY(ID) );
create table DEL_FILE ( FILE_EID integer, ORIG_PATH_PREFIX varchar(1024), constraint PK_DEL_FILE_FILE_EID primary key (FILE_EID),
constraint FK_ENTRY_ID foreign key (FILE_EID) references FILE(EID) );
create table DIR ( EID integer, REL_PATH varchar(256), NUM_FILES integer, LAST_IMPORT_DATE float,
constraint PK_DIR_EID primary key(EID),
constraint FK_DIR_ENTRY_ID foreign key (EID) references ENTRY(ID) );
create table PATH_DIR_LINK ( path_id integer, dir_eid integer,
constraint PK_PDL_path_id_dir_eid primary key (path_id, dir_eid),
constraint FK_PDL_PATH_ID foreign key (PATH_ID) references PATH(ID),
constraint FK_PDL_DIR_EID foreign key (DIR_EID) references DIR(EID) );
create table ENTRY_DIR_LINK ( entry_id integer, dir_eid integer,
constraint PK_EDL_entry_id_dir_eid primary key (entry_id, dir_eid),
constraint FK_EDL_ENTRY_ID foreign key (ENTRY_ID) references ENTRY(ID),
constraint FK_EDL_DIR_EID foreign key (DIR_EID) references DIR(EID) );
create table PERSON ( ID integer default nextval('PERSON_ID_SEQ'), TAG varchar(48), FIRSTNAME varchar(48), SURNAME varchar(48),
constraint PK_PERSON_ID primary key(ID) );
alter sequence PERSON_ID_SEQ owned by PERSON.ID;
CREATE TABLE query ( id INTEGER, path_type VARCHAR(16), noo VARCHAR(16), grouping VARCHAR(16), q_offset INTEGER,
entry_list VARCHAR, folders BOOLEAN, root VARCHAR, cwd VARCHAR, search_term VARCHAR, current INTEGER,
created TIMESTAMPTZ,
CONSTRAINT pk_query_id PRIMARY KEY(id ) );
create table REFIMG ( ID integer, FNAME varchar(128), FACE bytea, ORIG_W integer, ORIG_H integer,
FACE_TOP integer, FACE_RIGHT integer, FACE_BOTTOM integer, FACE_LEFT integer, CREATED_ON float, THUMBNAIL varchar, MODEL_USED integer,
constraint PK_REFIMG_ID primary key(ID),
constraint FK_REFIMG_MODEL_USED foreign key (MODEL_USED) references AI_MODEL(ID) );
alter sequence REFIMG_ID_SEQ owned by REFIMG.ID;
CREATE TABLE file_type ( id INTEGER, name VARCHAR(32) UNIQUE, CONSTRAINT pk_file_type_id PRIMARY KEY(id) );
create table FACE( ID integer, FACE bytea, FACE_TOP integer, FACE_RIGHT integer, FACE_BOTTOM integer, FACE_LEFT integer,
W integer, H integer, constraint PK_FACE_ID primary key(ID) );
CREATE TABLE path_type ( id INTEGER, name VARCHAR(16) UNIQUE, CONSTRAINT pk_path_type_id PRIMARY KEY(id) );
create table FACE_FILE_LINK( FACE_ID integer, FILE_EID integer, MODEL_USED integer,
constraint PK_FFL_FACE_ID_FILE_ID primary key(FACE_ID, FILE_EID),
constraint FK_FFL_FACE_ID foreign key (FACE_ID) references FACE(ID) on delete cascade,
constraint FK_FFL_FILE_EID foreign key (FILE_EID) references FILE(EID),
constraint FK_FFL_MODEL_USED foreign key (MODEL_USED) references AI_MODEL(ID) );
CREATE TABLE path ( id INTEGER, type_id INTEGER, path_prefix VARCHAR(1024), num_files INTEGER,
CONSTRAINT pk_path_id PRIMARY KEY(id),
CONSTRAINT fk_path_type_type_id FOREIGN KEY (type_id) REFERENCES path_type(id) );
create table FACE_REFIMG_LINK( FACE_ID integer, REFIMG_ID integer, FACE_DISTANCE float,
constraint PK_FRL_FACE_ID_REFIMG_ID primary key(FACE_ID, REFIMG_ID),
constraint FK_FRL_FACE_ID foreign key (FACE_ID) references FACE(ID) on delete cascade,
constraint FK_FRL_REFIMG_ID foreign key (REFIMG_ID) references REFIMG(ID) );
CREATE TABLE entry( id INTEGER, name VARCHAR(128), type_id INTEGER, exists_on_fs BOOLEAN,
CONSTRAINT pk_entry_id PRIMARY KEY(id),
CONSTRAINT fk_file_type_type_id FOREIGN KEY (type_id) REFERENCES file_type(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' );
CREATE TABLE file ( eid INTEGER, size_mb INTEGER, hash VARCHAR(34), thumbnail VARCHAR, faces_created_on FLOAT, last_hash_date FLOAT, last_ai_scan FLOAT, year INTEGER, month INTEGER, day INTEGER, woy INTEGER,
CONSTRAINT pk_file_id PRIMARY KEY(eid),
CONSTRAINT fk_file_entry_id FOREIGN KEY (eid) REFERENCES entry(id) );
CREATE TABLE del_file ( file_eid INTEGER, orig_path_prefix VARCHAR(1024), CONSTRAINT pk_del_file_file_eid PRIMARY KEY (file_eid),
CONSTRAINT fk_entry_id FOREIGN KEY (file_eid) REFERENCES file(eid) );
CREATE TABLE dir ( eid INTEGER, rel_path VARCHAR(256), num_files INTEGER, last_import_date FLOAT,
CONSTRAINT pk_dir_eid PRIMARY KEY(eid),
CONSTRAINT fk_dir_entry_id FOREIGN KEY (eid) REFERENCES entry(id) );
CREATE TABLE path_dir_link ( PATH_ID INTEGER, DIR_EID INTEGER,
CONSTRAINT pk_pdl_PATH_ID_DIR_EID PRIMARY KEY (PATH_ID, DIR_EID),
CONSTRAINT fk_pdl_path_id FOREIGN KEY (path_id) REFERENCES path(id),
CONSTRAINT fk_pdl_dir_eid FOREIGN KEY (dir_eid) REFERENCES dir(eid) );
CREATE TABLE entry_dir_link ( ENTRY_ID INTEGER, DIR_EID INTEGER,
CONSTRAINT pk_edl_ENTRY_ID_DIR_EID PRIMARY KEY (ENTRY_ID, DIR_EID),
CONSTRAINT fk_edl_entry_id FOREIGN KEY (entry_id) REFERENCES entry(id),
CONSTRAINT fk_edl_dir_eid FOREIGN KEY (dir_eid) REFERENCES dir(eid) );
CREATE TABLE person ( id INTEGER DEFAULT NEXTVAL('person_id_seq'), tag VARCHAR(48), firstname VARCHAR(48), surname VARCHAR(48),
CONSTRAINT pk_person_id PRIMARY KEY(id) );
ALTER SEQUENCE person_id_seq OWNED BY person.id;
CREATE TABLE refimg ( id INTEGER, fname VARCHAR(128), face BYTEA, orig_w INTEGER, orig_h INTEGER,
face_top INTEGER, face_right INTEGER, face_bottom INTEGER, face_left INTEGER, created_on FLOAT, thumbnail VARCHAR, model_used INTEGER,
CONSTRAINT pk_refimg_id PRIMARY KEY(id),
CONSTRAINT fk_refimg_model_used FOREIGN KEY (model_used) REFERENCES ai_model(id) );
ALTER SEQUENCE refimg_id_seq OWNED BY refimg.id;
CREATE TABLE face( id INTEGER, face BYTEA, face_top INTEGER, face_right INTEGER, face_bottom INTEGER, face_left INTEGER,
w INTEGER, h INTEGER, CONSTRAINT pk_face_id PRIMARY KEY(id) );
CREATE TABLE face_file_link( face_id INTEGER, file_eid INTEGER, model_used INTEGER,
CONSTRAINT pk_ffl_face_id_file_id PRIMARY KEY(face_id, file_eid),
CONSTRAINT fk_ffl_face_id FOREIGN KEY (face_id) REFERENCES face(id) ON DELETE CASCADE,
CONSTRAINT fk_ffl_file_eid FOREIGN KEY (file_eid) REFERENCES file(eid),
CONSTRAINT fk_ffl_model_used FOREIGN KEY (model_used) REFERENCES ai_model(id) );
CREATE TABLE face_refimg_link( face_id INTEGER, refimg_id INTEGER, face_distance FLOAT,
CONSTRAINT pk_frl_face_id_refimg_id PRIMARY KEY(face_id, refimg_id),
CONSTRAINT fk_frl_face_id FOREIGN KEY (face_id) REFERENCES face(id) ON DELETE CASCADE,
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' );
-- 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.
-- any reordering of faces, generates new face_ids... (but if the face data was the same, then this override should stand)
create table FACE_NO_MATCH_OVERRIDE ( ID integer, FACE_ID integer, TYPE_ID integer,
constraint FK_FNMO_FACE_ID foreign key (FACE_ID) references FACE(ID),
constraint FK_FNMO_TYPE foreign key (TYPE_ID) references FACE_OVERRIDE_TYPE(ID),
constraint PK_FNMO_ID primary key(ID) );
CREATE TABLE face_no_match_override ( id INTEGER, face_id INTEGER, type_id INTEGER,
CONSTRAINT fk_fnmo_face_id FOREIGN KEY (face_id) REFERENCES face(id),
CONSTRAINT fk_fnmo_type FOREIGN KEY (type_id) REFERENCES face_override_type(id),
CONSTRAINT pk_fnmo_id PRIMARY KEY(id) );
-- manual match goes to person not refimg, so on search, etc. we deal with this anomaly (via sql not ORM)
create table FACE_FORCE_MATCH_OVERRIDE ( ID integer, FACE_ID integer, PERSON_ID integer, constraint PK_FACE_FORCE_MATCH_OVERRIDE_ID primary key(ID) );
CREATE TABLE face_force_match_override ( id INTEGER, face_id INTEGER, person_id INTEGER, CONSTRAINT pk_face_force_match_override_id PRIMARY KEY(id) );
create table DISCONNECTED_NO_MATCH_OVERRIDE ( FACE bytea, TYPE_ID integer,
constraint FK_DNMO_TYPE_ID foreign key (TYPE_ID) references FACE_OVERRIDE_TYPE(ID),
constraint PK_DNMO_FACE primary key (FACE) );
CREATE TABLE disconnected_no_match_override ( face BYTEA, type_id INTEGER,
CONSTRAINT fk_dnmo_type_id FOREIGN KEY (type_id) REFERENCES face_override_type(id),
CONSTRAINT pk_dnmo_face PRIMARY KEY (face) );
create table DISCONNECTED_FORCE_MATCH_OVERRIDE ( FACE bytea, PERSON_ID integer,
constraint FK_DFMO_PERSON_ID foreign key (PERSON_ID) references PERSON(ID),
constraint PK_DFMO_FACE primary key (FACE) );
CREATE TABLE disconnected_force_match_override ( face BYTEA, person_id INTEGER,
CONSTRAINT fk_dfmo_person_id FOREIGN KEY (person_id) REFERENCES person(id),
CONSTRAINT pk_dfmo_face PRIMARY KEY (face) );
create table PERSON_REFIMG_LINK ( PERSON_ID integer, REFIMG_ID integer,
constraint PK_PRL primary key(PERSON_ID, REFIMG_ID),
constraint FK_PRL_PERSON_ID foreign key (PERSON_ID) references PERSON(ID),
constraint FK_PRL_REFIMG_ID foreign key (REFIMG_ID) references REFIMG(ID),
constraint U_PRL_REFIMG_ID unique(REFIMG_ID) );
CREATE TABLE person_refimg_link ( person_id INTEGER, refimg_id INTEGER,
CONSTRAINT pk_prl PRIMARY KEY(person_id, refimg_id),
CONSTRAINT fk_prl_person_id FOREIGN KEY (person_id) REFERENCES person(id),
CONSTRAINT fk_prl_refimg_id FOREIGN KEY (refimg_id) REFERENCES refimg(id),
CONSTRAINT u_prl_refimg_id UNIQUE(refimg_id) );
create table JOB (
ID integer, START_TIME timestamptz, LAST_UPDATE timestamptz, NAME varchar(64), STATE varchar(128),
NUM_FILES integer, CURRENT_FILE_NUM integer, CURRENT_FILE varchar(256), WAIT_FOR integer, PA_JOB_STATE varchar(48),
constraint PK_JOB_ID primary key(ID) );
CREATE TABLE job (
id INTEGER, start_time TIMESTAMPTZ, last_update TIMESTAMPTZ, name VARCHAR(64), state VARCHAR(128),
num_files INTEGER, current_file_num INTEGER, current_file VARCHAR(256), wait_for INTEGER, pa_job_state VARCHAR(48),
CONSTRAINT pk_job_id PRIMARY KEY(id) );
-- used to pass / keep extra values, e.g. num_files for jobs that have sets of files, or out* for adding output from jobs that you want to pass to next job in the chain
create table JOBEXTRA ( ID integer, JOB_ID integer, NAME varchar(32), VALUE varchar,
constraint PK_JOBEXTRA_ID primary key(ID), constraint FK_JOBEXTRA_JOB_ID foreign key(JOB_ID) references JOB(ID) );
CREATE TABLE jobextra ( id INTEGER, job_id INTEGER, name VARCHAR(32), value VARCHAR,
CONSTRAINT pk_jobextra_id PRIMARY KEY(id), CONSTRAINT fk_jobextra_job_id FOREIGN KEY(job_id) REFERENCES job(id) );
create table JOBLOG ( ID integer, JOB_ID integer, LOG_DATE timestamptz, LOG varchar,
constraint PK_JL_ID primary key(ID), constraint FK_JL_JOB_ID foreign key(JOB_ID) references JOB(ID) );
CREATE TABLE joblog ( id INTEGER, job_id INTEGER, log_date TIMESTAMPTZ, log VARCHAR,
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 FK_PA_JOB_MANAGER_FE_MESSAGE_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 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' );
--insert into PERSON values ( (select nextval('PERSON_ID_SEQ')), 'mum', 'Mandy', 'De Paoli' );
--insert into PERSON values ( (select nextval('PERSON_ID_SEQ')), 'cam', 'Cameron', 'De Paoli' );
--insert into PERSON values ( (select nextval('PERSON_ID_SEQ')), 'mich', 'Michelle', 'De Paoli' );
--INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'dad', 'Damien', 'De Paoli' );
--INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'mum', 'Mandy', 'De Paoli' );
--INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'cam', 'Cameron', 'De Paoli' );
--INSERT INTO person VALUES ( (SELECT NEXTVAL('person_id_seq')), 'mich', 'Michelle', 'De Paoli' );
-- 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 );
--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:
--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')), '/export/docker/storage/', 'Camera_uploads/', 'photos/', '.pa_bin/', '.pa_metadata/', true, 1, 1, '0.55', 43, 1, 1, 7, 30, 4 );
--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')), '/export/docker/storage/', 'Camera_uploads/', 'photos/', '.pa_bin/', '.pa_metadata/', TRUE, 1, 1, '0.55', 43, 1, 1, 7, 30, 4 );

View File

@@ -136,7 +136,7 @@
{% if not InDBox %}
{%block script_content %}{% endblock script_content %}
<div id="status_container" class="position-fixed top-0 end-0 p-0 my-5" "z-index: 11"> </div>
<div id="status_container" class="position-fixed top-0 end-0 p-0 my-5" style="z-index: 9999"> </div>
<!-- CheckForJobs(), will see if there are any messages/jobs and keep doing this until there are 0 more and then stop -->
<script>
$(document).ready(function() { CheckForJobs() } )

View File

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

View File

@@ -1,45 +1,43 @@
{% 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_transform.js')}}"></script>
<script>
document.fake_shift=0
document.fake_ctrl=0
var move_paths=[]
{% for p in move_paths %}
p = new Object()
p.type = '{{p.type}}'
p.path = '{{p.path}}'
p.icon_url = '{{p.icon_url}}'
move_paths.push(p)
{% endfor %}
document.OPT = '{{OPT}}'
document.entries = '{{entry_data}}'
document.how_many = '{{OPT.how_many}}'
document.entries_len = '{{entry_data|length}}'
</script>
<style>
@media (max-width: 576px) {
#la, #ra {
padding: 5% !important;
}
}
.norm-txt { font-size: 1.0rem }
.form-check-input:checked {
background-color: #39C0ED;
border-color: #CFF4FC;
}
.form-switch .form-check-input {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2339C0ED'/%3e%3c/svg%3e");
}
.form-switch .form-check-input:focus {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23CFF4FC'/%3e%3c/svg%3e");
}
#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')}}?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">
<form id="main_form" method="POST" action="/change_file_opts">
<input type="hidden" name="cwd" id="cwd" value="{{OPT.cwd}}">
{% if search_term is defined %}
<input type="hidden" name="search_term" id="view_term" value="{{search_term}}">
{% endif %}
<div class="d-flex row mb-2">
{% if OPT.folders %}
<div class="my-auto col col-auto">
<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>
@@ -47,15 +45,14 @@
{% endif %}
<div class="col col-auto">
<div class="input-group">
{{CreateSelect( "noo", OPT.noo, ["Oldest", "Newest","A to Z", "Z to A"], "$('#offset').val(0)", "rounded-start py-2")|safe }}
{{CreateSelect( "how_many", OPT.how_many|string, ["10", "25", "50", "75", "100", "150", "200", "500"])|safe }}
{{CreateSelect( "noo", OPT.noo, ["Oldest", "Newest","A to Z", "Z to A"], "changeOPT(getPageFigures); return false", "rounded-start py-2")|safe }}
{{CreateSelect( "how_many", OPT.how_many|string, ["10", "25", "50", "75", "100", "150", "200", "500"], "changeOPT(getPageFigures); return false" )|safe }}
{% if OPT.folders %}
<input type="hidden" name="grouping" id="grouping" value="{{OPT.grouping}}">
{{CreateFoldersSelect( OPT.folders, "rounded-end" )|safe }}
{{CreateFoldersSelect( OPT.folders, "changeOPT(getPageFigures); return false", "rounded-end" )|safe }}
{% else %}
{{CreateFoldersSelect( OPT.folders )|safe }}
{{CreateFoldersSelect( OPT.folders, "changeOPT(getPageFigures); return false" )|safe }}
<span class="sm-txt my-auto btn btn-outline-info disabled border-top border-bottom">grouped by:</span>
{{CreateSelect( "grouping", OPT.grouping, ["None", "Day", "Week", "Month"], "", "rounded-end")|safe }}
{{CreateSelect( "grouping", OPT.grouping, ["None", "Day", "Week", "Month"], "OPT.grouping=$('#grouping').val();drawPageOfFigures();return false", "rounded-end")|safe }}
{% endif %}
</div class="input-group">
</div class="col">
@@ -63,361 +60,271 @@
<div class="col col-auto my-auto">
<span class="alert alert-primary p-2">Searched for: '{{search_term}}'</span>
</div class="col my-auto">
<script>
$('#folders').prop('disabled', 'disabled').removeClass('border-info').addClass('border-secondary').removeClass('text-info').addClass('text-secondary');
</script>
{% endif %}
<div class="col flex-grow-1 my-auto d-flex justify-content-center w-100">
<button aria-label="prev" id="prev" name="prev" class="prev sm-txt btn btn-outline-secondary">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
<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')}}?v={{js_vers['ic']}}#prev"/></svg>
</button>
<span class="sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
{% set nxt_disabled="" %}
{% if not entry_data or entry_data|length < OPT.how_many|int %}
{% set nxt_disabled="disabled" %}
{% endif %}
<button aria-label="next" id="next" {{nxt_disabled}} name="next" class="next sm-txt btn btn-outline-secondary">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
<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')}}?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>
<button style="visibility:hidden" class="btn btn-outline-secondary" aria-label="ctrl-key" id="ctrl-key" onclick="document.fake_ctrl=1-document.fake_ctrl; event.stopPropagation(); return false">ctrl</button>
</div>
</div class="col flex-grow-1">
<div class="d-flex col col-auto justify-content-end">
<div class="btn-group">
{% if OPT.size == 64 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="extra small" id="64" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(64)">XS</button>
{% if OPT.size == 96 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="small" id="96" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(96)">S</button>
{% if OPT.size == 128 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="medium" id="128" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(128)">M</button>
{% if OPT.size == 192 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="large" id="192" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(192)">L</button>
{% if OPT.size == 256 %}
{% set bt="btn-info text-white" %}
{% else %}
{% set bt="btn-outline-info" %}
{% endif %}
<button aria-label="extra large" id="256" class="px-2 sm-txt sz-but btn {{bt}}" onClick="$('#size').val(256)">XL</button>
</div class="btn-group">
</div class="col">
<input id="offset" type="hidden" name="offset" value="{{OPT.offset}}">
<input id="size" type="hidden" name="size" value="{{OPT.size}}">
</div class="form-row">
{% set eids=namespace( str="" ) %}
{# gather all the file eids and collect them in case we go gallery mode #}
{% for obj in entry_data %}
{% if obj.type.name != "Directory" %}
{% set eids.str = eids.str + obj.id|string +"," %}
{% endif %}
{% endfor %}
<input name="eids" id="eids" type="hidden" value="{{eids.str}}">
</form>
</div>
{% set ecnt=namespace( val=0 ) %}
<div class="row ms-2">
{% set last = namespace(printed=0) %}
{# rare event of empty folder, still need to show back button #}
{% if OPT.folders and entry_data|length == 0 %}
{% if OPT.cwd != OPT.root %}
<figure id="_back" class="dir entry m-1" ecnt="{{ecnt.val}}" dir="{{OPT.cwd|ParentPath}}" type="Directory">
<svg class="svg" width="{{OPT.size|int-22}}" height="{{OPT.size|int-22}}"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#folder_back"/></svg>
<figcaption class="figure-caption text-center">Back</figcaption>
</figure class="figure">
{% set ecnt.val=ecnt.val+1 %}
<script>f=$('#_back'); w=f.find('svg').width(); f.find('figcaption').width(w);</script>
{% else %}
<div class="col col-auto g-0 m-1">
<svg class="svg" width="{{OPT.size|int-22}}" height="{{OPT.size|int-22}}"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#folder_back_gray"/></svg>
</div>
{% endif %}
{% endif %}
{% if not entry_data %}
<span class="alert alert-danger p-2 col-auto"> No matches for: '{{search_term}}'</span>
{% endif %}
{% for obj in entry_data %}
{% if loop.index==1 and OPT.folders %}
{% if OPT.cwd != OPT.root %}
<figure class="col col-auto g-0 dir entry m-1" ecnt="{{ecnt.val}}" dir="{{OPT.cwd|ParentPath}}" type="Directory">
<svg class="svg" width="{{OPT.size|int-22}}" height="{{OPT.size|int-22}}" fill="currentColor">
<use xlink:href="{{url_for('internal', filename='icons.svg')}}#folder_back"/></svg>
<figcaption class="svg_cap figure-caption text-center">Back</figcaption>
</figure class="figure">
{% set ecnt.val=ecnt.val+1 %}
{% else %}
{# create an even lighter-grey, unclickable back button - so folders dont jump around when you go into them #}
<div class="col col-auto g-0 m-1">
<svg class="svg" width="{{OPT.size|int-22}}" height="{{OPT.size|int-22}}"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#folder_back_gray"/></svg>
</div>
{% endif %}
{% endif %}
{% if not OPT.folders and obj.type.name == "Directory" %}
{% continue %}
{% endif %}
{% if OPT.grouping == "Day" %}
{% if last.printed != obj.file_details.day %}
<div class="row ps-3"><h6>Day: {{obj.file_details.day}} of {{obj.file_details.month}}/{{obj.file_details.year}}</h6></div>
{% set last.printed = obj.file_details.day %}
{% endif %}
{% elif OPT.grouping == "Week" %}
{% if last.printed != obj.file_details.woy %}
<div class="row ps-3"><h6>Week #: {{obj.file_details.woy}} of {{obj.file_details.year}}</h6></div>
{% set last.printed = obj.file_details.woy %}
{% endif %}
{% elif OPT.grouping == "Month" %}
{% if last.printed != obj.file_details.month %}
<div class="row ps-3"><h6>Month: {{obj.file_details.month}} of {{obj.file_details.year}}</h6></div>
{% set last.printed = obj.file_details.month %}
{% endif %}
{% endif %}
{% if obj.type.name == "Image" or obj.type.name == "Video" or obj.type.name == "Unknown" %}
{% if (not OPT.folders) or ((obj.in_dir.in_path.path_prefix+'/'+obj.in_dir.rel_path+'/'+obj.name) | TopLevelFolderOf(OPT.cwd)) %}
<figure id="{{obj.id}}" ecnt="{{ecnt.val}}" class="col col-auto g-0 figure entry m-1" path_type="{{obj.in_dir.in_path.type.name}}" size="{{obj.file_details.size_mb}}" hash="{{obj.file_details.hash}}" in_dir="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}" fname="{{obj.name}}" yr="{{obj.file_details.year}}" date="{{obj.file_details.year}}{{"%02d" % obj.file_details.month}}{{"%02d" % obj.file_details.day}}" pretty_date="{{obj.file_details.day}}/{{obj.file_details.month}}/{{obj.file_details.year}}" type="{{obj.type.name}}">
{% if obj.type.name=="Image" or obj.type.name=="Unknown" %}
<div style="position:relative; width:100%">
{% if obj.file_details.thumbnail %}
<a href="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}/{{obj.name}}">
<img alt="{{obj.name}}" class="thumb" height="{{OPT.size}}" src="data:image/jpeg;base64,{{obj.file_details.thumbnail}}"></img></a>
{% else %}
<a href="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}/{{obj.name}}">
<svg width="{{OPT.size}}" height="{{OPT.size}}" fill="white"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#unknown_ftype"/></svg>
</a>
{% endif %}
{% if search_term is defined %}
<div style="position:absolute; bottom: 0px; left: 2px;">
<svg width="16" height="16" fill="white"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#{{LocationIcon(obj)}}"/></svg>
</div>
{% endif %}
<div id="s{{obj.id}}" style="display:none; position:absolute; top: 50%; left:50%; transform:translate(-50%, -50%);">
<img height="64px" src="{{url_for('internal', filename='throbber.gif')}}"></img>
</div>
</div>
{% elif obj.type.name == "Video" %}
<div style="position:relative; width:100%">
{% if obj.file_details.thumbnail %}
<a href="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}/{{obj.name}}">
<img alt="{{obj.name}}" class="thumb" height="{{OPT.size}}" src="data:image/jpeg;base64,{{obj.file_details.thumbnail}}"></img></a>
{% else %}
<a href="{{obj.in_dir.in_path.path_prefix}}/{{obj.in_dir.rel_path}}/{{obj.name}}">
<svg width="{{OPT.size}}" height="{{OPT.size}}" fill="white"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#unknown_ftype"/></svg>
</a>
{% endif %}
<div class="btn-group" role="group" aria-label="Size radio button group">
<input type="radio" class="btn-check" name="size" id="size-xs" onCLick="changeSize()" autocomplete="off" value="64">
<label class="btn btn-outline-info btn-radio" for="size-xs">XS</label>
<div style="position:absolute; top: 0px; left: 2px;">
<svg width="16" height="16" fill="white"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#film"/></svg>
</div>
{% if search_term is defined %}
<div style="position:absolute; bottom: 0px; left: 2px;">
<svg width="16" height="16" fill="white"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#{{LocationIcon(obj)}}"/></svg>
</div>
{% endif %}
</div>
{% endif %}
</figure>
{% set ecnt.val=ecnt.val+1 %}
{% endif %}
{% elif obj.type.name == "Directory" %}
{% if OPT.folders %}
{% if obj.dir_details.rel_path | length %}
{% set dirname=obj.dir_details.in_path.path_prefix+'/'+obj.dir_details.rel_path %}
{% else %}
{% set dirname=obj.dir_details.in_path.path_prefix %}
{% endif %}
{# if this dir is the toplevel of the cwd, show the folder icon #}
{% if dirname| TopLevelFolderOf(OPT.cwd) %}
<figure class="col col-auto g-0 dir entry m-1" id={{obj.id}} ecnt={{ecnt.val}} dir="{{dirname}}" type="Directory">
<svg class="svg" width="{{OPT.size|int-22}}" height="{{OPT.size|int-22}}" fill="currentColor">
<use xlink:href="{{url_for('internal', filename='icons.svg')}}#Directory"/></svg>
<figcaption class="svg_cap figure-caption text-center text-wrap text-break">{{obj.name}}</figcaption>
</figure class="figure">
{% set ecnt.val=ecnt.val+1 %}
<script>f=$('#{{obj.id}}'); w=f.find('svg').width(); f.find('figcaption').width(w);</script>
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
<input type="radio" class="btn-check" name="size" id="size-s" onCLick="changeSize()" autocomplete="off" value="96">
<label class="btn btn-outline-info btn-radio" for="size-s">S</label>
<input type="radio" class="btn-check" name="size" id="size-m" onCLick="changeSize()" autocomplete="off" value="128">
<label class="btn btn-outline-info btn-radio" for="size-m">M</label>
<input type="radio" class="btn-check" name="size" id="size-l" onCLick="changeSize()" autocomplete="off" value="192">
<label class="btn btn-outline-info btn-radio" for="size-l">L</label>
<input type="radio" class="btn-check" name="size" id="size-xl" onCLick="changeSize()" autocomplete="off" value="256">
<label class="btn btn-outline-info btn-radio" for="size-xl">XL</label>
</div>
</div class="d-flex col">
</div class="d-flex row mb-2">
</div container="fluid">
<div id="figures" class="row ms-2">
</div>
<div class="container-fluid">
<form id="nav_form" method="POST" action="/change_file_opts">
<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">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
<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')}}?v={{js_vers['ic']}}#prev"/></svg>
</button>
<span class="sm-txt my-auto">&nbsp;{{OPT.how_many}} files&nbsp;</span>
<button aria-label="next" id="next" {{nxt_disabled}} name="next" class="next sm-txt btn btn-outline-secondary">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
<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')}}?v={{js_vers['ic']}}#next"/></svg>
</button>
</div class="col my-auto">
</div class="row">
</div class="container-fluid">
</div id="files_div">
<div id="viewer_div" class="d-none">
<div id="viewer" class="container-fluid">
<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')}}?v={{js_vers['ic']}}#back"></use>
</svg>
</button>
<!-- 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>
</div>
</form>
</div class="container">
{% endblock main_content %}
{% block script_content %}
<figure style="position: relative;" class="col col-auto border border-info rounded m-0 p-1" id="figure">
<canvas id="canvas"></canvas>
<!-- next 4 are placeholders and called on during amendments only in viewer code -->
<img id="throbber" src="{{url_for('internal', filename='throbber.gif')}}?v={{js_vers[th]}}" style="display:none;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
var context = canvas.getContext('2d')
</script>
<figcaption id="img-cap" class="figure-caption text-center text-wrap text-break">
<span id="fname_i"></span></figcaption>
</figure>
<div id="video_div" class="col col-auto">
<video id="video" class="col col-auto" controls>
<source id="videoSource" src="" type="video/mp4">
Your browser does not support the video tag.
</video>
<figcaption id="vid-cap" class="figure-caption text-center text-wrap text-break">
<span id="fname_v"></span></figcaption>
</div>
<!-- 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')}}?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')}}?v={{js_vers['ic']}}#next"/></svg>
</button>
</div>
<span class="col-auto my-auto">Show:</span>
<div title="Toggle showing filename (hotkey: n)" class="d-flex form-check form-switch border border-info rounded col col-auto my-auto py-1 justify-content-center ps-5">
<input class="form-check-input" type="checkbox" id="fname_toggle" onChange="$('.figure-caption').toggle()" checked>
<label class="form-check-label ps-1" for="fname_toggle">Filename</label>
</div>
<div title="Toggle showing matched faces (hotkey: f)" class="d-flex form-check form-switch border border-info rounded col col-auto my-auto py-1 justify-content-center ps-5">
<input class="form-check-input" type="checkbox" onChange="FaceToggle()" id="faces">
<label class="form-check-label ps-1" for="faces">Faces</label>
</div>
<div title="Toggle showing 'distance' on matched faces (hotkey: d)" class="d-flex form-check form-switch border border-info rounded col col-auto my-auto py-1 justify-content-center ps-5">
<input class="form-check-input" type="checkbox" onChange="DrawImg()" id="distance">
<label class="form-check-label ps-1" for="distance">Distance</label>
</div>
<div title="Change the model used to detect faces" class="col col-auto my-auto">
AI Model:
{# can use 0 as default, it will be (re)set correctly in DrawImg() anyway #}
{{CreateSelect( "model", 0, ["N/A", "normal", "slow/accurate"], "", "rounded norm-txt", [0,1,2])|safe }}
</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')}}?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')}}?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')}}?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')}}?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')}}?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')}}?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')}}?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')}}?v={{js_vers['ic']}}#download"/></svg>
</button>
<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">
</div id="viewer">
</div id="viewer_div">
{% endblock main_content %}
$('.figure').click( function(e) { DoSel(e, this ); SetButtonState(); return false; });
$(document).on('click', function(e) { $('.highlight').removeClass('highlight') ; SetButtonState() });
{% block script_content %}
<script>
// GLOBALS
document.fake_shift=0
document.fake_ctrl=0
function CallViewRouteWrapper()
// FIXME: used by viewer code - should probably get rid of this?
var fullscreen=false;
// 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}}
// pageList is just those entries shown on this page from the full entryList
var pageList=[]
// force pageList to set pageList for & render the first page
getPage(1,getPageFigures)
// gap is used to keep some space around video in viewer - tbh, not sure why anymore
var gap=0.8
function PrettyFname(fname)
{
CallViewRoute( $(this).attr("id") )
}
function CallViewRoute(id)
s='<span class="alert alert-secondary py-2">'
if( fname.indexOf( "static/Import" ) == 0 )
{
s='<form id="_fm" method="POST" action="/view/' + id + '">'
s+='<input type="hidden" name="eids" value="'+$("#eids").val() + '">'
s+='<input type="hidden" name="cwd" value="{{OPT.cwd}}">'
s+='<input type="hidden" name="root" value="{{OPT.root}}">'
s+='<input type="hidden" name="offset" value="{{OPT.offset}}">'
s+='<input type="hidden" name="how_many" value="{{OPT.how_many}}">'
s+='<input type="hidden" name="orig_url" value="{{request.path}}">'
s+='<input type="hidden" name="view_eid" value="'+id+'">'
{% if search_term is defined %}
s+='<input type="hidden" name="search_term" value="{{search_term}}">'
{% endif %}
s+='</form>'
$(s).appendTo('body').submit();
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","" )
}
$('.figure').dblclick( CallViewRouteWrapper )
// 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 )
if( fname.indexOf( "static/Storage" ) == 0 )
{
DoSel(e, e.currentTarget )
SetButtonState();
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( FiguresOrDirsOrBoth() == "figure" )
if( fname.indexOf( "static/Bin" ) == 0 )
{
item_list = {
details: { name: "Details..." },
view: { name: "View File" },
sep: "---",
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","" )
}
if( e.currentTarget.getAttribute('type') == 'Image' )
s+=tmp_path+'</span>'
return s
}
// 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') )
{
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" ) { CallViewRoute( $(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
};
}
$('#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');
});
$(document).ready(function() {
if( {{OPT.offset}} == 0 )
{
$('.prev').addClass('disabled')
$('.prev').prop('disabled', true)
}
$(".dir").click( function(e) { $('#offset').val(0) ; $('#cwd').val( $(this).attr('dir') ) ; $('#main_form').submit() } )
} )
$( 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;
} })
function isMobile() {
try{ document.createEvent("TouchEvent"); return true; }
catch(e){ return false; }
// 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() )

View File

@@ -34,9 +34,7 @@
<!-- browsers can put the fakepath in for security, remove it -->
function DoMagic() {
str=$("#new_file_chooser").val()
console.log(str)
str=str.replace('C:\\fakepath\\', '' )
console.log(str)
$("#fname").val(str)
}
</script>

View File

@@ -56,44 +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}}
{% if st.path_type == 'Search' %}
"{{st.orig_search_term}}"
{% endif %}
{% if st.path_type == 'View' %}
(orig: id={{st.view_eid}} in {{st.orig_ptype}})
{% if st.orig_ptype == 'Search' %}
"{{st.orig_search_term}}"
{% endif %}
{% endif %}
</td>
<td>{{st.noo}}</td>
<td>{{st.how_many}}</td>
<td>{{st.folders}}</td>
<td>{{st.grouping}}</td>
<td>{{st.size}}</td>
<td>{{st.st_offset}}</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

@@ -1,332 +0,0 @@
{% extends "base.html" %} {% block main_content %}
{# make the form-switch / toggle info color set, give or take #}
<style>
.norm-txt { font-size: 1.0rem }
.form-check-input:checked {
background-color: #39C0ED;
border-color: #CFF4FC;
}
.form-switch .form-check-input {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2339C0ED'/%3e%3c/svg%3e");
}
.form-switch .form-check-input:focus {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23CFF4FC'/%3e%3c/svg%3e");
}
#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/view_transform.js')}}"></script>
<script src="{{ url_for( 'internal', filename='js/view_support.js')}}"></script>
<script>
var gap=0.8
var grayscale=0
var throbber=0
var objs=[]
var NMO=[]
var current={{current}}
var eids="{{eids}}"
var eid_lst=eids.split(",")
var offset={{OPT.offset}}
var first_eid={{OPT.first_eid}}
var last_eid={{OPT.last_eid}}
var imp_path="static/Import/{{imp_path}}"
var st_path="static/Storage/{{st_path}}"
var bin_path="static/Bin/{{bin_path}}"
{% for id in objs %}
e=new Object()
e.url = "{{objs[id].FullPathOnFS()|safe}}"
e.type = "{{objs[id].type.name}}"
{% if objs[id].file_details.faces %}
e.face_model="{{objs[id].file_details.faces[0].facefile_lnk.model_used}}"
{% endif %}
e.faces=[]
{% for face in objs[id].file_details.faces %}
data = { 'id': '{{face.id}}', 'x': '{{face.face_left}}', 'y': '{{face.face_top}}', 'w': '{{face.w}}', 'h':'{{face.h}}' }
{% if face.refimg %}
data['pid']='{{face.refimg.person.id}}'
data['who']='{{face.refimg.person.tag}}'
data['distance']="{{face.refimg_lnk.face_distance|round(2)}}"
{% endif %}
{% if face.no_match_override %}
data['override'] = {
'face_id' : '{{face.no_match_override.face_id}}',
'type_id' : '{{face.no_match_override.type.id}}',
'type_name': '{{face.no_match_override.type.name}}',
'who' : '{{face.no_match_override.type.name}}',
'distance' : 'N/A'
}
{% endif %}
{% if face.manual_override %}
data['override'] = {
'face_id' : '{{face.manual_override.face_id}}',
'type_id' : '{{face.manual_override.type.id}}',
'type_name': '{{face.manual_override.type.name}}',
'who' : '{{face.manual_override.person.tag}}',
'distance' : 'N/A'
}
{% endif %}
e.faces.push( data )
{% endfor %}
objs[{{id}}]=e
{% endfor %}
{% for el in NMO_data %}
NMO[{{el.id}}] = { 'type_id': {{el.id}}, 'name': '{{el.name}}' }
{% endfor %}
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,"" )
}
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>'
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>'
tmp_path=fname.replace("static/Bin","" )
}
s+=tmp_path+'</span>'
return s
}
function CallViewListRoute(dir)
{
// dont allow mad spamming of arrows
$("#la").prop("disabled", true)
$("#ra").prop("disabled", true)
data="eids="+$("#eids").val()
data+="&cwd={{OPT.cwd}}"
data+="&root={{OPT.root}}"
data+="&orig_url={{OPT.orig_url}}"
data+="&view_eid={{OPT.view_eid}}"
// direction (next/prev)
data+="&"+dir+ "=1"
{% if search_term is defined %}
data+="&search_term={{search_term}}"
{% endif %}
$.ajax({ type: 'POST', data: data, url: '/view_list', success: function(res){
current=res.current
eids=res.eids
objs=res.objs
eid_lst=eids.split(",")
offset=res.offset
// okay, we now have results back, can reset next/prev buttons
if( current != first_eid )
$("#la").prop("disabled", false)
if( current != last_eid )
$("#ra").prop("disabled", false)
ViewImageOrVideo()
}
})
}
</script>
<div id="viewer" class="container-fluid">
{% set max=eids.split(',')|length %}
<input type="hidden" name="eids" value={{eids}}>
<div class="row">
<button title="Show previous image" class="col-auto btn btn-outline-info px-2" style="padding: 10%" id="la"
{% if OPT.first_eid == current %}
disabled
{% endif %}
onClick="
cidx = eid_lst.indexOf(current.toString())
prev=cidx-1
if( prev < 0 )
{
if( offset )
{
CallViewListRoute('prev')
return
}
else
{
$('#la').attr('disabled', true )
prev=0
}
}
$('#ra').attr('disabled', false )
current=eid_lst[prev]
ViewImageOrVideo()
if( current == first_eid )
$('#la').attr('disabled', true )
">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#prev"/></svg>
</button>
<figure 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;">
<script>
var im=new Image();
im.onload=DrawImg
im.src="../" + objs[current].url
var context = canvas.getContext('2d')
window.addEventListener('resize', DrawImg, false);
</script>
<figcaption id="img-cap" class="figure-caption text-center text-wrap text-break"><span id="fname_i"></span></figcaption>
</figure>
<script>$('#fname_i').html(PrettyFname(objs[current].url))</script>
{% if objs[current].type.name != "Image" %}
<script>$('#figure').hide()</script>
{% endif %}
<div id="video_div" class="col col-auto">
<video id="video" class="col col-auto" controls>
<source src="../{{objs[current].FullPathOnFS()}}" type="video/mp4">
Your browser does not support the video tag.
</video>
<figcaption id="vid-cap" class="figure-caption text-center text-wrap text-break"><span id="fname_v"></span></figcaption>
<script>$('#fname_v').html(PrettyFname(objs[current].url))</script>
</div>
<script>
window.addEventListener('resize', ResizeVideo, false);
ResizeVideo()
{% if objs[current].type.name != "Video" %}
$('#video_div').hide()
{% endif %}
</script>
<button title="Show next image" class="col-auto btn btn-outline-info px-2" style="padding: 10%" id="ra"
{% if OPT.last_eid == current %}
disabled
{% endif %}
onClick="
cidx = eid_lst.indexOf(current.toString())
if( cidx < eid_lst.length-1 )
{
current=eid_lst[cidx+1]
ViewImageOrVideo()
if( current != first_eid )
$('#la').attr('disabled', false )
}
else
CallViewListRoute('next')
if( current == last_eid )
{
$('#ra').attr('disabled', true )
return
}
">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
</button>
</div id="/form-row">
{# use this for color of toggles: https://www.codeply.com/p/4sL9uhevwJ #}
<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" disabled style="visibility:hidden">
<svg width="16" height="16" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#next"/></svg>
</button>
</div>
<span class="col-auto my-auto">Show:</span>
<div title="Toggle showing filename (hotkey: n)" class="d-flex form-check form-switch border border-info rounded col col-auto my-auto py-1 justify-content-center ps-5">
<input class="form-check-input" type="checkbox" id="fname_toggle" onChange="$('.figure-caption').toggle()" checked>
<label class="form-check-label ps-1" for="fname_toggle">Filename</label>
</div>
<div title="Toggle showing matched faces (hotkey: f)" class="d-flex form-check form-switch border border-info rounded col col-auto my-auto py-1 justify-content-center ps-5">
<input class="form-check-input" type="checkbox" onChange="FaceToggle()" id="faces">
<label class="form-check-label ps-1" for="faces">Faces</label>
</div>
<div title="Toggle showing 'distance' on matched faces (hotkey: d)" class="d-flex form-check form-switch border border-info rounded col col-auto my-auto py-1 justify-content-center ps-5">
<input class="form-check-input" type="checkbox" onChange="DrawImg()" id="distance">
<label class="form-check-label ps-1" for="distance">Distance</label>
</div>
<div title="Change the model used to detect faces" class="col col-auto my-auto">
AI Model:
{# can use 0 as default, it will be (re)set correctly in DrawImg() anyway #}
{{CreateSelect( "model", 0, ["N/A", "normal", "slow/accurate"], "", "rounded norm-txt", [0,1,2])|safe }}
</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')}}'" />
</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')}}'" />
</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')}}'" />
</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>
</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>
</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>
</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>
</button>
<button class="btn btn-outline-info p-1" title="View Original" onClick="window.location='/'+objs[current].url">
<svg width="32" height="32" fill="currentColor"><use xlink:href="{{url_for('internal', filename='icons.svg')}}#download"/></svg>
</button>
<button id="del" class="btn btn-outline-danger p-1" title="Delete (hotkey: Del)"
onClick="$.ajax({ type: 'POST', data: '&eid-0={{current}}', 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>
</div>
</div class="row">
{% endblock main_content %}
{% block script_content %}
<script>
$( 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":
if( $('#la').prop('disabled') == false )
$('#la').click()
break;
case "Right": // IE/Edge specific value
case "ArrowRight":
if( $('#ra').prop('disabled') == false )
$('#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.
}
});
var fullscreen=false;
</script>
{% endblock script_content %}

View File

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