Compare commits

..
311 Commits
Author SHA1 Message Date
franzz de8a3f07e5 Gaia Updater fix (new Gaia GPS upload process) 2020-12-28 19:34:41 +01:00
franzz 17411c1f19 Remove rotation fix (fixed in browser) 2020-12-04 21:13:18 +01:00
franzz efd3a73c09 Fix popup hovering (bubbling) 2 2020-08-20 22:42:09 +02:00
franzz cc1d9d97db Fix popup hovering (bubbling) 2020-08-20 22:02:57 +02:00
franzz b6ec8c908e Store maps (layers) settings in DB + display only project-relevant layers 2020-08-18 23:12:40 +02:00
franzz da3b16db46 Fix scroll top calculation on focus event 2020-08-17 18:18:18 +02:00
franzz b40d813634 Add link on feed items 2020-08-16 22:17:24 +02:00
franzz f05cdbf351 Better handling of # variables + bump simplebar to v5.2.1 2020-08-16 10:34:49 +02:00
franzz b8a8d75084 Adding Last Update 2020-08-14 14:58:29 +02:00
franzz d605acbd51 Gaia GPS Upload Greasemonkey Script 2020-06-20 15:14:39 +02:00
franzz 7d9a61cdd4 Media comment embedded in picture 2020-05-17 12:16:36 +02:00
franzz 332f3df940 Fix lightbox close button position in vertical mode 2020-05-16 12:15:54 +02:00
franzz 342d4dda8d Fix lightbox close icon position & size 2020-05-16 11:23:40 +02:00
franzz 341d61b29e Newsletter: Add custom link to media/post 2020-05-13 19:23:39 +02:00
franzz 6a8552ee47 Fix message drill-down icon position 2020-05-10 18:40:27 +02:00
franzz 9552814421 Fix Timezone display on media 2020-05-10 15:41:26 +02:00
franzz 6e39b12a5f Fix cookie typo 2020-05-10 11:38:41 +02:00
franzz 2aebcb00cc Fix lightbox portrait mode: dynamic media data position 2020-05-09 19:46:09 +02:00
franzz c86dce74c2 Fix video fullscreen mode on mobile 2020-05-08 22:59:46 +02:00
franzz ebdad2e657 Set Same Site value on Cookie (Firefox requirement) 2020-05-08 21:23:22 +02:00
franzz 888e32520b Bump d3, jQuery & mods 2020-05-08 21:12:21 +02:00
franzz a9adbb5045 elevation module replaced with HeightGraph (improved perfs) 2020-05-03 17:17:03 +02:00
franzz 06c1dcc6f4 Fix settings panel scrollbar on iOS safari 2020-05-02 22:16:05 +02:00
franzz 33bc059658 Adding credits 2020-04-26 17:54:39 +02:00
franzz 52669bed48 l10n typos & CSS fixes 2020-04-25 17:02:24 +02:00
franzz dc570f883b Fix Time Zone Name (when not using city names) 2020-04-20 15:42:42 +02:00
franzz 7ba9243fa1 Replace Leaftlet Elevation with HeightGraph 2020-04-20 11:57:53 +02:00
franzz fe7fd52357 Disable elevation module on IE & Edge. 2020-04-17 21:25:56 +02:00
franzz ffe7f3162f Remove email frequency early commit 2020-04-17 18:37:47 +02:00
franzz a33cda5f22 Email: Support multiple time zones 2020-04-17 18:32:12 +02:00
franzz e66ec08ab4 Add Timezone to user data 2020-04-17 11:00:11 +02:00
franzz 5d79123e75 Define user language from browser only 2020-04-14 20:43:46 +02:00
franzz a0a3ba2d26 Remove cookie on unsubscribe 2020-04-14 20:27:56 +02:00
franzz d6f2621368 Admin page: delete feed / project 2020-04-13 22:50:37 +02:00
franzz 47b693988a Add update emails 2020-04-13 18:06:48 +02:00
franzz a5fa444997 Fix auto-login (avoid multiple confirmation emails) 2020-04-11 08:56:31 +02:00
franzz 1a07aac836 Log in when subscribing an existing email 2020-04-11 08:36:37 +02:00
franzz abab8e1f22 Fix leaflet tooltip padding 2020-04-10 20:25:44 +02:00
franzz a9044de3f1 cron script: add cron config in comments 2020-04-10 14:16:07 +02:00
franzz e34c2b690d Generate Cron File 2020-04-10 09:14:59 +02:00
franzz a81f7a596b Fix border radius css feed panel 2 2020-04-06 21:35:57 +02:00
franzz 49ae92447b Fix border radius css feed panel 2020-04-06 21:30:49 +02:00
franzz 5bdccec6ce Update PCT track 2020 (pctmap.net) 2020-04-06 21:23:38 +02:00
franzz ef3dc986da Fix value assignment for users.active 2020-04-06 21:21:26 +02:00
franzz c01fe7f3bd Add list-unsubscribe 2020-04-06 20:59:53 +02:00
franzz 8f10febcf2 New logo 2020-04-06 17:54:39 +02:00
franzz 44ba1eae68 Replace mail() with PHPMailer + new logo 2020-04-06 17:53:49 +02:00
franzz 85e8c4a3d1 New settings panel & &subscription 2020-04-05 18:56:42 +02:00
franzz 9b59ec502e Fix feed panel display on mobile 2020-03-27 18:15:32 +01:00
franzz 90983754dc New Key Down event catching 2020-03-27 18:09:09 +01:00
franzz 741e4efa0d Auto pan map on feed panel opening/closing 2020-03-27 18:08:28 +01:00
franzz fa18493439 Fix leaflet elevation plugin 2020-03-27 18:07:45 +01:00
franzz 79dbb5ad54 Fix Elevation Panel Width 2020-03-26 21:12:43 +01:00
franzz a52c1d5782 Update PCT track 2020-03-26 17:54:09 +01:00
franzz de2e17c430 Improve admin page (add new projects) 2020-02-08 21:13:52 +01:00
franzz 7b4b965f15 Remove DB field geofile 2020-02-05 21:04:08 +01:00
franzz 37f1ccc8c1 Enlarge hover event triggering surface 2020-02-03 19:53:07 +01:00
franzz 05a2b1d581 Fix Geo File Path 2020-02-02 20:08:25 +01:00
franzz f170855ab8 Adding markers: trail start / end 2020-01-12 21:06:13 +01:00
franzz 273bd48b9f Bump leaflet 1.6.0 2020-01-12 16:29:00 +01:00
franzz 502147cb0f Add GPX file download 2020-01-02 14:57:02 +01:00
franzz 1cf03b6d15 Fix taken On / Posted On Media Dates 2019-12-13 19:46:15 +01:00
franzz 93d2161139 Bump simplebar to 5.0.7 2019-12-13 18:58:16 +01:00
franzz 00f8a9b763 Bump fileupload plugin to 10.4.0 2019-12-13 18:43:38 +01:00
franzz 156c0a0414 Fix crash in blog mode and no message 2019-12-13 18:34:13 +01:00
franzz 6260b80c96 Scale up travel times 2019-12-01 20:24:23 +01:00
franzz a364ad03e8 Use track's comment to store geojson generation options 2019-11-29 22:34:33 +01:00
franzz 80a9364be6 Fix no post detection 2019-11-28 10:20:13 +01:00
franzz 9d9309c623 Adding elevation difference to the GPX simplification 2019-11-24 13:26:23 +01:00
franzz 44651b3f3e Add PCT tracks and update converter to include GPX simplication 2019-11-23 19:10:40 +01:00
franzz 8a530a13ed update todo list 2019-11-20 19:28:56 +01:00
franzz 4f38c14413 Change Main Constructor parameter positions 2019-11-02 19:52:41 +01:00
franzz 1ed68c866f Link pic & post 2019-11-01 23:12:32 +01:00
franzz 584accc72e Add zoom to lightbox 2019-10-28 19:06:22 +01:00
franzz 9a78604ff2 merge lightbox calls (post / markers) 2019-10-24 21:32:05 +02:00
franzz f129e90005 lightbox: use video object throughout 2019-10-22 20:21:33 +02:00
franzz 5404a79947 Bump jquery to 3.4.1 2019-10-22 18:40:28 +02:00
franzz 83d5e4bcd6 Bump leaflet to 1.5.1 2019-10-22 18:28:47 +02:00
franzz 5dcb04a1d7 Fix lightbox videos 2019-10-21 20:17:11 +02:00
franzz acf81386ce new lightbox interface v2 2019-10-20 20:08:15 +02:00
franzz 7dbf5bef87 fix lightbox video interface 2019-10-20 16:29:57 +02:00
franzz fb38feb51c new lightbox interface v1 2019-10-20 16:20:29 +02:00
franzz 2369a4302c Bump font awesome 5.11.2 2019-10-20 16:09:57 +02:00
franzz 6f040d61c5 lightbox 2.11.1 css 2019-10-20 00:13:21 +02:00
franzz faaa24012b bump lightbox to 2.11.1 2019-10-19 22:01:22 +02:00
franzz 68cb62a3fb Convert all time to browser timezone 2019-10-19 21:04:22 +02:00
franzz 56deda45fe Translate object counter 2019-10-19 19:49:03 +02:00
franzz 43ad3c4193 Improve media retrieval performance 2019-10-19 19:47:33 +02:00
franzz 00cf52ead7 Fix lang js function 2019-10-19 19:42:57 +02:00
franzz 20a23e9957 i18n 2019-10-19 18:27:26 +02:00
franzz 03aa057a11 Fix elevation 2019-10-18 17:08:47 +02:00
franzz 7ec6d25220 initialize translation 2019-10-13 18:58:56 +02:00
franzz 34f152dead Rounded legend tracks 2019-10-13 11:22:22 +02:00
franzz 9bd8cd5342 Update todo list 2019-09-02 22:40:37 +02:00
franzz d6939598d3 Update HRP track with actual data 2019-09-02 21:15:34 +02:00
franzz 56948cb59f Fix thumbnail orientation 2019-09-01 21:00:06 +02:00
franzz d250181a15 Fix table constraints SQL generation 2019-09-01 19:04:52 +02:00
franzz a7b552fe4a Idiot-proof unstable spot api parsing (messages) 2019-09-01 00:17:21 +02:00
franzz e2e5df5414 Update feeds once an hour (clean commit) 2019-09-01 00:17:21 +02:00
franzz 6a9c563df1 Revert "Update feeds once an hour"
This reverts commit abad39eaa8.
2019-09-01 00:17:11 +02:00
franzz abad39eaa8 Update feeds once an hour 2019-08-13 15:57:49 +02:00
julien ea430cba72 Fix message parsing 2019-07-25 12:41:56 +02:00
franzz 28ff5c44d3 Use mapbox hybrid map 2019-07-13 11:34:04 +02:00
franzz 3bad9d4893 Fix message icon position 2019-07-06 23:08:36 +02:00
franzz 9d09171a73 Add drill down icon on message 2019-07-06 22:58:52 +02:00
franzz add272ade6 Fix auto-popup of last message 2019-07-05 19:42:10 +02:00
franzz 7e729a7fdb Update HRP track 2019-07-05 18:25:20 +02:00
franzz 0a955cd432 Fix drill icons on marker medias 2 2019-07-02 22:49:22 +02:00
franzz d0ecd047ca Fix drill icons on marker medias 2019-07-02 22:48:51 +02:00
franzz 95ad05de26 Fix images / videos on markers 2019-07-01 21:34:43 +02:00
franzz 7c0f3feed7 Add button icons on all drill downs 2019-07-01 21:22:37 +02:00
franzz 9c5dc5fa4d Play icon on video files 2019-07-01 19:52:25 +02:00
franzz e80ae81e42 Add media subtype & fix resizing 2019-06-30 22:21:39 +02:00
franzz 73f2e6c6e4 Video support 2019-06-30 21:46:41 +02:00
franzz e1aac63ae0 Allow upload of videos 2019-06-28 22:17:46 +02:00
franzz 9f7ada89f4 Update HRP track 2019-06-28 16:57:31 +02:00
franzz 022e41ac7e Adding readme 2019-06-28 11:44:26 +02:00
franzz 6d7f561544 Fix file upload feedback 2019-06-28 00:06:23 +02:00
franzz 4d46662544 Bump fileuploader to v9.32 2019-06-27 23:24:06 +02:00
franzz 961b1581d1 Fix no messages in feed warnings 2019-06-27 20:17:24 +02:00
franzz 55ede8bdeb Typo upload page title 2019-06-20 16:50:56 +02:00
franzz e431d42ec7 Update upload page interface slighly 2019-06-20 11:00:39 +02:00
franzz 932537d22d Fix typo on quit page function 2019-06-20 09:03:43 +02:00
franzz bac6817abd unset timer on page exit 2019-06-19 20:51:44 +02:00
franzz a8ea874125 set page title 2019-06-03 18:44:37 +02:00
franzz d3dc6901a6 Fix image orientation 2 (from markers) 2019-06-01 22:10:23 +02:00
franzz 297660b70c Remove scale on feeds display (mobile) 2019-06-01 16:24:53 +02:00
franzz 966601f2ff Leave scale on mobile 2019-06-01 15:43:15 +02:00
franzz 97712b5122 add swipe event catcher 2019-06-01 14:08:20 +02:00
franzz 09dfe7d24b avoid message duplicates 2019-05-31 21:53:12 +02:00
franzz 755aa9e0ec Add total track length / elevation 2019-05-15 22:56:38 +02:00
franzz 6a1faceb6f Add track step estimated duration 2019-05-11 18:51:53 +02:00
franzz eb82cc3c39 fix elevation chart icon 2019-05-11 16:31:29 +02:00
franzz 667a094f54 add distance & elevation on tracks 2019-05-11 01:12:24 +02:00
franzz 94ae30e1f3 fix picture orientation 2019-05-09 19:40:45 +02:00
franzz b83ea24c18 add lightbox unminified library 2019-05-09 19:33:16 +02:00
franzz 832b98e516 Revert "add lightbox unminified library"
This reverts commit e2e707595b.
2019-05-09 19:29:30 +02:00
franzz e2e707595b add lightbox unminified library 2019-05-09 19:27:04 +02:00
franzz 1cd022463e Update lightbox & set options 2019-05-03 23:34:10 +02:00
franzz cb5ca08891 fix pictures with no "taken on" info 2019-05-03 23:18:48 +02:00
franzz 226de87ff8 fix picture numbering 2019-05-03 22:51:09 +02:00
franzz 5511b28e47 open latest marker on mobile devices 2019-05-03 22:39:06 +02:00
franzz 5983cab494 On resize management 2019-04-09 22:34:56 +02:00
franzz d8a938af31 fix time on project active period 2019-04-01 18:31:35 +02:00
franzz 096c25a69e remove debug 2019-04-01 18:25:38 +02:00
franzz a9956a6a2e admin page v1 2019-03-31 22:07:21 +02:00
franzz e4a8cc7dbb index cleanup 2019-03-27 22:04:10 +01:00
franzz aafce4d207 update HRP track 2019-03-24 23:02:27 +01:00
franzz 8c50a5aea9 use new geo server for tiles 2019-03-24 00:05:14 +01:00
franzz 5063379f22 403 http request wrapping 2019-03-23 22:56:38 +01:00
franzz 693b929519 no need for Geo API keys anymore 2019-03-23 18:08:27 +01:00
franzz dc3eadc00a Push Cacher to Objects (WMTS Class) 2019-03-23 14:43:50 +01:00
franzz 3359467caa new OGP image 2019-03-15 20:14:20 +01:00
franzz 4b0f5714b3 OGP support 2019-03-15 19:42:16 +01:00
franzz a25dd83629 add prod server as settings param 2019-03-15 18:58:23 +01:00
franzz 5a147d7c47 fix offset on mobile devices 2019-03-11 22:07:38 +01:00
franzz 3938c5f4aa fix feed panel top margin 2019-03-11 18:57:45 +01:00
franzz 048bb6497f fix feed panel display on mobile 2019-03-11 13:18:15 +01:00
franzz 5bb13e54bd fix auto update timer on histo projects 2019-03-11 12:05:17 +01:00
franzz 3a9b14c772 fix elevation chart size 2019-03-10 19:27:29 +01:00
franzz 0717001a08 foldable feed panel 2019-03-10 15:50:58 +01:00
franzz 930cf74d34 backup v5 2019-03-07 20:51:13 +01:00
franzz 8a2d2a535d update hrp path 2019-03-07 20:37:06 +01:00
franzz 5574188e8f automatic geojson file generation 2019-03-07 20:36:23 +01:00
franzz 13dbdea2fb Update HRP geo file 2019-03-06 22:27:32 +01:00
franzz dedb9a18ae feed class 2019-02-28 22:29:09 +01:00
franzz 0a6ff9dffd add feed auto update 2019-02-25 21:20:40 +01:00
franzz e4f89008cf page split width variables 2019-02-25 20:07:30 +01:00
franzz 7a503c0888 fix IE11 bug (not supporting ES6) 2019-02-25 19:55:31 +01:00
franzz 81b9e061a8 sass color variables 2019-02-24 00:07:05 +01:00
franzz 851c743fa6 get rid of google fonts dependency 2019-02-23 21:04:13 +01:00
franzz f68a864b16 change favicon 2019-02-23 20:50:36 +01:00
franzz ba57642d24 new icon as png 2019-02-23 19:33:33 +01:00
franzz 6d34b8b415 new svg icon 2019-02-23 19:20:39 +01:00
franzz 0cf48c8dd0 on hover, display absolute time 2019-02-22 22:06:55 +01:00
franzz 9e9605fe2e reorganize leaflet plugin css 2019-02-21 21:33:21 +01:00
franzz eb6a94a490 fix map loading icon position 2019-02-21 20:31:03 +01:00
franzz 82ebfc48aa fix lightbox loading icon color 2019-02-21 19:20:18 +01:00
franzz 1ba6489802 replacing icons with font awesome v2 2019-02-20 23:07:26 +01:00
franzz 9bea0a318f replacing icons with font awesome 2019-02-20 23:06:43 +01:00
franzz 29ffa3098a fix message displayed ID 2019-02-20 22:27:29 +01:00
franzz 077c38fe58 update HRP track 2019-02-20 21:42:19 +01:00
franzz 299e4c8a54 Adding elevation chart 2019-02-14 22:59:32 +01:00
franzz 34a7cf6b08 updating GPX files 2019-02-14 22:56:55 +01:00
franzz 7f316624a7 ignoring geojson files 2019-02-14 22:56:16 +01:00
franzz 1faa71eaaa untrack geojson files 2019-02-14 22:54:46 +01:00
franzz c532573688 Split CSS by page 2019-02-13 21:01:15 +01:00
franzz 2ac8dead56 GPX to GeoJson converter 2019-02-12 20:00:59 +01:00
franzz 8a3c951b65 add scale 2019-02-10 18:51:44 +01:00
franzz 9499e2c7c9 hover on message (feed) triggers marker's popup 2019-02-07 19:35:03 +01:00
franzz 26c2057677 backup v4 2019-02-06 22:54:05 +01:00
franzz c88c027c24 files2 folder removed 2019-02-06 22:43:21 +01:00
franzz 9dc302091b Replace 'heure française' with actual site timezone 2019-02-06 22:43:06 +01:00
franzz 561a8ae215 fix wrong commit 2019-02-06 22:33:25 +01:00
franzz ffc5d81a3d add mapbox marker 2019-02-06 22:23:45 +01:00
franzz f4e787b9b7 fix marker position on static maps 2019-02-06 22:23:06 +01:00
franzz 19080d926a fix picture assignment to spot message 2019-02-06 21:08:53 +01:00
franzz ebe286362b fix SQL upgrade (v4) 2019-02-06 18:57:58 +01:00
franzz c9503bf3ef Harmonize timezones 2019-02-06 18:56:08 +01:00
franzz f1d8879624 update todo list 2019-02-03 21:01:14 +01:00
franzz 3a636b040b fix picture numbering 2019-02-03 19:59:43 +01:00
franzz 46b3bc1ccc update backup v3 2019-02-03 19:23:26 +01:00
franzz c4377d62d5 sort feed ascending on histo mode 2019-02-03 19:00:23 +01:00
franzz 3aa349b338 typo 2019-02-03 15:03:40 +01:00
franzz a940b7ec2d back up v3 2019-02-03 14:44:34 +01:00
franzz e328ad860c fix feed max width (images are 400px) 2019-02-03 14:44:20 +01:00
franzz 441dcae09a fix picture width 2019-02-03 14:26:45 +01:00
franzz efbb31c1d0 extra info on picture selection 2019-02-03 12:32:42 +01:00
franzz 03edff8baa pictures table 2019-02-03 01:26:56 +01:00
franzz 8eb110879d Add DMS value on Spot Messages 2019-01-30 22:39:28 +01:00
franzz 999094b23b translate relative time to french 2019-01-27 23:10:34 +01:00
franzz 15ca2841d3 replace leaflet images with font awesome icons 2019-01-27 21:15:33 +01:00
franzz 28eaffe247 update backup 2019-01-27 21:15:03 +01:00
franzz cc5491cd5f Pushed static maps retrieval to backend 2019-01-27 20:11:09 +01:00
franzz 2dc35bbc12 set a first_exec var for project detection 2019-01-27 17:15:32 +01:00
franzz 2a9686429f fix first project selection 2019-01-27 16:56:46 +01:00
franzz a00d9a7a51 Fix poster display 2019-01-27 16:47:41 +01:00
franzz 54426daca6 Add project radio buttons 2019-01-27 16:42:15 +01:00
franzz 62b94624f0 Move legend to left 2019-01-27 13:02:49 +01:00
franzz 3149a80fa5 Hover opening popup on tracks 2019-01-27 00:28:24 +01:00
franzz 0ea31c8ff2 Add track icons 2019-01-26 20:33:10 +01:00
franzz 571eb0d241 Upgrade Font Awesome to 5.6.3 2019-01-26 18:52:13 +01:00
franzz e138f2ca41 add timestamp on all css/js files 2019-01-26 18:05:17 +01:00
franzz 85bd290d22 SQL backup v2 2019-01-26 17:54:55 +01:00
franzz e40e57cf7f fix first execution of project finder 2019-01-26 12:17:11 +01:00
franzz 591a2c5bad Spot v2: Project Management 2019-01-26 10:13:06 +01:00
franzz b92c6fb5ed fix zoom offset 2018-10-21 17:25:39 +02:00
franzz d943094d87 remove offset centering (causing infinite loops) 2018-10-21 12:32:13 +02:00
franzz 9443a83501 replace marker tooltip with popup + update jquery 2018-10-17 20:54:14 +02:00
franzz 4a570e3b9d replace tracks tooltip with popup 2018-10-17 20:01:04 +02:00
franzz a824912683 add LINZ API key to settings 2018-10-17 18:26:49 +02:00
franzz c0954c32d9 add cache folder 2018-10-17 18:23:04 +02:00
franzz 05638e8762 add tile api 2018-10-17 18:05:10 +02:00
franzz 40a242ccb2 add 30% x-offset on zoom 2018-10-10 22:48:27 +02:00
franzz 36a3bfc726 adding track names 2018-10-09 20:33:12 +02:00
franzz 82be0d5dc7 swap open topo map with LINZ 2018-10-08 22:12:57 +02:00
franzz fd07402940 add Open Topo Map 2018-10-07 22:43:05 +02:00
franzz 2c146e6236 convert to leaflet 2018-10-07 11:46:50 +02:00
francois 00cb4790b6 add pictures to messages 2018-04-22 16:21:31 +02:00
francois 19b3d8373f simplify minified CSS 2018-04-19 22:08:23 +02:00
francois 59a9e5b690 update fileUploader to 9.21.0 2018-04-19 21:03:46 +02:00
francois 5a0734a357 upgrade lightbox + add thumbnails 2018-04-18 22:54:00 +02:00
francois 8ff7185ff3 add auto post loader 2018-04-15 14:59:39 +02:00
francois 32108b0141 code cleaning 2018-04-15 13:14:37 +02:00
francois 8dcfb4822b adding customer sidebar + posts over map + add validity interval (histo) 2018-04-14 16:16:25 +02:00
francois 73fa987490 remove dependence on gmap3 2018-04-12 22:08:51 +02:00
francois fd9b4d787f update settings sample 2018-04-11 20:37:32 +02:00
francois 4a9dfa22e2 add message minimaps 2018-04-11 20:26:33 +02:00
franzz 0912e76ec4 remove message form on histo mode 2016-09-24 23:47:03 +02:00
franzz 52e39832c2 update link + remove extra photo 2016-06-30 20:12:12 +02:00
franzz 20a7b1f49f adding hitchhike to Te Anau 2016-06-30 20:04:04 +02:00
franzz d6a8172e8d changing map center 2016-06-29 21:07:26 +02:00
franzz a9329012f7 force kmz refresh 2016-06-28 00:20:39 +02:00
franzz 945b0332d0 adding water taxi to ship cove 2016-06-28 00:19:47 +02:00
franzz 273561ce72 test kmz 2016-06-27 23:47:22 +02:00
franzz 2424a7bed1 adding hitchhike to Methven 2016-06-27 23:40:50 +02:00
franzz 1895b5545b changing legend position 2016-06-27 21:44:48 +02:00
franzz e7bf63c4df fixing kml URL 2016-06-27 21:26:19 +02:00
franzz f1691b44a2 Adding Queenstown bus ride + map legend 2016-06-27 21:25:27 +02:00
franzz 6ce4e440cd adding greenstone track 2016-06-23 23:02:33 +02:00
franzz d74cbccd01 sort messages by timestamp 2016-06-23 21:11:24 +02:00
franzz e4dbed2716 upgrade to gmap3 6.1.0 2016-06-23 20:48:01 +02:00
franzz 9a56c27df5 upgrade to jQuery 3.0.0 and google API key added 2016-06-23 20:43:43 +02:00
franzz e6de9bc72a adding settings sample file 2016-06-22 19:43:31 +02:00
franzz b7c12f981d remove settings file from repo 2016-06-22 19:36:34 +02:00
franzz 8a78de3ad8 fix message box (again) 2016-06-22 19:28:57 +02:00
franzz 6034cd762c fix message box width 2016-06-19 20:21:01 +02:00
franzz 2b8caa71a4 fixing poster panel 2016-06-13 23:35:30 +02:00
franzz 02c9ea3249 adding news feed chunks 2016-06-13 22:17:46 +02:00
franzz e5d64168d5 Post trek update: recentering map + style minor changes 2016-06-09 23:11:10 +02:00
franzz 3b953e1237 removing lightbox quick fix 2016-03-26 14:15:54 +01:00
franzz bf4a4e32fe fixing lightbox 2016-03-26 14:13:33 +01:00
franzz cc63ee51a7 prefix fo lightbox 2016-03-26 11:43:30 +01:00
franzz 6235825509 adding Rangitata crossing and removing Wakatipu crossing 2016-03-20 22:30:45 +01:00
franzz 72b41124d8 remove post number 2016-03-15 21:13:50 +01:00
franzz 3ea624de09 fix feed sorting and image numbering 2016-03-15 20:57:26 +01:00
franzz 07c3d81779 remove test image 2016-03-09 17:54:02 +01:00
franzz 6e7c45ef36 adding routeburn 2016-03-06 17:12:54 +01:00
franzz b51b5c2792 auto refresh 2015-12-27 23:00:33 +01:00
franzz 672872e9a1 init pictures folder 2015-12-27 13:19:16 +01:00
franzz 0702e5afeb comments + images 2015-12-27 13:08:01 +01:00
franzz 4fdbfd57bb images for lightbox 2015-12-27 13:06:23 +01:00
franzz c3af807b51 no htaccess 2015-12-27 13:06:02 +01:00
franzz af10c675d3 accept only images (front end) 2015-12-25 21:32:24 +01:00
franzz 59e07a65ff Accept only images 2015-12-25 21:30:14 +01:00
franzz 1fb2d72732 upload fixes 2015-12-25 21:07:55 +01:00
franzz e56e77a37e replacing 32x32 favicon 2015-12-25 20:22:54 +01:00
franzz 25405b27f1 replacing png favicon 2015-12-25 20:18:46 +01:00
franzz 853fed071e new foot print icons + favicon 2015-12-25 20:16:05 +01:00
franzz 7246e877af closer zoom level + fixing loader position 2015-12-20 12:32:57 +01:00
franzz ee21a4f592 add footprint alternative image 2015-12-18 11:46:04 +01:00
franzz b0ff0b9a53 alt foot print 2015-12-18 11:45:47 +01:00
franzz c3bad2d742 remove force refresh 2015-12-18 11:35:48 +01:00
franzz 4a250a1a68 force update 2015-12-18 11:35:15 +01:00
franzz 5d47c10aa1 init uploader (v0.1) + go live spot feed 2015-12-18 11:33:53 +01:00
franzz 3cc93afaf1 uploader v0.1 2015-12-16 22:52:17 +01:00
franzz 63478c37da fix infowindow focus/blur event 2015-12-16 20:44:34 +01:00
franzz f11bd6a96a remove centering on KML layer 2015-12-16 20:40:04 +01:00
franzz e3b8e3048c removing recenter 2015-12-11 19:35:16 +01:00
franzz bb771f6a8c recenter kml 2015-12-11 00:28:57 +01:00
franzz 0f097fdf63 adding kayak section in Queenstown 2015-12-11 00:24:52 +01:00
franzz dcac0bfe98 removing north island 2015-12-11 00:14:57 +01:00
franzz 369f6dab5d removing colors 2015-12-11 00:12:57 +01:00
franzz ddfd1eaf21 all green 2015-12-10 23:51:10 +01:00
franzz 9c89e5e237 missed before, adding kml file (simplified) 2015-12-10 23:35:26 +01:00
franzz 59faae245d simplifying kml file 2015-12-10 23:34:08 +01:00
franzz ec550d0a15 testing kml 2015-12-10 23:26:09 +01:00
franzz 5d3d2d54ee gitignore 2015-12-10 23:23:32 +01:00
franzz 42d891b952 switching to gmap3 2015-12-10 23:12:50 +01:00
franzz 03217058e8 v0.1 with spot app 2015-12-01 23:54:46 +01:00
franzz 0363f4f272 repo init 2015-12-01 23:22:23 +01:00
218 changed files with 191683 additions and 536736 deletions
-60
View File
@@ -1,60 +0,0 @@
name: Deploy Livetrail
on:
push:
branches:
- master
jobs:
deploy:
runs-on: livetrail
env:
COMPOSER_NO_INTERACTION: "1"
COMPOSER_HOME: .composer
DEPLOY_PATH: /var/www/livetrail
npm_config_cache: .npm-cache
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check runner tools
run: |
command -v composer
command -v npm
command -v rsync
- name: Check deploy path
run: |
test -d "$DEPLOY_PATH"
test -w "$DEPLOY_PATH"
- name: Validate Composer configuration
run: composer validate --no-check-publish
- name: Install PHP dependencies
run: composer install --no-dev --prefer-dist --optimize-autoloader
- name: Install npm dependencies
run: npm ci
- name: Prepare runtime missing mount points
run: mkdir -p files resources/geo
- name: Build frontend
run: npm run prod
- name: Deploy to production
run: |
rsync -azc --no-times --delete \
--exclude "/.git/" \
--exclude "/.gitea/" \
--exclude "/.composer/" \
--exclude "/.npm-cache/" \
--exclude "/node_modules/" \
--exclude "/config/settings.php" \
--exclude "/log.html" \
--exclude "/files/" \
--exclude "/resources/geo/*.geojson" \
./ "$DEPLOY_PATH/"
+18 -19
View File
@@ -1,19 +1,18 @@
# App config files /build.xml
/config/settings.php /settings.php
/log.html /.externalToolBuilders/
/.sass-cache/
# Upload folders /.buildpath
/files/ /.project
/resources/geo/*.geojson /style/.sass-cache/
/.settings/
# Build folder /files/**/*.jpg
/public/* /files/**/*.JPG
!/public/index.php /files/**/*.jpeg
/files/**/*.JPEG
# Dependencies files /files/**/*.png
/vendor/ /files/**/*.PNG
/node_modules/ /files/**/*.mov
/composer.dev.lock /files/**/*.MOV
/geo/*.geojson
# Lint caches /spot_cron.sh
/.php-cs-fixer.cache
-53
View File
@@ -1,53 +0,0 @@
<?php
/*
* PHP-CS-Fixer config codifying the style already in use under lib/ and
* public/. Deliberately NOT based on @PSR2/@PSR12 - this codebase diverges
* from those on purpose (tabs, no space before control-structure parens),
* so pulling in a preset would rewrite most of the codebase to match a
* style nobody uses here. Rules below were derived by sampling lib/*.php,
* not from a style guide.
*/
$finder = (new PhpCsFixer\Finder())
->in([__DIR__.'/lib', __DIR__.'/public'])
->append([__DIR__.'/config/settings-sample.php'])
->name('*.php');
return (new PhpCsFixer\Config())
->setIndent("\t")
->setLineEnding("\n")
->setRules([
//Strings: single quotes except where interpolation needs double
'single_quote' => true,
//Arrays: short [] syntax, not long-form array()
'array_syntax' => ['syntax' => 'short'],
//Braces: same line as class/function signature (dominant in lib/,
//not the PSR-2 next-line-for-class convention)
'braces_position' => [
'classes_opening_brace' => 'same_line',
'functions_opening_brace' => 'same_line',
'anonymous_functions_opening_brace' => 'same_line'
],
//Concatenation: no padding around `.`
'concat_space' => ['spacing' => 'none'],
//true/false/null: lowercase (100% consistent already)
'constant_case' => ['case' => 'lower'],
'lowercase_keywords' => true,
'visibility_required' => ['elements' => ['method', 'property', 'const']],
//Housekeeping - safe regardless of style
'no_unused_imports' => true,
'no_trailing_whitespace' => true,
'no_trailing_whitespace_in_comment' => true,
'single_blank_line_at_eof' => true,
'no_empty_statement' => true,
'trim_array_spaces' => true,
'new_with_parentheses' => true
])
->setFinder($finder);
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
cd "$(dirname "$0")/../public" || exit 1 #Execute from public folder
php -f index.php a=update_project > /dev/null
#Crontab job: 0 * * * * /path/to/livetrail/cli/cron.sh > /dev/null
-36
View File
@@ -1,36 +0,0 @@
{
"name": "franzz/livetrail",
"description": "LiveTrail",
"type": "project",
"license": "GPL-3.0-or-later",
"repositories": [
{
"type": "path",
"url": "../objects",
"options": {
"symlink": true
}
}
],
"require": {
"php": ">=8.5",
"franzz/objects": "dev-vue",
"phpmailer/phpmailer": "^7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.75"
},
"autoload": {
"psr-4": {
"Franzz\\Livetrail\\": "lib/",
"Franzz\\Objects\\": "../objects/inc/"
},
"files": [
"config/settings.php"
]
},
"scripts": {
"lint": "php-cs-fixer fix --dry-run --diff",
"lint-fix": "php-cs-fixer fix"
}
}
-32
View File
@@ -1,32 +0,0 @@
{
"name": "franzz/livetrail",
"description": "LiveTrail",
"type": "project",
"license": "GPL-3.0-or-later",
"repositories": [
{
"type": "git",
"url": "https://git.lutran.fr/franzz/objects"
}
],
"require": {
"php": ">=8.5",
"franzz/objects": "dev-vue",
"phpmailer/phpmailer": "^7.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.75"
},
"autoload": {
"psr-4": {
"Franzz\\Livetrail\\": "lib/"
},
"files": [
"config/settings.php"
]
},
"scripts": {
"lint": "php-cs-fixer fix --dry-run --diff",
"lint-fix": "php-cs-fixer fix"
}
}
Generated
-2959
View File
File diff suppressed because it is too large Load Diff
-70
View File
@@ -1,70 +0,0 @@
<VirtualHost *:80>
ServerName localhost
ServerAlias maui.local
DocumentRoot /var/www/html/
RewriteEngine on
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName localhost
ServerAlias maui.local
DocumentRoot /var/www/html/
SSLCertificateFile /etc/letsencrypt/live/mushu.nest.lutran.ch/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/mushu.nest.lutran.ch/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
DirectoryIndex index.php
# Serve https://localhost/livetrail/ from the public web root.
Alias /livetrail /var/www/html/livetrail/public
<Directory /var/www/html/livetrail/public>
Options FollowSymLinks
AllowOverride None
Require all granted
AddType application/geo+json .geojson
<IfModule mod_headers.c>
<FilesMatch "\.geojson$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS \
text/html \
text/plain \
text/css \
text/javascript \
text/xml \
application/javascript \
application/json \
application/geo+json \
application/manifest+json \
application/xml \
image/svg+xml
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE \
text/html \
text/plain \
text/css \
text/javascript \
text/xml \
application/javascript \
application/json \
application/geo+json \
application/manifest+json \
application/xml \
image/svg+xml
</IfModule>
</Directory>
</VirtualHost>
</IfModule>
-19
View File
@@ -1,19 +0,0 @@
<?php
class Settings {
public const DB_SERVER = 'localhost';
public const DB_LOGIN = '';
public const DB_PASS = '';
public const DB_NAME = 'livetrail';
public const DB_ENC = 'utf8mb4';
public const TEXT_ENC = 'UTF-8';
public const TIMEZONE = 'Europe/Zurich';
public const MAIL_SERVER = '';
public const MAIL_FROM = '';
public const MAIL_USER = '';
public const MAIL_PASS = '';
public const WEATHER_TOKEN = ''; //visualcrossing.com
public const TIMEZONE_USER = ''; //geonames.org
public const DEBUG = true;
public const LOG_FOLDER = __DIR__;
}
-94
View File
@@ -1,94 +0,0 @@
// ESLint flat config codifying the style already in use across src/.
// Goal: catch real mistakes and enforce the existing conventions, not impose
// an external style guide. Rules were derived by sampling src/scripts/*.js
// and src/components/*.vue, not from a template.
import js from '@eslint/js';
import vue from 'eslint-plugin-vue';
import globals from 'globals';
export default [
js.configs.recommended,
...vue.configs['flat/essential'],
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser
}
},
rules: {
//Indentation: tabs everywhere, one indent per level
'indent': 'off', //too many false positives on Vue template attr wrapping; keep manual
'no-tabs': 'off',
//Strings: single quotes (project uses single-quoted strings exclusively)
'quotes': ['warn', 'single', {avoidEscape: true, allowTemplateLiterals: true}],
//Semicolons: required almost everywhere. The one consistent
//exception is the top-level `export default {...}` in Vue SFCs,
//which never gets a trailing semicolon - ESLint's `semi` rule
//can't carve that one spot out, so this is 'warn' rather than
//'error' to avoid flagging every component's SFC boilerplate
//as a hard failure.
'semi': ['warn', 'always'],
//Equality: codebase mixes == and === deliberately (e.g. loose checks
//against numeric strings from the API) - don't force either
'eqeqeq': 'off',
//Function parens: no space before the parameter list - `function(x)`, not `function (x)`
'space-before-function-paren': ['warn', 'never'],
//Control structures: no space before the parenthesis -
//`if(x)`, `for(...)`, `switch(x)`, not `if (x)` etc.
//(100% consistent across src/ - checked via grep before writing this)
'keyword-spacing': ['warn', {
after: true,
overrides: {
if: {after: false},
for: {after: false},
while: {after: false},
switch: {after: false},
catch: {after: false}
}
}],
'space-before-blocks': ['warn', 'always'],
//Object-curly-spacing is deliberately NOT configured: the codebase
//consistently uses `{a, b}` (no padding) for object literals and
//destructuring, but `import { x } from 'y'` (padded) for imports -
//and this rule can't apply different spacing to those two node
//kinds, so enforcing either would misfire on the other.
'object-curly-spacing': 'off',
'array-bracket-spacing': ['warn', 'never'],
//Prefix convention (Hungarian-ish: sString, iInt, aArray, oObject, bBool)
//is a project-wide naming discipline, not something ESLint can check;
//left undocumented here on purpose.
//Real-bug catchers - keep these strict regardless of style
//ignoreRestSiblings covers the `const {prev, ...rest} = obj` idiom
//(destructuring a key out just to exclude it from the rest), used
//in App.vue - that `prev` binding is intentionally unused.
'no-unused-vars': ['warn', {argsIgnorePattern: '^_', ignoreRestSiblings: true}],
'no-undef': 'error',
'no-var': 'warn', //codebase is ES module/class based; var only appears in one legacy fallback block
'prefer-const': 'off', //not consistently followed, don't force churn
//Vue-specific: components are registered and used with camelCase
//tags in templates (<appIcon>, <projectMapLink>). ESLint's
//component-name-in-template-casing rule only supports PascalCase
//or kebab-case, neither of which matches, so it's left off here
//rather than forced into a casing the codebase doesn't use.
'vue/component-name-in-template-casing': 'off',
'vue/multi-word-component-names': 'off', //AppIcon, Admin, Project etc. mix single/multi-word by design
'vue/attribute-hyphenation': 'off', //existing templates mix camelCase and kebab-case attrs; not consistent enough to enforce yet
'vue/require-default-prop': 'off',
'vue/no-v-html': 'error' //codebase currently has zero v-html usage - keep it that way
}
},
{
ignores: ['public/**', 'vendor/**', 'node_modules/**']
}
];
+38
View File
@@ -0,0 +1,38 @@
CREATE TABLE `maps` (
`id_map` int(10) UNSIGNED auto_increment,
`codename` VARCHAR(100),
`geo_name` VARCHAR(100),
`min_zoom` TINYINT UNSIGNED,
`max_zoom` TINYINT UNSIGNED,
`attribution` VARCHAR(100),
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_map`));
CREATE TABLE `mappings` (
`id_mapping` int(10) UNSIGNED auto_increment,
`id_map` int(10) UNSIGNED,
`id_project` int(10) UNSIGNED,
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_mapping`));
ALTER TABLE mappings ADD INDEX(`id_map`);
ALTER TABLE mappings ADD FOREIGN KEY (`id_map`) REFERENCES maps(`id_map`);
ALTER TABLE mappings ADD INDEX(`id_project`);
ALTER TABLE mappings ADD FOREIGN KEY (`id_project`) REFERENCES projects(`id_project`);
INSERT INTO maps(codename, geo_name, min_zoom, max_zoom, attribution) VALUES
/*1*/('satellite', 'mapbox.satellite-streets', 0, 19, ''),
/*2*/('otm', 'opentopomap', 2, 19, ''),
/*3*/('ign_france', 'ign.fr', 2, 19, ''),
/*4*/('ign_spain', 'ign.es', 1, 20, ''),
/*5*/('linz', 'linz', 0, 17, 'Sourced from LINZ. CC BY 4.0'),
/*6*/('usgs', 'usgs', 1, 16, ''),
/*7*/('natgeo', 'natgeo.pct', 5, 14, '');
INSERT INTO mappings(id_map, id_project) VALUES
(1, NULL),
(2, NULL),
(3, 2),
(4, 2),
(5, 1),
(6, 3);
+25
View File
@@ -0,0 +1,25 @@
CREATE TABLE `projects` (
`id_project` int(10) UNSIGNED auto_increment,
`codename` VARCHAR(100),
`name` VARCHAR(100),
`active_from` DATETIME,
`active_to` DATETIME,
`geofile` VARCHAR(50),
`timezone` VARCHAR(100),
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_project`));
INSERT INTO projects (name, codename, active_from, active_to, geofile, timezone) VALUES ('Te Araroa', 'te_araroa', '2015-12-29 00:00:00', '2016-03-05 23:59:59', 'te_araroa.geojson', 'Pacific/Auckland');
INSERT INTO projects (name, codename, active_from, active_to, geofile, timezone) VALUES ('HRP', 'hrp', '2019-06-01 00:00:00', '2019-09-10 23:59:59', 'hrp.geojson', 'Europe/Paris');
ALTER TABLE feeds ADD COLUMN id_project int(10) UNSIGNED AFTER id_spot;
ALTER TABLE feeds ADD last_update DATETIME AFTER status;
ALTER TABLE feeds ADD FOREIGN KEY (`id_project`) REFERENCES projects(`id_project`);
UPDATE feeds SET last_update = led;
UPDATE feeds SET id_project = 1;
ALTER TABLE posts ADD COLUMN id_project int(10) UNSIGNED AFTER id_post;
ALTER TABLE posts ADD timestamp DATETIME AFTER content;
ALTER TABLE posts ADD FOREIGN KEY (`id_project`) REFERENCES projects(`id_project`);
UPDATE posts SET timestamp = led;
UPDATE posts SET id_project = 1;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `pictures` (
`id_picture` int(10) UNSIGNED auto_increment,
`id_project` int(10) UNSIGNED,
`filename` VARCHAR(100) NOT NULL,
`taken_on` DATETIME,
`timestamp` DATETIME,
`rotate` SMALLINT,
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_picture`),
UNIQUE KEY `uni_file_name` (`filename`)
);
ALTER TABLE pictures ADD INDEX(`id_project`);
ALTER TABLE pictures ADD FOREIGN KEY (`id_project`) REFERENCES projects(`id_project`);
+15
View File
@@ -0,0 +1,15 @@
ALTER TABLE messages ADD iso_time VARCHAR(24) AFTER longitude;
ALTER TABLE messages CHANGE COLUMN timestamp site_time TIMESTAMP DEFAULT 0;
ALTER TABLE messages CHANGE COLUMN unix_timestamp unix_time INT;
UPDATE messages SET iso_time = CONCAT(REPLACE(CONVERT_TZ(FROM_UNIXTIME(unix_time), @@session.time_zone, 'Pacific/Auckland'), ' ', 'T'), '+1200') WHERE id_feed = 1;
ALTER TABLE feeds MODIFY last_update TIMESTAMP DEFAULT 0;
ALTER TABLE projects MODIFY active_from TIMESTAMP DEFAULT 0;
ALTER TABLE projects MODIFY active_to TIMESTAMP DEFAULT 0;
ALTER TABLE posts CHANGE COLUMN timestamp site_time TIMESTAMP DEFAULT 0;
ALTER TABLE pictures CHANGE COLUMN timestamp posted_on TIMESTAMP DEFAULT 0;
UPDATE pictures INNER JOIN projects USING(id_project) SET taken_on = CONVERT_TZ(taken_on, projects.timezone, @@session.time_zone) where id_project = 1;
ALTER TABLE pictures MODIFY taken_on TIMESTAMP DEFAULT 0;
+1
View File
@@ -0,0 +1 @@
UPDATE projects SET geofile = REPLACE(geofile, '.geojson', '');
+4
View File
@@ -0,0 +1,4 @@
RENAME TABLE pictures TO medias;
ALTER TABLE medias CHANGE COLUMN id_picture id_media INT(10) UNSIGNED NOT NULL auto_increment;
ALTER TABLE medias ADD COLUMN type VARCHAR(20) AFTER filename;
UPDATE medias SET type = 'image';
+2
View File
@@ -0,0 +1,2 @@
/* Remove NO_ZERO_DATE mode, checks mode with: SELECT @@SQL_MODE, @@GLOBAL.SQL_MODE; and: SET @@SQL_MODE = REPLACE(@@SQL_MODE, 'NO_ZERO_DATE', ''); */
ALTER TABLE medias ADD comment LONGTEXT AFTER rotate;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE projects DROP COLUMN geofile;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `users` (
`id_user` int(10) UNSIGNED auto_increment,
`name` VARCHAR(100),
`email` VARCHAR(320),
`language` VARCHAR(2),
`active` BOOLEAN,
`led` TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id_user`),
UNIQUE KEY `uni_email` (`email`)
);
ALTER TABLE posts ADD COLUMN id_user int(10) UNSIGNED AFTER id_project;
ALTER TABLE posts ADD INDEX(`id_user`);
ALTER TABLE posts ADD FOREIGN KEY (`id_user`) REFERENCES users(`id_user`);
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE users ADD COLUMN timezone char(64) AFTER language;
ALTER TABLE users MODIFY COLUMN email VARCHAR(320) NOT NULL;
UPDATE users SET timezone = 'Europe/Paris';
ALTER TABLE projects MODIFY COLUMN timezone char(64);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/images/icons/mstile-150x150.png?v=GvmqYyKwbb"/>
<TileColor>#00a300</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5041 5784 c366 -384 572 -817 630 -1323 17 -156 7 -495 -20 -641
-78 -415 -250 -717 -1091 -1915 -108 -154 -338 -482 -510 -730 -172 -247 -328
-469 -346 -492 -21 -25 -56 -52 -91 -68 -48 -23 -66 -27 -127 -23 -46 2 -86
11 -113 24 l-42 21 36 -44 c47 -58 122 -116 191 -150 50 -25 68 -28 152 -28
88 0 100 2 163 34 80 40 167 121 254 236 102 137 967 1375 1158 1660 429 639
578 959 636 1365 20 140 17 485 -5 630 -37 237 -101 440 -205 655 -162 331
-404 620 -696 830 -33 24 -28 16 26 -41z"/>
<path d="M2750 5673 c-63 -22 -143 -75 -197 -131 -192 -196 -318 -616 -298
-990 9 -174 36 -314 118 -607 l34 -120 316 -3 317 -2 0 62 c1 81 22 199 49
265 11 29 42 87 69 130 143 230 186 366 185 588 -1 420 -140 736 -356 809 -60
20 -177 20 -237 -1z"/>
<path d="M4017 5056 c-174 -62 -301 -280 -348 -591 -19 -129 -17 -337 4 -425
32 -136 85 -257 159 -368 81 -119 127 -268 128 -410 l0 -62 317 2 316 3 34
120 c106 374 136 590 115 816 -22 225 -66 391 -153 566 -48 97 -74 138 -131
198 -39 42 -90 87 -115 102 -99 58 -241 79 -326 49z"/>
<path d="M2412 3548 c3 -109 7 -129 30 -173 120 -232 445 -231 559 0 28 58 32
76 37 180 l5 115 -318 0 -317 0 4 -122z"/>
<path d="M3960 2962 c0 -234 106 -368 300 -380 98 -5 168 20 233 83 75 75 91
119 95 263 l4 122 -316 0 -316 0 0 -88z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "Spot",
"short_name": "Spot",
"icons": [
{
"src": "/images/icons/android-chrome-192x192.png?v=GvmqYyKwbb",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/icons/android-chrome-512x512.png?v=GvmqYyKwbb",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

@@ -1,23 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg <svg
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 32 64" viewBox="0 0 32 64"
version="1.1" version="1.1"
id="svg4" id="svg4"
sodipodi:docname="footprint_mapbox.svg" sodipodi:docname="footprint_mapbox.svg"
width="32" width="32"
height="64" height="64"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)" inkscape:version="0.92.2 (5c3e80d, 2017-08-06)"
inkscape:export-filename="C:\Users\francois\Downloads\footprint_mapbox.png"
inkscape:export-xdpi="96" inkscape:export-xdpi="96"
inkscape:export-ydpi="96" inkscape:export-ydpi="96">
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title1">Logo Marker</title>
<metadata <metadata
id="metadata10"> id="metadata10">
<rdf:RDF> <rdf:RDF>
@@ -26,35 +26,15 @@
<dc:format>image/svg+xml</dc:format> <dc:format>image/svg+xml</dc:format>
<dc:type <dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<cc:license <dc:title></dc:title>
rdf:resource="http://creativecommons.org/licenses/by/4.0/" />
<dc:title>Logo Marker</dc:title>
<dc:creator>
<cc:Agent>
<dc:title>Franzz</dc:title>
</cc:Agent>
</dc:creator>
</cc:Work> </cc:Work>
<cc:License
rdf:about="http://creativecommons.org/licenses/by/4.0/">
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
</cc:License>
</rdf:RDF> </rdf:RDF>
</metadata> </metadata>
<defs <defs
id="defs8"> id="defs8">
<linearGradient <linearGradient
id="linearGradient4520" id="linearGradient4520"
inkscape:swatch="solid"> osb:paint="solid">
<stop <stop
style="stop-color:#6dff58;stop-opacity:1;" style="stop-color:#6dff58;stop-opacity:1;"
offset="0" offset="0"
@@ -95,11 +75,7 @@
<filter <filter
style="color-interpolation-filters:sRGB;" style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow" inkscape:label="Drop Shadow"
id="filter5223" id="filter5223">
x="-0.12000002"
y="-0.090000001"
width="1.2900001"
height="1.2175">
<feFlood <feFlood
flood-opacity="0.498039" flood-opacity="0.498039"
flood-color="rgb(0,0,0)" flood-color="rgb(0,0,0)"
@@ -138,27 +114,24 @@
guidetolerance="10" guidetolerance="10"
inkscape:pageopacity="0" inkscape:pageopacity="0"
inkscape:pageshadow="2" inkscape:pageshadow="2"
inkscape:window-width="3840" inkscape:window-width="1920"
inkscape:window-height="2054" inkscape:window-height="1017"
id="namedview6" id="namedview6"
showgrid="false" showgrid="false"
inkscape:snap-grids="true" inkscape:snap-grids="true"
inkscape:zoom="7.375" inkscape:zoom="7.375"
inkscape:cx="2.0338983" inkscape:cx="2.1125062"
inkscape:cy="47.389831" inkscape:cy="47.406997"
inkscape:window-x="2549" inkscape:window-x="-8"
inkscape:window-y="-11" inkscape:window-y="-8"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="svg4" inkscape:current-layer="svg4"
units="px" units="px" />
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1" />
<path <path
id="path4698" id="path4698"
d="M 14.766749,31.35437 C 5.6856259,18.189433 4.0000018,16.83831 4.0000018,11.999996 4.0000018,5.37256 9.3725628,0 15.999999,0 c 6.627437,0 11.999999,5.37256 11.999999,11.999996 0,4.838314 -1.685625,6.189437 -10.766749,19.354374 -0.595937,0.860873 -1.870625,0.860809 -2.4665,0 z" d="M 14.766749,31.354369 C 5.685625,18.189432 4,16.838309 4,11.999995 4,5.3725591 9.372562,1.6977696e-7 15.999999,1.6977696e-7 22.627436,1.6977696e-7 27.999997,5.3725591 27.999997,11.999995 c 0,4.838314 -1.685625,6.189437 -10.766748,19.354374 -0.595937,0.860873 -1.870625,0.860809 -2.4665,0 z"
inkscape:connector-curvature="0" inkscape:connector-curvature="0"
style="fill:#6dff58;fill-opacity:1;stroke:none;stroke-width:0.0625;stroke-opacity:1;filter:url(#filter5223)" style="fill:#6dff58;fill-opacity:1;stroke:none;stroke-width:0.06249999;stroke-opacity:1;filter:url(#filter5223)"
inkscape:label="marker" /> inkscape:label="marker" />
<path <path
id="shoes" id="shoes"

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.
+142
View File
@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 512 512"
version="1.1"
id="svg4"
sodipodi:docname="spot.svg"
width="512"
height="512"
inkscape:version="0.92.2 (5c3e80d, 2017-08-06)"
inkscape:export-filename="C:\Users\francois\Downloads\footprint_mapbox.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8">
<linearGradient
id="linearGradient4520"
osb:paint="solid">
<stop
style="stop-color:#6dff58;stop-opacity:1;"
offset="0"
id="stop4518" />
</linearGradient>
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter4696">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4686" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4688" />
<feGaussianBlur
in="composite1"
stdDeviation="3"
result="blur"
id="feGaussianBlur4690" />
<feOffset
dx="3"
dy="3"
result="offset"
id="feOffset4692" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4694" />
</filter>
<filter
style="color-interpolation-filters:sRGB"
inkscape:label="Drop Shadow"
id="filter5223">
<feFlood
flood-opacity="0.498039"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood5213" />
<feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite5215" />
<feGaussianBlur
in="composite1"
stdDeviation="1.2"
result="blur"
id="feGaussianBlur5217" />
<feOffset
dx="1.2"
dy="1.2"
result="offset"
id="feOffset5219" />
<feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite5221" />
</filter>
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview6"
showgrid="false"
inkscape:snap-grids="true"
inkscape:zoom="1.3037281"
inkscape:cx="248.69883"
inkscape:cy="256.06919"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg4"
units="px" />
<path
id="path4698"
d="M 14.766749,479.35437 C 5.685625,466.18943 4,464.83831 4,460 c 0,-6.62744 5.372562,-12 11.999999,-12 6.627437,0 11.999998,5.37256 11.999998,12 0,4.83831 -1.685625,6.18943 -10.766748,19.35437 -0.595937,0.86087 -1.870625,0.86081 -2.4665,0 z"
inkscape:connector-curvature="0"
style="fill:#6dff58;fill-opacity:1;stroke:none;stroke-width:0.06249999;stroke-opacity:1;filter:url(#filter5223)"
inkscape:label="marker"
transform="matrix(13.333333,0,0,13.333333,42.666662,-5930.6664)" />
<path
id="shoes"
d="m 221.87402,255.01429 v -11.375 h -45.49995 v 11.375 c 0,12.56578 10.18419,22.74983 22.74997,22.74983 12.56579,0 22.74998,-10.18405 22.74998,-22.74983 z m 90.99975,68.24979 c 12.56579,0 22.74982,-10.18419 22.74982,-22.74998 v -11.37499 h -45.4998 v 11.37499 c 0,12.56579 10.18419,22.74998 22.74998,22.74998 z M 267.37383,203.30789 c 0,12.40941 4.66373,27.07248 11.37498,37.22819 5.82257,8.81203 11.37498,15.8254 11.37498,37.22804 h 45.4998 l 5.67685,-20.45001 c 2.58791,-9.31681 4.66375,-18.84333 5.34988,-28.54756 0.8216,-11.6203 0.23791,-23.31873 -2.20387,-34.68656 -8.36069,-38.91663 -26.87344,-52.81544 -42.94766,-52.81544 -22.74998,0 -34.12496,29.92325 -34.12496,62.04334 z m -99.81881,-54.72772 c -2.44205,11.36784 -3.02861,23.06626 -2.20402,34.68644 0.68639,9.70437 2.7621,19.23086 5.34624,28.54769 l 5.67683,20.45002 h 45.49995 c 0,-21.39914 5.55243,-28.41615 11.37498,-37.22818 6.71124,-10.15572 11.37499,-24.81865 11.37499,-37.22807 0,-32.12008 -11.37499,-62.043305 -34.12495,-62.043305 -16.07422,0 -34.58699,13.898815 -42.94402,52.815405 z"
inkscape:connector-curvature="0"
style="fill:#326526;fill-opacity:1;stroke-width:0.35546762" />
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 23.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg width="406" height="469" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 406 469" style="enable-background:new 0 0 406 469;">
<style type="text/css">.st0{fill:#F18A00;}</style>
<g id="Layer_3"/>
<g id="Layer_8"/>
<g id="Layer_9"/>
<g id="Layer_7"/>
<g id="Layer_6"/>
<g id="Layer_4"/>
<g>
<g>
<path class="st0" d="m85.80618,195.80013c-1,-0.8 -1.3,-2.3 -0.6,-3.4c11.1,-18.2 56.5,-85.8 117.3,-85.8c49.6,0 90.4,33.4 110.3,53.3c1.2,1.2 2.9,1.9 4.6,1.9c1.7,0 3.4,-0.7 4.6,-1.9l16.4,-16.3c1,-1 1.1,-2.5 0.2,-3.5c-5.1,-6.1 -15.3,-17.2 -29.8,-28.2c-31.7,-24.1 -67.5,-36.3 -106.3,-36.3c-79.4,0 -129.8,75.4 -142.3,96.5c-0.8,1.4 -2.6,1.7 -3.8,0.7l-55.4,-43.6c-1,-0.8 -1.3,-2.2 -0.7,-3.3c5.9,-10.8 23,-39.4 48.5,-63.7c42.8,-40.7 95.1,-62.2 151.4,-62.2c56,0 109.2,18.9 153.9,54.8c27.3,21.9 45.5,43.2 51.3,50.4c0.8,1 0.7,2.5 -0.2,3.5l-12.3,12.2c-1.1,1.1 -2.9,1 -3.8,-0.2c-7.3,-8.9 -26.2,-30.5 -49.7,-49.2c-41.1,-32.7 -88,-49.2 -139.2,-49.2c-50.9,0 -96.5,18.6 -135.4,55.4c-20.9,19.7 -30.4,34.1 -35.6,42.3c-0.7,1.1 -0.5,2.6 0.6,3.5l16.7,13.2c1.2,0.9 2.6,1.4 4.1,1.4c2.2,0 4.2,-1.1 5.4,-2.9c7.4,-10.7 15.9,-20.6 26.8,-31.1c34.3,-33.1 75.7,-50.6 119.9,-50.6c92,0 151.2,70.7 165.3,89.4c0.8,1.1 0.7,2.5 -0.3,3.4l-49.8,49.3c-1.1,1.1 -2.8,1 -3.8,-0.1c-15.9,-18.1 -63,-66.5 -111.4,-66.5c-23.6,0 -46.6,11.3 -68.4,33.5c-7.2,7.3 -13.9,15.6 -19.9,24.4c-0.8,1.1 -0.5,2.7 0.6,3.6l93.1,73.2c1.1,0.9 1.3,2.5 0.4,3.7l-10.5,13.3c-0.9,1.1 -2.5,1.3 -3.7,0.4l-108.5,-85.3z" />
<path class="st0" d="m205.90618,468.90013c-56,0 -109.2,-18.9 -153.9,-54.8c-27.3,-21.9 -45.5,-43.2 -51.3,-50.4c-0.8,-1 -0.7,-2.5 0.2,-3.5l12.3,-12.2c1.1,-1.1 2.9,-1 3.8,0.2c7.3,8.9 26.2,30.5 49.7,49.2c41.1,32.7 88,49.2 139.2,49.2c50.9,0 96.5,-18.6 135.4,-55.4c20.9,-19.7 30.4,-34.1 35.6,-42.3c0.7,-1.1 0.5,-2.6 -0.6,-3.5l-16.7,-13.2c-1.2,-0.9 -2.6,-1.4 -4.1,-1.4c-2.2,0 -4.2,1.1 -5.4,2.9c-7.4,10.7 -15.9,20.6 -26.8,31.1c-34.3,33.1 -75.7,50.6 -119.8,50.6c-92,0 -151.2,-70.7 -165.3,-89.4c-0.8,-1.1 -0.7,-2.5 0.3,-3.4l49.8,-49.3c1.1,-1.1 2.8,-1 3.8,0.1c15.9,18.1 63,66.5 111.4,66.5c23.6,0 46.6,-11.3 68.4,-33.5c7.2,-7.3 13.9,-15.6 19.9,-24.4c0.8,-1.1 0.5,-2.7 -0.6,-3.6l-93.1,-73.2c-1.1,-0.9 -1.3,-2.5 -0.4,-3.7l10.5,-13.3c0.9,-1.1 2.5,-1.3 3.7,-0.4l108.3,85.2c1,0.8 1.3,2.3 0.6,3.4c-11.1,18.2 -56.5,85.8 -117.3,85.8c-49.6,0 -90.4,-33.4 -110.3,-53.3c-1.2,-1.2 -2.9,-1.9 -4.6,-1.9s-3.4,0.7 -4.6,1.9l-16.4,16.3c-1,1 -1.1,2.5 -0.2,3.5c5.1,6.1 15.3,17.2 29.8,28.2c31.7,24.1 67.5,36.3 106.3,36.3c79.4,0 129.8,-75.4 142.3,-96.5c0.8,-1.4 2.6,-1.7 3.8,-0.7l55.4,43.6c1,0.8 1.3,2.2 0.7,3.3c-5.9,10.8 -23,39.4 -48.5,63.7c-42.6,40.8 -95,62.3 -151.3,62.3z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

+39
View File
@@ -0,0 +1,39 @@
<?php
/**
* PHPMailer Exception class.
* PHP Version 5.5.
*
* @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
*
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2017 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
* @note This program is distributed in the hope that it will be useful - WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE.
*/
namespace PHPMailer\PHPMailer;
/**
* PHPMailer exception handler.
*
* @author Marcus Bointon <phpmailer@synchromedia.co.uk>
*/
class Exception extends \Exception
{
/**
* Prettify error message output.
*
* @return string
*/
public function errorMessage()
{
return '<strong>' . htmlspecialchars($this->getMessage()) . "</strong><br />\n";
}
}
+138
View File
@@ -0,0 +1,138 @@
<?php
/**
* PHPMailer - PHP email creation and transport class.
* PHP Version 5.5.
*
* @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
*
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2015 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
* @note This program is distributed in the hope that it will be useful - WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE.
*/
namespace PHPMailer\PHPMailer;
use League\OAuth2\Client\Grant\RefreshToken;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Token\AccessToken;
/**
* OAuth - OAuth2 authentication wrapper class.
* Uses the oauth2-client package from the League of Extraordinary Packages.
*
* @see http://oauth2-client.thephpleague.com
*
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
*/
class OAuth
{
/**
* An instance of the League OAuth Client Provider.
*
* @var AbstractProvider
*/
protected $provider;
/**
* The current OAuth access token.
*
* @var AccessToken
*/
protected $oauthToken;
/**
* The user's email address, usually used as the login ID
* and also the from address when sending email.
*
* @var string
*/
protected $oauthUserEmail = '';
/**
* The client secret, generated in the app definition of the service you're connecting to.
*
* @var string
*/
protected $oauthClientSecret = '';
/**
* The client ID, generated in the app definition of the service you're connecting to.
*
* @var string
*/
protected $oauthClientId = '';
/**
* The refresh token, used to obtain new AccessTokens.
*
* @var string
*/
protected $oauthRefreshToken = '';
/**
* OAuth constructor.
*
* @param array $options Associative array containing
* `provider`, `userName`, `clientSecret`, `clientId` and `refreshToken` elements
*/
public function __construct($options)
{
$this->provider = $options['provider'];
$this->oauthUserEmail = $options['userName'];
$this->oauthClientSecret = $options['clientSecret'];
$this->oauthClientId = $options['clientId'];
$this->oauthRefreshToken = $options['refreshToken'];
}
/**
* Get a new RefreshToken.
*
* @return RefreshToken
*/
protected function getGrant()
{
return new RefreshToken();
}
/**
* Get a new AccessToken.
*
* @return AccessToken
*/
protected function getToken()
{
return $this->provider->getAccessToken(
$this->getGrant(),
['refresh_token' => $this->oauthRefreshToken]
);
}
/**
* Generate a base64-encoded OAuth token.
*
* @return string
*/
public function getOauth64()
{
// Get a new token if it's not available or has expired
if (null === $this->oauthToken || $this->oauthToken->hasExpired()) {
$this->oauthToken = $this->getToken();
}
return base64_encode(
'user=' .
$this->oauthUserEmail .
"\001auth=Bearer " .
$this->oauthToken .
"\001\001"
);
}
}
File diff suppressed because it is too large Load Diff
+421
View File
@@ -0,0 +1,421 @@
<?php
/**
* PHPMailer POP-Before-SMTP Authentication Class.
* PHP Version 5.5.
*
* @see https://github.com/PHPMailer/PHPMailer/ The PHPMailer GitHub project
*
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
* @author Brent R. Matzelle (original founder)
* @copyright 2012 - 2019 Marcus Bointon
* @copyright 2010 - 2012 Jim Jagielski
* @copyright 2004 - 2009 Andy Prevost
* @license http://www.gnu.org/copyleft/lesser.html GNU Lesser General Public License
* @note This program is distributed in the hope that it will be useful - WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE.
*/
namespace PHPMailer\PHPMailer;
/**
* PHPMailer POP-Before-SMTP Authentication Class.
* Specifically for PHPMailer to use for RFC1939 POP-before-SMTP authentication.
* 1) This class does not support APOP authentication.
* 2) Opening and closing lots of POP3 connections can be quite slow. If you need
* to send a batch of emails then just perform the authentication once at the start,
* and then loop through your mail sending script. Providing this process doesn't
* take longer than the verification period lasts on your POP3 server, you should be fine.
* 3) This is really ancient technology; you should only need to use it to talk to very old systems.
* 4) This POP3 class is deliberately lightweight and incomplete, implementing just
* enough to do authentication.
* If you want a more complete class there are other POP3 classes for PHP available.
*
* @author Richard Davey (original author) <rich@corephp.co.uk>
* @author Marcus Bointon (Synchro/coolbru) <phpmailer@synchromedia.co.uk>
* @author Jim Jagielski (jimjag) <jimjag@gmail.com>
* @author Andy Prevost (codeworxtech) <codeworxtech@users.sourceforge.net>
*/
class POP3
{
/**
* The POP3 PHPMailer Version number.
*
* @var string
*/
const VERSION = '6.1.5';
/**
* Default POP3 port number.
*
* @var int
*/
const DEFAULT_PORT = 110;
/**
* Default timeout in seconds.
*
* @var int
*/
const DEFAULT_TIMEOUT = 30;
/**
* Debug display level.
* Options: 0 = no, 1+ = yes.
*
* @var int
*/
public $do_debug = 0;
/**
* POP3 mail server hostname.
*
* @var string
*/
public $host;
/**
* POP3 port number.
*
* @var int
*/
public $port;
/**
* POP3 Timeout Value in seconds.
*
* @var int
*/
public $tval;
/**
* POP3 username.
*
* @var string
*/
public $username;
/**
* POP3 password.
*
* @var string
*/
public $password;
/**
* Resource handle for the POP3 connection socket.
*
* @var resource
*/
protected $pop_conn;
/**
* Are we connected?
*
* @var bool
*/
protected $connected = false;
/**
* Error container.
*
* @var array
*/
protected $errors = [];
/**
* Line break constant.
*/
const LE = "\r\n";
/**
* Simple static wrapper for all-in-one POP before SMTP.
*
* @param string $host The hostname to connect to
* @param int|bool $port The port number to connect to
* @param int|bool $timeout The timeout value
* @param string $username
* @param string $password
* @param int $debug_level
*
* @return bool
*/
public static function popBeforeSmtp(
$host,
$port = false,
$timeout = false,
$username = '',
$password = '',
$debug_level = 0
) {
$pop = new self();
return $pop->authorise($host, $port, $timeout, $username, $password, $debug_level);
}
/**
* Authenticate with a POP3 server.
* A connect, login, disconnect sequence
* appropriate for POP-before SMTP authorisation.
*
* @param string $host The hostname to connect to
* @param int|bool $port The port number to connect to
* @param int|bool $timeout The timeout value
* @param string $username
* @param string $password
* @param int $debug_level
*
* @return bool
*/
public function authorise($host, $port = false, $timeout = false, $username = '', $password = '', $debug_level = 0)
{
$this->host = $host;
// If no port value provided, use default
if (false === $port) {
$this->port = static::DEFAULT_PORT;
} else {
$this->port = (int) $port;
}
// If no timeout value provided, use default
if (false === $timeout) {
$this->tval = static::DEFAULT_TIMEOUT;
} else {
$this->tval = (int) $timeout;
}
$this->do_debug = $debug_level;
$this->username = $username;
$this->password = $password;
// Reset the error log
$this->errors = [];
// connect
$result = $this->connect($this->host, $this->port, $this->tval);
if ($result) {
$login_result = $this->login($this->username, $this->password);
if ($login_result) {
$this->disconnect();
return true;
}
}
// We need to disconnect regardless of whether the login succeeded
$this->disconnect();
return false;
}
/**
* Connect to a POP3 server.
*
* @param string $host
* @param int|bool $port
* @param int $tval
*
* @return bool
*/
public function connect($host, $port = false, $tval = 30)
{
// Are we already connected?
if ($this->connected) {
return true;
}
//On Windows this will raise a PHP Warning error if the hostname doesn't exist.
//Rather than suppress it with @fsockopen, capture it cleanly instead
set_error_handler([$this, 'catchWarning']);
if (false === $port) {
$port = static::DEFAULT_PORT;
}
// connect to the POP3 server
$errno = 0;
$errstr = '';
$this->pop_conn = fsockopen(
$host, // POP3 Host
$port, // Port #
$errno, // Error Number
$errstr, // Error Message
$tval
); // Timeout (seconds)
// Restore the error handler
restore_error_handler();
// Did we connect?
if (false === $this->pop_conn) {
// It would appear not...
$this->setError(
"Failed to connect to server $host on port $port. errno: $errno; errstr: $errstr"
);
return false;
}
// Increase the stream time-out
stream_set_timeout($this->pop_conn, $tval, 0);
// Get the POP3 server response
$pop3_response = $this->getResponse();
// Check for the +OK
if ($this->checkResponse($pop3_response)) {
// The connection is established and the POP3 server is talking
$this->connected = true;
return true;
}
return false;
}
/**
* Log in to the POP3 server.
* Does not support APOP (RFC 2828, 4949).
*
* @param string $username
* @param string $password
*
* @return bool
*/
public function login($username = '', $password = '')
{
if (!$this->connected) {
$this->setError('Not connected to POP3 server');
}
if (empty($username)) {
$username = $this->username;
}
if (empty($password)) {
$password = $this->password;
}
// Send the Username
$this->sendString("USER $username" . static::LE);
$pop3_response = $this->getResponse();
if ($this->checkResponse($pop3_response)) {
// Send the Password
$this->sendString("PASS $password" . static::LE);
$pop3_response = $this->getResponse();
if ($this->checkResponse($pop3_response)) {
return true;
}
}
return false;
}
/**
* Disconnect from the POP3 server.
*/
public function disconnect()
{
$this->sendString('QUIT');
//The QUIT command may cause the daemon to exit, which will kill our connection
//So ignore errors here
try {
@fclose($this->pop_conn);
} catch (Exception $e) {
//Do nothing
}
}
/**
* Get a response from the POP3 server.
*
* @param int $size The maximum number of bytes to retrieve
*
* @return string
*/
protected function getResponse($size = 128)
{
$response = fgets($this->pop_conn, $size);
if ($this->do_debug >= 1) {
echo 'Server -> Client: ', $response;
}
return $response;
}
/**
* Send raw data to the POP3 server.
*
* @param string $string
*
* @return int
*/
protected function sendString($string)
{
if ($this->pop_conn) {
if ($this->do_debug >= 2) { //Show client messages when debug >= 2
echo 'Client -> Server: ', $string;
}
return fwrite($this->pop_conn, $string, strlen($string));
}
return 0;
}
/**
* Checks the POP3 server response.
* Looks for for +OK or -ERR.
*
* @param string $string
*
* @return bool
*/
protected function checkResponse($string)
{
if (strpos($string, '+OK') !== 0) {
$this->setError("Server reported an error: $string");
return false;
}
return true;
}
/**
* Add an error to the internal error store.
* Also display debug output if it's enabled.
*
* @param string $error
*/
protected function setError($error)
{
$this->errors[] = $error;
if ($this->do_debug >= 1) {
echo '<pre>';
foreach ($this->errors as $e) {
print_r($e);
}
echo '</pre>';
}
}
/**
* Get an array of error messages, if any.
*
* @return array
*/
public function getErrors()
{
return $this->errors;
}
/**
* POP3 connection error handler.
*
* @param int $errno
* @param string $errstr
* @param string $errfile
* @param int $errline
*/
protected function catchWarning($errno, $errstr, $errfile, $errline)
{
$this->setError(
'Connecting to the POP3 server raised a PHP warning:' .
"errno: $errno errstr: $errstr; errfile: $errfile; errline: $errline"
);
}
}
File diff suppressed because it is too large Load Diff
+316 -230
View File
@@ -1,230 +1,316 @@
<?php <?php
namespace Franzz\Livetrail; /**
* GPX to GeoJSON Converter
class GeoJson extends Geo { *
protected const EXT = '.geojson'; * To convert a gpx file:
* 1. Add file <file_name>.gpx to geo/ folder
private const MAX_FILESIZE = 2; //MB * 2. Assign file to project: UPDATE projects SET codename = '<file_name>' WHERE id_project = <id_project>;
private const MAX_DEVIATION_FLAT = 0.1; //10% * 3. Load any page
private const MAX_DEVIATION_ELEV = 0.1; //10% *
* To force gpx rebuild:
public function __construct($sCodeName) { * ?a=build_geojson&name=<file_name>
parent::__construct($sCodeName); */
} class Converter extends PhpObject {
public function saveFile() { public function __construct() {
$this->addNotice('Saving '.$this->sFilePath); parent::__construct(__CLASS__, Settings::DEBUG);
file_put_contents($this->sFilePath, $this->buildGeoJson()); }
}
public static function convertToGeoJson($sCodeName) {
public function isSimplicationRequired() { $oGpx = new Gpx($sCodeName);
//Size in bytes $oGeoJson = new GeoJson($sCodeName);
$iFileSize = strlen($this->buildGeoJson());
$oGeoJson->buildTracks($oGpx->getTracks());
//Convert to MB if($oGeoJson->isSimplicationRequired()) $oGeoJson->buildTracks($oGpx->getTracks(), true);
$iFileSize = round($iFileSize / pow(1024, 2), 2); $oGeoJson->sortOffTracks();
$oGeoJson->saveFile();
//Compare with max allowed size
$bFileTooLarge = ($iFileSize > self::MAX_FILESIZE); return $oGpx->getLog().'<br />'.$oGeoJson->getLog();
if($bFileTooLarge) $this->addNotice('Output file is too large ('.$iFileSize.'MB > '.self::MAX_FILESIZE.'MB)'); }
return $bFileTooLarge; public static function isGeoJsonValid($sCodeName) {
} $bResult = false;
$sGeoJsonFilePath = Geo::getFilePath($sCodeName, GeoJson::EXT);
public function buildTracks($asTracks, $bSimplify=false) { if(file_exists($sGeoJsonFilePath) && filemtime($sGeoJsonFilePath) > filemtime(Geo::getFilePath($sCodeName, Gpx::EXT))) $bResult = true;
$this->addNotice('Creating '.($bSimplify?'Simplified ':'').'GeoJson Tracks'); return $bResult;
}
$iGlobalInvalidPointCount = 0; }
$iGlobalPointCount = 0;
class Geo extends PhpObject {
$this->asTracks = [];
foreach($asTracks as $asTrackProps) { const GEO_FOLDER = 'geo/';
$asOptions = $this->parseOptions($asTrackProps['cmt']); const OPT_SIMPLE = 'simplification';
//Color mapping protected $asTracks;
switch($asTrackProps['color']) { protected $sFilePath;
case 'DarkBlue':
$sType = 'main'; public function __construct($sCodeName) {
break; parent::__construct(get_class($this), Settings::DEBUG, PhpObject::MODE_HTML);
case 'Magenta': $this->sFilePath = self::getFilePath($sCodeName, static::EXT);
if($bSimplify && $asOptions[self::OPT_SIMPLE]!='keep') { $this->asTracks = array();
$this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (off-track)'); }
continue 2; //discard tracks
} public static function getFilePath($sCodeName, $sExt) {
else { return self::GEO_FOLDER.$sCodeName.$sExt;
$sType = 'off-track'; }
break;
} public function getLog() {
case 'Red': return $this->getCleanMessageStack(PhpObject::NOTICE_TAB);
$sType = 'hitchhiking'; }
break; }
default:
$this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (unknown color "'.$asTrackProps['color'].'")'); class Gpx extends Geo {
continue 2; //discard tracks
} const EXT = '.gpx';
$asTrack = [ public function __construct($sCodeName) {
'type' => 'Feature', parent::__construct($sCodeName);
'properties' => [ $this->parseFile();
'name' => $asTrackProps['name'], }
'type' => $sType,
'description' => $asTrackProps['desc'] public function getTracks() {
], return $this->asTracks;
'geometry' => [ }
'type' => 'LineString',
'coordinates' => [] private function parseFile() {
] $this->addNotice('Parsing: '.$this->sFilePath);
]; $oXml = simplexml_load_file($this->sFilePath);
if($sType != 'hitchhiking' && str_contains($asTrackProps['desc'], ' ➜ ')) { //Tracks
list($sFrom, $sTo) = explode(' ➜ ', $asTrackProps['desc']); $this->addNotice('Converting '.count($oXml->trk).' tracks');
$asTrack['properties']['leg'] = ['from'=> $sFrom, 'to'=> $sTo]; foreach($oXml->trk as $aoTrack) {
} $asTrack = array(
'name' => (string) $aoTrack->name,
//Track points 'desc' => str_replace("\n", '', ToolBox::fixEOL((strip_tags($aoTrack->desc)))),
$asTrackPoints = $asTrackProps['points']; 'cmt' => ToolBox::fixEOL((strip_tags($aoTrack->cmt))),
$iPointCount = count($asTrackPoints); 'color' => (string) $aoTrack->extensions->children('gpxx', true)->TrackExtension->DisplayColor,
$iInvalidPointCount = 0; 'points'=> array()
$asPrevPoint = []; );
foreach($asTrackPoints as $iIndex=>$asPoint) {
$asNextPoint = ($iIndex < ($iPointCount - 1))?$asTrackPoints[$iIndex + 1]:[]; foreach($aoTrack->trkseg as $asSegment) {
if($bSimplify && !empty($asPrevPoint) && !empty($asNextPoint)) { foreach($asSegment as $asPoint) {
if(!$this->isPointValid($asPrevPoint, $asPoint, $asNextPoint)) { $asTrack['points'][] = array(
$iInvalidPointCount++; 'lon' => (float) $asPoint['lon'],
continue; 'lat' => (float) $asPoint['lat'],
} 'ele' => (int) $asPoint->ele
} );
$asTrack['geometry']['coordinates'][] = array_values($asPoint); }
$asPrevPoint = $asPoint; }
} $this->asTracks[] = $asTrack;
$this->asTracks[] = $asTrack; }
$iGlobalInvalidPointCount += $iInvalidPointCount; //Waypoints
$iGlobalPointCount += $iPointCount; $this->addNotice('Ignoring '.count($oXml->wpt).' waypoints');
if($iInvalidPointCount > 0) $this->addNotice('Removing '.$iInvalidPointCount.'/'.$iPointCount.' points ('.round($iInvalidPointCount / $iPointCount * 100, 1).'%) from '.$asTrackProps['name']); }
} }
if($bSimplify) $this->addNotice('Total: '.$iGlobalInvalidPointCount.'/'.$iGlobalPointCount.' points removed ('.round($iGlobalInvalidPointCount / $iGlobalPointCount * 100, 1).'%)'); class GeoJson extends Geo {
}
const EXT = '.geojson';
public function sortOffTracks() { const MAX_FILESIZE = 2; //MB
$this->addNotice('Sorting off-tracks'); const MAX_DEVIATION_FLAT = 0.1; //10%
const MAX_DEVIATION_ELEV = 0.1; //20%
//Find first & last track points
$asTracksEnds = []; public function __construct($sCodeName) {
$asTracks = []; parent::__construct($sCodeName);
foreach($this->asTracks as $iTrackId=>$asTrack) { }
$sTrackId = 't'.$iTrackId;
$asTracksEnds[$sTrackId] = ['first'=>reset($asTrack['geometry']['coordinates']), 'last'=>end($asTrack['geometry']['coordinates'])]; public function saveFile() {
$asTracks[$sTrackId] = $asTrack; $this->addNotice('Saving '.$this->sFilePath);
} file_put_contents($this->sFilePath, $this->buildGeoJson());
}
//Find variants close-by tracks
$asClonedTracks = $asTracks; public function isSimplicationRequired() {
foreach($asClonedTracks as $sTrackId=>$asTrack) { //Size in bytes
if($asTrack['properties']['type'] != 'off-track') continue; $iFileSize = strlen($this->buildGeoJson());
$iMinDistance = INF; //Convert to MB
$sConnectedTrackId = 0; $iFileSize = round($iFileSize / pow(1024, 2), 2);
$iPosition = 0;
//Compare with max allowed size
//Test all track ending points to find the closest $bFileTooLarge = ($iFileSize > self::MAX_FILESIZE);
foreach($asTracksEnds as $sTrackEndId=>$asTrackEnds) { if($bFileTooLarge) $this->addNotice('Output file is too large ('.$iFileSize.'MB > '.self::MAX_FILESIZE.'MB)');
if($sTrackEndId != $sTrackId) {
//Calculate distance between the last point of the track and every starting point of other tracks return $bFileTooLarge;
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['last'], $asTrackEnds['first']); }
if($iDistance < $iMinDistance) {
$sConnectedTrackId = $sTrackEndId; public function buildTracks($asTracks, $bSimplify=false) {
$iPosition = 0; //Track before the Connected Track $this->addNotice('Creating '.($bSimplify?'Simplified ':'').'GeoJson Tracks');
$iMinDistance = $iDistance;
} $iGlobalInvalidPointCount = 0;
$iGlobalPointCount = 0;
//Calculate distance between the first point of the track and every ending point of other tracks
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['first'], $asTrackEnds['last']); $this->asTracks = array();
if($iDistance < $iMinDistance) { foreach($asTracks as $asTrackProps) {
$sConnectedTrackId = $sTrackEndId; $asOptions = $this->parseOptions($asTrackProps['cmt']);
$iPosition = +1; //Track after the Connected Track
$iMinDistance = $iDistance; //Color mapping
} switch($asTrackProps['color']) {
} case 'DarkBlue':
} $sType = 'main';
break;
//Move track case 'Magenta':
unset($asTracks[$sTrackId]); if($bSimplify && $asOptions[self::OPT_SIMPLE]!='keep') {
$iOffset = array_search($sConnectedTrackId, array_keys($asTracks)) + $iPosition; $this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (off-track)');
$asTracks = array_slice($asTracks, 0, $iOffset) + [$sTrackId => $asTrack] + array_slice($asTracks, $iOffset); continue 2; //discard tracks
} }
else {
$this->asTracks = array_values($asTracks); $sType = 'off-track';
} break;
}
public function getCenter() { case 'Red':
$asCoords = []; $sType = 'hitchhiking';
$asMainTracks = array_filter($this->asTracks, function ($astrack) {return $astrack['properties']['type'] == 'main';}); break;
foreach($asMainTracks as $asMainTrack) { default:
foreach($asMainTrack['geometry']['coordinates'] as $aiCoords) { $this->addNotice('Ignoring Track "'.$asTrackProps['name'].' (unknown color "'.$asTrackProps['color'].'")');
$asCoords[] = $aiCoords; continue 2; //discard tracks
} }
}
$asTrack = array(
return $asCoords[(int) floor(count($asCoords) / 2)]; 'type' => 'Feature',
} 'properties' => array(
'name' => $asTrackProps['name'],
private function parseOptions($sComment) { 'type' => $sType,
$sComment = strip_tags(html_entity_decode($sComment)); 'description' => $asTrackProps['desc']
$asOptions = [self::OPT_SIMPLE=>'']; ),
foreach(explode("\n", $sComment) as $sLine) { 'geometry' => array(
$asOptions[mb_strtolower(trim(mb_strstr($sLine, ':', true)))] = mb_strtolower(trim(mb_substr(mb_strstr($sLine, ':'), 1))); 'type' => 'LineString',
} 'coordinates' => array()
return $asOptions; )
} );
private function isPointValid($asPointA, $asPointO, $asPointB) { //Track points
/* A----O Calculate angle AO^OB $asTrackPoints = $asTrackProps['points'];
* \ If angle is within [90% Pi ; 110% Pi], O can be discarded $iPointCount = count($asTrackPoints);
* \ O is valid otherwise $iInvalidPointCount = 0;
* B $asPrevPoint = array();
*/ foreach($asTrackPoints as $iIndex=>$asPoint) {
$asNextPoint = ($iIndex < ($iPointCount - 1))?$asTrackPoints[$iIndex + 1]:array();
//Path Turn Check -> -> -> -> if($bSimplify && !empty($asPrevPoint) && !empty($asNextPoint)) {
//Law of Cosines (vector): angle = arccos(OA.OB / ||OA||.||OB||) if(!$this->isPointValid($asPrevPoint, $asPoint, $asNextPoint)) {
$fVectorOA = ['lon'=>($asPointA['lon'] - $asPointO['lon']), 'lat'=> ($asPointA['lat'] - $asPointO['lat'])]; $iInvalidPointCount++;
$fVectorOB = ['lon'=>($asPointB['lon'] - $asPointO['lon']), 'lat'=> ($asPointB['lat'] - $asPointO['lat'])]; continue;
}
$fLengthOA = sqrt(pow($asPointA['lon'] - $asPointO['lon'], 2) + pow($asPointA['lat'] - $asPointO['lat'], 2)); }
$fLengthOB = sqrt(pow($asPointO['lon'] - $asPointB['lon'], 2) + pow($asPointO['lat'] - $asPointB['lat'], 2)); $asTrack['geometry']['coordinates'][] = array_values($asPoint);
$asPrevPoint = $asPoint;
$fVectorOAxOB = $fVectorOA['lon'] * $fVectorOB['lon'] + $fVectorOA['lat'] * $fVectorOB['lat']; }
$fAngleAOB = ($fLengthOA != 0 && $fLengthOB != 0) ? acos($fVectorOAxOB/($fLengthOA * $fLengthOB)) : 0; $this->asTracks[] = $asTrack;
//Elevation Check $iGlobalInvalidPointCount += $iInvalidPointCount;
//Law of Cosines: angle = arccos((OB² + AO² - AB²) / (2*OB*AO)) $iGlobalPointCount += $iPointCount;
$fLengthAB = sqrt(pow($asPointB['ele'] - $asPointA['ele'], 2) + pow($fLengthOA + $fLengthOB, 2)); if($iInvalidPointCount > 0) $this->addNotice('Removing '.$iInvalidPointCount.'/'.$iPointCount.' points ('.round($iInvalidPointCount / $iPointCount * 100, 1).'%) from '.$asTrackProps['name']);
$fLengthAO = sqrt(pow($asPointO['ele'] - $asPointA['ele'], 2) + pow($fLengthOA, 2)); }
$fLengthOB = sqrt(pow($asPointB['ele'] - $asPointO['ele'], 2) + pow($fLengthOB, 2));
$fAngleAOBElev = ($fLengthOB != 0 && $fLengthAO != 0) ? (acos((pow($fLengthOB, 2) + pow($fLengthAO, 2) - pow($fLengthAB, 2)) / (2 * $fLengthOB * $fLengthAO))) : 0; if($bSimplify) $this->addNotice('Total: '.$iGlobalInvalidPointCount.'/'.$iGlobalPointCount.' points removed ('.round($iGlobalInvalidPointCount / $iGlobalPointCount * 100, 1).'%)');
}
return ($fAngleAOB <= (1 - self::MAX_DEVIATION_FLAT) * M_PI || $fAngleAOB >= (1 + self::MAX_DEVIATION_FLAT) * M_PI ||
$fAngleAOBElev <= (1 - self::MAX_DEVIATION_ELEV) * M_PI || $fAngleAOBElev >= (1 + self::MAX_DEVIATION_ELEV) * M_PI);
} public function sortOffTracks() {
$this->addNotice('Sorting off-tracks');
private function buildGeoJson() {
return json_encode(['type'=>'FeatureCollection', 'features'=>$this->asTracks]); //Find first & last track points
} $asTracksEnds = array();
$asTracks = array();
private static function getDistance($asPointA, $asPointB) { foreach($this->asTracks as $iTrackId=>$asTrack) {
$fLatFrom = $asPointA[1]; $sTrackId = 't'.$iTrackId;
$fLonFrom = $asPointA[0]; $asTracksEnds[$sTrackId] = array('first'=>reset($asTrack['geometry']['coordinates']), 'last'=>end($asTrack['geometry']['coordinates']));
$fLatTo = $asPointB[1]; $asTracks[$sTrackId] = $asTrack;
$fLonTo = $asPointB[0]; }
$fRad = M_PI / 180; //Find variants close-by tracks
$asClonedTracks = $asTracks;
//Calculate distance from latitude and longitude foreach($asClonedTracks as $sTrackId=>$asTrack) {
$fTheta = $fLonFrom - $fLonTo; if($asTrack['properties']['type'] != 'off-track') continue;
$fDistance = sin($fLatFrom * $fRad) * sin($fLatTo * $fRad) + cos($fLatFrom * $fRad) * cos($fLatTo * $fRad) * cos($fTheta * $fRad);
$iMinDistance = INF;
return acos($fDistance) / $fRad * 60 * 1.853; $sConnectedTrackId = 0;
} $iPosition = 0;
}
//Test all track ending points to find the closest
foreach($asTracksEnds as $sTrackEndId=>$asTrackEnds) {
if($sTrackEndId != $sTrackId) {
//Calculate distance between the last point of the track and every starting point of other tracks
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['last'], $asTrackEnds['first']);
if($iDistance < $iMinDistance) {
$sConnectedTrackId = $sTrackEndId;
$iPosition = 0; //Track before the Connected Track
$iMinDistance = $iDistance;
}
//Calculate distance between the first point of the track and every ending point of other tracks
$iDistance = self::getDistance($asTracksEnds[$sTrackId]['first'], $asTrackEnds['last']);
if($iDistance < $iMinDistance) {
$sConnectedTrackId = $sTrackEndId;
$iPosition = +1; //Track after the Connected Track
$iMinDistance = $iDistance;
}
}
}
//Move track
unset($asTracks[$sTrackId]);
$iOffset = array_search($sConnectedTrackId, array_keys($asTracks)) + $iPosition;
$asTracks = array_slice($asTracks, 0, $iOffset) + array($sTrackId => $asTrack) + array_slice($asTracks, $iOffset);
}
$this->asTracks = array_values($asTracks);
}
private function parseOptions($sComment){
$asOptions = array(self::OPT_SIMPLE=>'');
foreach(explode("\n", $sComment) as $sLine) {
$asOptions[mb_strtolower(trim(mb_strstr($sLine, ':', true)))] = mb_strtolower(trim(mb_substr(mb_strstr($sLine, ':'), 1)));
}
return $asOptions;
}
private function isPointValid($asPointA, $asPointO, $asPointB) {
/* A----O Calculate angle AO^OB
* \ If angle is within [90% Pi ; 110% Pi], O can be discarded
* \ O is valid otherwise
* B
*/
//Path Turn Check -> -> -> ->
//Law of Cosines (vector): angle = arccos(OA.OB / ||OA||.||OB||)
$fVectorOA = array('lon'=>($asPointA['lon'] - $asPointO['lon']), 'lat'=> ($asPointA['lat'] - $asPointO['lat']));
$fVectorOB = array('lon'=>($asPointB['lon'] - $asPointO['lon']), 'lat'=> ($asPointB['lat'] - $asPointO['lat']));
$fLengthOA = sqrt(pow($asPointA['lon'] - $asPointO['lon'], 2) + pow($asPointA['lat'] - $asPointO['lat'], 2));
$fLengthOB = sqrt(pow($asPointO['lon'] - $asPointB['lon'], 2) + pow($asPointO['lat'] - $asPointB['lat'], 2));
$fVectorOAxOB = $fVectorOA['lon'] * $fVectorOB['lon'] + $fVectorOA['lat'] * $fVectorOB['lat'];
$fAngleAOB = acos($fVectorOAxOB/($fLengthOA * $fLengthOB));
//Elevation Check
//Law of Cosines: angle = arccos((OB² + AO² - AB²) / (2*OB*AO))
$fLengthAB = sqrt(pow($asPointB['ele'] - $asPointA['ele'], 2) + pow($fLengthOA + $fLengthOB, 2));
$fLengthAO = sqrt(pow($asPointO['ele'] - $asPointA['ele'], 2) + pow($fLengthOA, 2));
$fLengthOB = sqrt(pow($asPointB['ele'] - $asPointO['ele'], 2) + pow($fLengthOB, 2));
$fAngleAOBElev = acos((pow($fLengthOB, 2) + pow($fLengthAO, 2) - pow($fLengthAB, 2)) / (2 * $fLengthOB * $fLengthAO));
return ($fAngleAOB <= (1 - self::MAX_DEVIATION_FLAT) * M_PI || $fAngleAOB >= (1 + self::MAX_DEVIATION_FLAT) * M_PI ||
$fAngleAOBElev <= (1 - self::MAX_DEVIATION_ELEV) * M_PI || $fAngleAOBElev >= (1 + self::MAX_DEVIATION_ELEV) * M_PI);
}
private function buildGeoJson() {
return json_encode(array('type'=>'FeatureCollection', 'features'=>$this->asTracks));
}
private static function getDistance($asPointA, $asPointB) {
$fLatFrom = $asPointA[1];
$fLonFrom = $asPointA[0];
$fLatTo = $asPointB[1];
$fLonTo = $asPointB[0];
$fRad = M_PI / 180;
//Calculate distance from latitude and longitude
$fTheta = $fLonFrom - $fLonTo;
$fDistance = sin($fLatFrom * $fRad) * sin($fLatTo * $fRad) + cos($fLatFrom * $fRad) * cos($fLatTo * $fRad) * cos($fTheta * $fRad);
return acos($fDistance) / $fRad * 60 * 1.853;
}
}
+44 -55
View File
@@ -1,110 +1,99 @@
<?php <?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Mask;
use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP; use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception; use PHPMailer\PHPMailer\Exception;
use \Settings;
require_once 'inc/PHPMailer/Exception.php';
require_once 'inc/PHPMailer/PHPMailer.php';
require_once 'inc/PHPMailer/SMTP.php';
class Email extends PhpObject { class Email extends PhpObject {
private $sServName; private $sServName;
private $sTemplateName; private $sTemplateName;
/** /**
* Email Template * Email Template
* @var Mask * @var Mask
*/ */
public $oTemplate; public $oTemplate;
private $asDests; private $asDests;
public function __construct($sServName, $sTemplateName='') { public function __construct($sServName, $sTemplateName='') {
parent::__construct(__CLASS__);
$this->sServName = $sServName; $this->sServName = $sServName;
$this->setTemplate($sTemplateName); $this->setTemplate($sTemplateName);
$this->asDests = []; $this->asDests = array();
} }
public function setTemplate($sTemplateName) { public function setTemplate($sTemplateName) {
$this->sTemplateName = $sTemplateName; $this->sTemplateName = $sTemplateName;
$this->oTemplate = new Mask($this->sTemplateName); $this->oTemplate = new Mask($this->sTemplateName);
$this->oTemplate->setTag('local_server', $this->sServName); $this->oTemplate->setTag('local_server', $this->sServName);
$this->oTemplate->setTag('geo_server', Settings::GEO_SERVER);
} }
/** /**
* Set Target User Info * Set Target User Info
* @param array $asDests Contains: id_user, name, email, language, timezone, active * @param array $asDests Contains: id_user, name, email, language, active
*/ */
public function setDestInfo($asDests) { public function setDestInfo($asDests) {
if(array_key_exists('email', $asDests)) $asDests = [$asDests]; if(array_key_exists('email', $asDests)) $asDests = array($asDests);
$this->asDests = $asDests; $this->asDests = $asDests;
} }
public function send() { public function send() {
$oPHPMailer = new PHPMailer(true);
//Server settings
if(Settings::DEBUG) $oPHPMailer->SMTPDebug = SMTP::DEBUG_SERVER;//Enable verbose debug output
$oPHPMailer->isSMTP(); //Send using SMTP
$oPHPMailer->CharSet = Settings::TEXT_ENC; //Mail Character Set
$oPHPMailer->Encoding = 'base64'; //Base 64 Character Encoding
$oPHPMailer->Host = Settings::MAIL_SERVER; //Set the SMTP server to send through
$oPHPMailer->SMTPAuth = true; //Enable SMTP authentication
$oPHPMailer->Username = Settings::MAIL_USER; //SMTP username
$oPHPMailer->Password = Settings::MAIL_PASS; //SMTP password
$oPHPMailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; //Enable TLS encryption; `PHPMailer::ENCRYPTION_SMTPS` encouraged
$oPHPMailer->Port = 587; //TCP port to connect to, use 465 for `PHPMailer::ENCRYPTION_SMTPS` above
$oPHPMailer->setFrom(Settings::MAIL_FROM, Livetrail::PROJECT_NAME);
$oPHPMailer->addReplyTo(Settings::MAIL_FROM, Livetrail::PROJECT_NAME);
$bSuccess = true;
foreach($this->asDests as $asDest) { foreach($this->asDests as $asDest) {
$oPHPMailer = new PHPMailer(true);
//Server settings
if(Settings::DEBUG) $oPHPMailer->SMTPDebug = SMTP::DEBUG_SERVER;//Enable verbose debug output
$oPHPMailer->isSMTP(); //Send using SMTP
$oPHPMailer->CharSet = Settings::TEXT_ENC; //Mail Character Set
$oPHPMailer->Encoding = 'base64'; //Base 64 Character Encoding
$oPHPMailer->Host = Settings::MAIL_SERVER; //Set the SMTP server to send through
$oPHPMailer->SMTPAuth = true; //Enable SMTP authentication
$oPHPMailer->Username = Settings::MAIL_USER; //SMTP username
$oPHPMailer->Password = Settings::MAIL_PASS; //SMTP password
$oPHPMailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; //Enable TLS encryption; `PHPMailer::ENCRYPTION_SMTPS` encouraged
$oPHPMailer->Port = 587; //TCP port to connect to, use 465 for `PHPMailer::ENCRYPTION_SMTPS` above
$oPHPMailer->setFrom(Settings::MAIL_FROM, 'Spotty');
$oPHPMailer->addReplyTo(Settings::MAIL_FROM, 'Spotty');
//Message //Message
$this->oTemplate->setLanguage($asDest['language'], Livetrail::DEFAULT_LANG); $this->oTemplate->setLanguage($asDest['language'], Spot::DEFAULT_LANG);
$this->oTemplate->setTimezone($asDest['timezone']); $this->oTemplate->setTimezone($asDest['timezone']);
//Unsubscribe Link //Unsubscribe Link
$sUnsubLink = $this->sServName.'?a=unsubscribe_email&id='.$asDest['id_user']; $sUnsubLink = $this->sServName.'?a=unsubscribe_email&id='.$asDest['id_user'];
$this->oTemplate->setTag('unsubscribe_link', htmlspecialchars($sUnsubLink)); $this->oTemplate->setTag('unsubscribe_link', htmlspecialchars($sUnsubLink));
$oPHPMailer->addCustomHeader('List-Unsubscribe','<mailto:'.Settings::MAIL_FROM.'?subject=unsubscribe>, <'.$sUnsubLink.'>'); $oPHPMailer->addCustomHeader('List-Unsubscribe','<mailto:'.Settings::MAIL_FROM.'?subject=unsubscribe>, <'.$sUnsubLink.'>');
$oPHPMailer->addCustomHeader('List-Unsubscribe-Post','List-Unsubscribe=One-Click'); $oPHPMailer->addCustomHeader('List-Unsubscribe-Post','List-Unsubscribe=One-Click');
//Email Content //Email Content
$this->oTemplate->setTag('timezone', 'lang:time.city', self::getTimeZoneCity($asDest['timezone'])); $this->oTemplate->setTag('timezone', 'lang:city_time', self::getTimeZoneCity($asDest['timezone']));
$sHtmlMessage = $this->oTemplate->getMask(); $sHtmlMessage = $this->oTemplate->getMask();
$sPlainMessage = strip_tags(str_replace('<br />', "\n", $sHtmlMessage)); $sPlainMessage = strip_tags(str_replace('<br />', "\n", $sHtmlMessage));
//Recipients //Recipients
try { $oPHPMailer->addAddress($asDest['email'], $asDest['name']);
$oPHPMailer->addAddress($asDest['email'], $asDest['name']);
} catch (Exception $oError) {
$this->addError('Invalid address skipped: '.$asDest['email'].' ('.$asDest['name'].')');
continue;
}
//Content //Content
$oPHPMailer->isHTML(true); $oPHPMailer->isHTML(true);
$oPHPMailer->Subject = $this->oTemplate->getTranslator()->getTranslation($this->sTemplateName.'.subject'); $oPHPMailer->Subject = $this->oTemplate->getTranslator()->getTranslation($this->sTemplateName.'_subject');
$oPHPMailer->Body = $sHtmlMessage; $oPHPMailer->Body = $sHtmlMessage;
$oPHPMailer->AltBody = $sPlainMessage; $oPHPMailer->AltBody = $sPlainMessage;
try { try {
$bSuccess = $bSuccess && $oPHPMailer->send(); $oPHPMailer->send();
} }
catch (Exception $oError) { catch (Exception $e) {
$this->addError('Message could not be sent to "'.$asDest['email'].'". Mailer Error: '.$oPHPMailer->ErrorInfo); $this->addError('Message could not be sent to "'.$asDest['email'].'". Mailer Error: '.$oPHPMailer->ErrorInfo);
$oPHPMailer->getSMTPInstance()->reset();
} }
$oPHPMailer->clearAddresses();
$oPHPMailer->clearCustomHeaders();
} }
return $bSuccess;
} }
private static function getTimeZoneCity($sTimeZone) { private static function getTimeZoneCity($sTimeZone) {
return (strpos($sTimeZone, '/')!==false)?str_replace('_', ' ', explode('/', $sTimeZone)[1]):$sTimeZone; return (strpos($sTimeZone, '/')!==false)?str_replace('_', ' ', explode('/', $sTimeZone)[1]):$sTimeZone;
} }
} }
+212
View File
@@ -0,0 +1,212 @@
<?php
/**
* Feed Class
* Also manages spots (devices) & messages
*/
class Feed extends PhpObject {
//Spot feed
const FEED_HOOK = 'https://api.findmespot.com/spot-main-web/consumer/rest-api/2.0/public/feed/';
const FEED_TYPE_XML = '/message.xml';
const FEED_TYPE_JSON = '/message.json';
const FEED_MAX_REFRESH = 5 * 60; //Seconds
//DB Tables
const SPOT_TABLE = 'spots';
const FEED_TABLE = 'feeds';
const MSG_TABLE = 'messages';
/**
* Database Handle
* @var Db
*/
private $oDb;
private $iFeedId;
private $sRefFeedId;
private $iLastUpdate;
public function __construct(Db &$oDb, $iFeedId=0) {
parent::__construct(__CLASS__, Settings::DEBUG);
$this->oDb = &$oDb;
if($iFeedId > 0) $this->setFeedId($iFeedId);
}
public function getFeedId() {
return $this->iFeedId;
}
public function getLastUpdate(): int {
return $this->iLastUpdate;
}
public function setFeedId($iFeedId) {
$this->iFeedId = $iFeedId;
$asFeed = $this->getFeed();
$this->sRefFeedId = $asFeed['ref_feed_id'];
$this->iLastUpdate = strtotime($asFeed['last_update']);
}
public function createFeedId($oProjectId) {
$this->setFeedId($this->oDb->insertRow(self::FEED_TABLE, array(
Db::getId(Project::PROJ_TABLE) => $oProjectId,
'status' => 'INACTIVE'
)));
return $this->getFeedId();
}
public function setRefFeedId($sRefFeedId) {
return $this->updateField('ref_feed_id', $sRefFeedId);
}
public function setSpotId($iSpotId) {
return $this->updateField(Db::getId(self::SPOT_TABLE), $iSpotId);
}
public function setProjectId($iProjectId) {
return $this->updateField(Db::getId(Project::PROJ_TABLE), $iProjectId);
}
public function getSpots() {
$asSpots = $this->oDb->selectRows(array('from'=>self::SPOT_TABLE));
foreach($asSpots as &$asSpot) $asSpot['id'] = $asSpot[Db::getId(self::SPOT_TABLE)];
return $asSpots;
}
public function getFeeds($iFeedId=0) {
$asInfo = array('from'=>self::FEED_TABLE);
if($iFeedId > 0) $asInfo['constraint'] = array(Db::getId(self::FEED_TABLE)=>$iFeedId);
$asFeeds = $this->oDb->selectRows($asInfo);
foreach($asFeeds as &$asFeed) $asFeed['id'] = $asFeed[Db::getId(self::FEED_TABLE)];
return $asFeeds;
}
public function getFeed() {
$asFeeds = $this->getFeeds($this->getFeedId());
return array_shift($asFeeds);
}
public function getMessages($asActivePeriod = array()) {
$asInfo = array(
'select' => array('id_message', 'ref_msg_id', 'type', 'latitude', 'longitude', 'site_time', 'unix_time'),
'from' => self::MSG_TABLE,
'constraint'=> array(Db::getId(self::FEED_TABLE) => $this->getFeedId()),
'constOpe' => array(Db::getId(self::FEED_TABLE) => "="),
'orderBy' => array('site_time'=>'ASC')
);
if(!empty($asActivePeriod)) {
$asInfo['constraint']['site_time'] = $asActivePeriod;
$asInfo['constOpe']['site_time'] = "BETWEEN";
}
return $this->oDb->selectRows($asInfo);
}
public function checkUpdateFeed($sProjectMode) {
$bNewMsg = false;
//Spam Check: no more than 1 API request per 5 minutes
if($sProjectMode == Project::MODE_BLOG) {
$oLastUpdate = new DateTime('@'.$this->iLastUpdate);
$oNow = new DateTime('now');
$iSecDiff = $oNow->getTimestamp() - $oLastUpdate->getTimestamp();
if($iSecDiff > self::FEED_MAX_REFRESH) $bNewMsg = $this->updateFeed();
}
return $bNewMsg;
}
private function updateFeed() {
$bNewMsg = false;
$asData = $this->retrieveFeed();
$sLastUpdate = date(Db::TIMESTAMP_FORMAT);
if(!isset($asData['response']['errors']) || empty($asData['response']['errors'])) {
$asMsgs = $asData['response']['feedMessageResponse']['messages'];
$asFeed = $asData['response']['feedMessageResponse']['feed'];
//Fix unstable Spot API Structure
if(array_key_exists('message', $asMsgs)) $asMsgs = $asMsgs['message']; //Sometimes adds an extra "message" level
if(!array_key_exists(0, $asMsgs)) $asMsgs = array($asMsgs); //Jumps a level when there is only 1 message
//Update Spot, Feed & Messages
if(!empty($asMsgs) && array_key_exists('messengerId', $asMsgs[0])) {
//Update Spot Info from the first message
$asSpotInfo = array(
'ref_spot_id' => $asMsgs[0]['messengerId'],
'name' => $asMsgs[0]['messengerName'],
'model' => $asMsgs[0]['modelId']
);
$iSpotId = $this->oDb->insertUpdateRow(self::SPOT_TABLE, $asSpotInfo, array('ref_spot_id'));
//Update Feed Info and last update date
$asFeedInfo = array(
'ref_feed_id' => $asFeed['id'],
Db::getId(self::SPOT_TABLE) => $iSpotId,
'name' => $asFeed['name'],
'description' => $asFeed['description'],
'status' => $asFeed['status'],
'last_update' => $sLastUpdate
);
$iFeedId = $this->oDb->insertUpdateRow(self::FEED_TABLE, $asFeedInfo, array('ref_feed_id'));
//Update Messages
foreach($asMsgs as $asMsg) {
$asMsg = array(
'ref_msg_id' => $asMsg['id'],
Db::getId(self::FEED_TABLE) => $iFeedId,
'type' => $asMsg['messageType'],
'latitude' => $asMsg['latitude'],
'longitude' => $asMsg['longitude'],
'iso_time' => $asMsg['dateTime'], //ISO 8601 time (backup)
'site_time' => date(Db::TIMESTAMP_FORMAT, $asMsg['unixTime']), //Conversion to Site Time (default timezone, see Settings::TIMEZONE)
'unix_time' => $asMsg['unixTime'], //UNIX Time (backup)
'content' => $asMsg['messageContent'],
'battery_state' => $asMsg['batteryState']
);
$iMsgId = $this->oDb->selectId(self::MSG_TABLE, array('ref_msg_id'=>$asMsg['ref_msg_id']));
if(!$iMsgId) {
$this->oDb->insertRow(self::MSG_TABLE, $asMsg);
$bNewMsg = true;
}
else $this->oDb->updateRow(self::MSG_TABLE, $iMsgId, $asMsg);
}
}
}
else $this->oDb->updateRow(self::FEED_TABLE, $this->getFeedId(), array('last_update'=>$sLastUpdate));
return $bNewMsg;
}
private function retrieveFeed() {
$sContent = '[]';
if($this->sRefFeedId !='') {
$sUrl = self::FEED_HOOK.$this->sRefFeedId.self::FEED_TYPE_JSON;
$sContent = file_get_contents($sUrl);
}
return json_decode($sContent, true);
}
private function updateField($sField, $oValue) {
$bResult = ($this->oDb->updateRow(self::FEED_TABLE, $this->getFeedId(), array($sField=>$oValue)) > 0);
$this->setFeedId($this->getFeedId());
return $bResult;
}
public function delete() {
$sDesc = '';
if($this->getFeedId() > 0) {
$bSuccess = $this->oDb->deleteRow(self::FEED_TABLE, $this->getFeedId());
if(!$bSuccess) $sDesc = $this->oDb->getLastError();
}
else $sDesc = 'Error while setting project: no Feed ID';
return $sDesc;
}
}
+266
View File
@@ -0,0 +1,266 @@
<?php
class Media extends PhpObject {
//DB Tables
const MEDIA_TABLE = 'medias';
//Media folders
const MEDIA_FOLDER = 'files/';
const THUMB_FOLDER = self::MEDIA_FOLDER.'thumbs/';
const THUMB_MAX_WIDTH = 400;
/**
* Database Handle
* @var Db
*/
private $oDb;
/**
* Media Project
* @var Project
*/
private $oProject;
private $asMedia;
private $asMedias;
private $sSystemType;
private $iMediaId;
public function __construct(Db &$oDb, &$oProject, $iMediaId=0) {
parent::__construct(__CLASS__, Settings::DEBUG);
$this->oDb = &$oDb;
$this->oProject = &$oProject;
$this->asMedia = array();
$this->asMedias = array();
$this->sSystemType = (substr(php_uname(), 0, 7) == "Windows")?'win':'unix';
$this->setMediaId($iMediaId);
}
public function setMediaId($iMediaId) {
$this->iMediaId = $iMediaId;
}
public function getMediaId() {
return $this->iMediaId;
}
public function getProjectCodeName() {
return $this->oProject->getProjectCodeName();
}
public function setComment($sComment) {
$sError = '';
$asData = array();
if($this->iMediaId > 0) {
$bResult = $this->oDb->updateRow(self::MEDIA_TABLE, $this->iMediaId, array('comment'=>$sComment));
if(!$bResult) $sError = 'error_commit_db';
else $asData = $this->getInfo();
}
else $sError = 'media_no_id';
return Spot::getResult(($sError==''), $sError, $asData);
}
public function getMediasInfo($iMediaId=0) {
$bOwnMedia = ($iMediaId > 0);
if($bOwnMedia && empty($this->asMedia) || !$bOwnMedia && empty($this->asMedias)) {
if($this->oProject->getProjectId()) {
$asParams = array(
'select' => array(Db::getId(self::MEDIA_TABLE), 'filename', 'taken_on', 'posted_on', 'rotate', 'type AS subtype', 'comment'),
'from' => self::MEDIA_TABLE,
'constraint'=> array(Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId())
);
if($bOwnMedia) $asParams['constraint'][Db::getId(self::MEDIA_TABLE)] = $iMediaId;
$asMedias = $this->oDb->selectRows($asParams);
foreach($asMedias as &$asMedia) {
$asMedia['media_path'] = self::getMediaPath($asMedia['filename']);
$asMedia['thumb_path'] = $this->getMediaThumbnail($asMedia['filename']);
}
if(!empty($asMedias)) {
if($bOwnMedia) $this->asMedia = array_shift($asMedias);
else $this->asMedias = $asMedias;
}
}
}
return $bOwnMedia?$this->asMedia:$this->asMedias;
}
public function getInfo() {
return $this->getMediasInfo($this->iMediaId);
}
public function isProjectModeValid() {
return ($this->oProject->getMode() == Project::MODE_BLOG);
}
public function addMedia($sMediaName, $sMethod='upload') {
$sError = '';
$asParams = array();
if(!$this->isProjectModeValid() && $sMethod!='sync') {
$sError = 'upload_wrong_mode';
$asParams[] = $this->oProject->getProjectCodeName();
}
elseif($this->oDb->pingValue(self::MEDIA_TABLE, array('filename'=>$sMediaName)) && $sMethod!='sync') {
$sError = 'upload_media_exist';
$asParams[] = $sMediaName;
}
else {
//Add media to DB
$asMediaInfo = $this->getMediaInfoFromFile($sMediaName);
$asDbInfo = array(
Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId(),
'filename' => $sMediaName,
'taken_on' => ($asMediaInfo['taken_ts'] > 0)?date(Db::TIMESTAMP_FORMAT, $asMediaInfo['taken_ts']):0, //Site Time
'posted_on' => date(Db::TIMESTAMP_FORMAT, $asMediaInfo['file_ts']), //Site Time
'rotate' => $asMediaInfo['rotate'],
'type' => $asMediaInfo['type']
);
if($sMethod=='sync') $iMediaId = $this->oDb->insertUpdateRow(self::MEDIA_TABLE, $asDbInfo, array(Db::getId(Project::PROJ_TABLE), 'filename'));
else $iMediaId = $this->oDb->insertRow(self::MEDIA_TABLE, $asDbInfo);
if(!$iMediaId) $sError = 'error_commit_db';
else {
$this->setMediaId($iMediaId);
$asParams = $this->getInfo(); //Creates thumbnail
}
}
return Spot::getResult(($sError==''), $sError, $asParams);
}
/**
* One-shot function to initialize DB with existing images
*/
public function syncFileFolder() {
$asMediaPaths = glob(self::getMediaPath('*.{jpg,JPG,jpeg,JPEG,png,PNG,mov,MOV}'), GLOB_BRACE);
foreach($asMediaPaths as $sMediaPath)
{
$sMediaName = pathinfo($sMediaPath, PATHINFO_BASENAME);
$this->addMedia($sMediaName, 'sync');
}
$this->setExtractMode(PhpObject::MODE_HTML);
return $this->getCleanMessageStack();
}
private function getMediaInfoFromFile($sMediaName)
{
$sMediaPath = self::getMediaPath($sMediaName);
$sType = self::getMediaType($sMediaName);
$iTimeStamp = $iTakenOn = $iPostedOn = 0;
$sRotate = '0';
$sTakenOn = '';
switch($sType) {
case 'video':
$asResult = array();
$sParams = implode(' ', array(
'-loglevel error', //Remove comments
'-select_streams v:0', //First video channel
'-show_entries stream_tags=rotate,creation_time', //filter tags :rotation & creation time only
'-print_format json', //output format: json
'-i' //input file
));
exec('ffprobe '.$sParams.' "'.$sMediaPath.'"', $asResult);
$asResult = json_decode(implode('', $asResult), true);
//Timestamps
$sTakenOn = date(Db::TIMESTAMP_FORMAT, strtotime($asResult['streams'][0]['tags']['creation_time']));
$iPostedOn = filemtime($sMediaPath);
//Orientation
if(isset($asResult['streams'][0]['tags']['rotate'])) $sRotate = $asResult['streams'][0]['tags']['rotate'];
break;
case 'image':
$asExif = @exif_read_data($sMediaPath, 0, true);
if(!$asExif) $asExif['FILE']['FileDateTime'] = filemtime($sMediaPath);
//Timestamps
if(array_key_exists('EXIF', $asExif) && array_key_exists('DateTimeOriginal', $asExif['EXIF'])) $sTakenOn = $asExif['EXIF']['DateTimeOriginal'];
if(array_key_exists('FILE', $asExif) && array_key_exists('FileDateTime', $asExif['FILE'])) $iPostedOn = $asExif['FILE']['FileDateTime'];
//Orientation
if(array_key_exists('IFD0', $asExif) && array_key_exists('Orientation', $asExif['IFD0'])) {
switch($asExif['IFD0']['Orientation'])
{
case 1: $sRotate = '0'; break; //None
case 3: $sRotate = '180'; break; //Flip over
case 6: $sRotate = '90'; break; //Clockwise
case 8: $sRotate = '-90'; break; //Trigo
}
}
break;
}
//Media info do not have any TZ: Interpreting date time using project timezone (assuming all medias have been taken in this time zone)
if($sTakenOn != '') {
$oTakenOn = new DateTime($sTakenOn, new DateTimeZone($this->oProject->getTimeZone()));
$iTakenOn = $oTakenOn->format('U');
}
//Merge timestamps
$iTimeStamp = ($iTakenOn > 0)?$iTakenOn:$iPostedOn;
return array(
'timestamp' => $iTimeStamp,
'taken_ts' => $iTakenOn,
'file_ts' => $iPostedOn,
'rotate' => $sRotate,
'type' => $sType
);
}
private function getMediaThumbnail($sMediaName)
{
$sMediaPath = self::getMediaPath($sMediaName);
$sThumbPath = self::getMediaPath($sMediaName, 'thumbnail');
if(!file_exists($sThumbPath)) {
$sType = self::getMediaType($sMediaName);
switch($sType) {
case 'image':
$asThumbInfo = ToolBox::createThumbnail($sMediaPath, self::THUMB_MAX_WIDTH, 0, $sThumbPath);
break;
case 'video':
//Get a screenshot of the video 1 second in
$sTempPath = self::getMediaPath(uniqid('temp_').'.png');
$asResult = array();
$sParams = implode(' ', array(
'-i "'.$sMediaPath.'"', //input file
'-ss 00:00:01.000', //Image taken after x seconds
'-vframes 1', //number of video frames to output
'"'.$sTempPath.'"', //output file
));
exec('ffmpeg '.$sParams, $asResult);
//Resize
$asThumbInfo = ToolBox::createThumbnail($sTempPath, self::THUMB_MAX_WIDTH, 0, $sThumbPath, true);
break;
}
}
else $asThumbInfo = array('error'=>'', 'out'=>$sThumbPath);
return ($asThumbInfo['error']=='')?$asThumbInfo['out']:$sMediaPath;
}
private static function getMediaPath($sMediaName, $sFileType='media') {
if($sFileType=='thumbnail') return self::THUMB_FOLDER.$sMediaName.(strtolower(substr($sMediaName, -3))=='mov'?'.png':'');
else return self::MEDIA_FOLDER.$sMediaName;
}
private static function getMediaType($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sMediaMime = mime_content_type($sMediaPath);
switch($sMediaMime) {
case 'video/quicktime': $sType = 'video'; break;
default: $sType = 'image'; break;
}
return $sType;
}
}
+211 -234
View File
@@ -1,234 +1,211 @@
<?php <?php
namespace Franzz\Livetrail; class Project extends PhpObject {
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db; //Spot Mode
const MODE_PREVIZ = 'P';
class Project extends PhpObject { const MODE_BLOG = 'B';
const MODE_HISTO = 'H';
//Spot Mode const MODES = array('previz'=>self::MODE_PREVIZ, 'blog'=>self::MODE_BLOG, 'histo'=>self::MODE_HISTO);
public const MODE_PREVIZ = 'P';
public const MODE_BLOG = 'B'; //DB Tables
public const MODE_HISTO = 'H'; const PROJ_TABLE = 'projects';
public const MODES = ['previz'=>self::MODE_PREVIZ, 'blog'=>self::MODE_BLOG, 'histo'=>self::MODE_HISTO];
/**
//DB Tables * Database Handle
public const PROJ_TABLE = 'projects'; * @var Db
*/
/** private $oDb;
* Database Handle
* @var Db private $iProjectId;
*/ private $sMode;
private $oDb; private $sName;
private $sCodeName;
private $iProjectId; private $asActive;
private $sName; private $asGeo;
private $sCodeName;
private $sMode; public function __construct(Db &$oDb, $iProjectId=0) {
private $asActive; parent::__construct(__CLASS__, Settings::DEBUG);
$this->oDb = &$oDb;
public function __construct(Db &$oDb, $iProjectId=0) { if($iProjectId > 0) $this->setProjectId($iProjectId);
parent::__construct(__CLASS__); }
$this->oDb = &$oDb;
if($iProjectId > 0) $this->setProjectId($iProjectId); public function getProjectId() {
} return $this->iProjectId;
}
public function getProjectId() {
return $this->iProjectId; public function setProjectId($iProjectId=0) {
} if($iProjectId > 0) {
$this->iProjectId = $iProjectId;
public function setProjectId($iProjectId=0) { }
if($iProjectId > 0) { else {
$this->iProjectId = $iProjectId; /**
} * Project 1 [-----------------]
else { * Project 2 [---------------------------]
/** * Project 3 [-----------]
* Project 1 [-----------------] * Selected Project [-------Project 1-------][------------Project 2-------------][---------------Project 3------------------
* Project 2 [---------------------------] * Mode --P--][--------B--------][--P--][-----------B---------------][---P---][-----B-----][---------H----------
* Project 3 [-----------] */
* Selected Project [-------Project 1-------][------------Project 2-------------][---------------Project 3------------------ $sQuery =
* Mode --P--][--------B--------][--P--][-----------B---------------][---P---][-----B-----][---------H---------- "SELECT MAX(id_project) ".
*/ "FROM projects ".
$sQuery = "WHERE active_to = (".
'SELECT MAX(id_project) '. "SELECT MIN(active_to) ". //Select closest project in the future
'FROM projects '. "FROM projects ".
'WHERE active_to = ('. "WHERE active_to > NOW() ". //Select Next project
'SELECT MIN(active_to) '. //Select closest project in the future "OR active_to = (". //In case there is no next project, select the last one
'FROM projects '. "SELECT MAX(active_to) ".
'WHERE active_to > NOW() '. //Select Next project "FROM projects".
'OR active_to = ('. //In case there is no next project, select the last one ")".
'SELECT MAX(active_to) '. ")";
'FROM projects'. $asResult = $this->oDb->getArrayQuery($sQuery, true);
')'. $this->iProjectId = array_shift($asResult);
')'; }
$asResult = $this->oDb->getArrayQuery($sQuery, true);
$this->iProjectId = array_shift($asResult); $this->setProjectInfo();
} }
$this->setProjectInfo(); public function createProjectId() {
} $this->setProjectId($this->oDb->insertRow(self::PROJ_TABLE, array('timezone'=>Settings::TIMEZONE)));
return $this->getProjectId();
public function createProjectId() { }
$this->setProjectId($this->oDb->insertRow(self::PROJ_TABLE, ['codename'=>'']));
return $this->getProjectId(); public function getMode() {
} return $this->sMode;
}
public function getMode() {
return $this->sMode; public function getProjectName() {
} return $this->sName;
}
public function getProjectName() {
return $this->sName; public function setProjectName($sName) {
} return $this->updateField('name', $sName);
}
public function setProjectName($sName) {
return $this->updateField('name', $sName); public function getProjectCodeName() {
} return $this->sCodeName;
}
public function getProjectCodeName() {
return $this->sCodeName; public function setProjectCodeName($sCodeName) {
} return $this->updateField('codename', $sCodeName);
}
public function setProjectCodeName($sCodeName) {
return $this->updateField('codename', $sCodeName); public function getActivePeriod($sFromTo='') {
} return ($sFromTo=='')?$this->asActive:$this->asActive[$sFromTo];
}
public function getActivePeriod($sFromTo='') {
return ($sFromTo=='')?$this->asActive:$this->asActive[$sFromTo]; public function setActivePeriod($oValue, $sFromTo='') {
} if($sFromTo=='') {
$this->updateField('active_from', $oValue['from']);
public function setActivePeriod($oValue, $sFromTo='') { return $this->updateField('active_to', $oValue['to']);
if($sFromTo=='') { }
$this->updateField('active_from', $oValue['from']); else {
return $this->updateField('active_to', $oValue['to']); return $this->updateField('active_'.$sFromTo, $oValue);
} }
else { }
return $this->updateField('active_'.$sFromTo, $oValue);
} public function getTimeZone() {
} return $this->asGeo['timezone'];
}
public function getFeedIds() {
return $this->oDb->selectColumn( public function setTimeZone($sTimeZone) {
Feed::FEED_TABLE, return $this->updateField('timezone', $sTimeZone);
Db::getId(Feed::FEED_TABLE), }
[Db::getId(self::PROJ_TABLE) => $this->getProjectId()]
); public function getFeedIds() {
} return $this->oDb->selectColumn(
Feed::FEED_TABLE,
public function getProjects($iProjectId=0) { Db::getId(Feed::FEED_TABLE),
$bSpecificProj = ($iProjectId > 0); array(Db::getId(self::PROJ_TABLE) => $this->getProjectId())
$sDefaultProjectCodeName = $this->getProjectCodeName(); );
$asInfo = [ }
'select'=> [
Db::getId(self::PROJ_TABLE).' AS id', public function getProjects($iProjectId=0) {
'codename', $bSpecificProj = ($iProjectId > 0);
'name', $asInfo = array(
'latitude', 'select'=> array(
'longitude', Db::getId(self::PROJ_TABLE)." AS id",
'active_from', 'codename',
'active_to', 'name',
'IF(NOW() BETWEEN active_from AND active_to, 1, IF(NOW() < active_from, 0, 2)) AS mode' 'active_from',
], 'active_to',
'from' => self::PROJ_TABLE, "IF(NOW() BETWEEN active_from AND active_to, 1, IF(NOW() < active_from, 0, 2)) AS mode",
'orderBy' => ['active_from' => 'ASC'] 'timezone'
]; ),
if($bSpecificProj) $asInfo['constraint'] = [Db::getId(self::PROJ_TABLE)=>$iProjectId]; 'from' => self::PROJ_TABLE
);
$asProjects = $this->oDb->selectRows($asInfo, 'codename'); if($bSpecificProj) $asInfo['constraint'] = array(Db::getId(self::PROJ_TABLE)=>$iProjectId);
foreach($asProjects as $sCodeName => &$asProject) {
//Update geoJSON $asProjects = $this->oDb->selectRows($asInfo, 'codename');
if(Converter::hasGpxFile($sCodeName) && (!Converter::isGeoJsonValid($sCodeName) || !$asProject['latitude'] && !$asProject['longitude'])) { foreach($asProjects as $sCodeName=>&$asProject) {
$aiCenter = Converter::convertToGeoJson($sCodeName)['center']; switch($asProject['mode']) {
$this->oDb->updateRow(self::PROJ_TABLE, $asProject['id'], ['latitude' => $aiCenter[1], 'longitude' => $aiCenter[0]]); case 0: $asProject['mode'] = self::MODE_PREVIZ; break;
$asProject['latitude'] = $aiCenter[1]; case 1: $asProject['mode'] = self::MODE_BLOG; break;
$asProject['longitude'] = $aiCenter[0]; case 2: $asProject['mode'] = self::MODE_HISTO; break;
} }
switch($asProject['mode']) { if($sCodeName!= '' && !Converter::isGeoJsonValid($sCodeName)) Converter::convertToGeoJson($sCodeName);
case 0: $asProject['mode'] = self::MODE_PREVIZ; break;
case 1: $asProject['mode'] = self::MODE_BLOG; break; $asProject['geofilepath'] = Spot::addTimestampToFilePath(Geo::getFilePath($sCodeName, GeoJson::EXT));
case 2: $asProject['mode'] = self::MODE_HISTO; break; $asProject['gpxfilepath'] = Spot::addTimestampToFilePath(Geo::getFilePath($sCodeName, Gpx::EXT));
} $asProject['codename'] = $sCodeName;
$asProject['editable'] = $this->isModeEditable($asProject['mode']); }
$asProject['gpxfilepath'] = Livetrail::addTimestampToFilePath(Gpx::getFrontendFilePath($sCodeName)); return $bSpecificProj?$asProject:$asProjects;
$asProject['geofilepath'] = Livetrail::addTimestampToFilePath(GeoJson::getFrontendFilePath($sCodeName)); }
$asProject['codename'] = $sCodeName;
$asProject['default'] = ($sCodeName == $sDefaultProjectCodeName); public function getProject() {
} return $this->getProjects($this->getProjectId());
return $bSpecificProj?$asProject:$asProjects; }
}
public function getLastUpdate(): int {
public function getProject() { $iLastUpdate = INF;
return $this->getProjects($this->getProjectId());
} $asFeedIds = $this->getFeedIds();
foreach($asFeedIds as $iFeedId) {
public function getLastUpdate(): int { $iLastUpdate = min($iLastUpdate, (new Feed($this->oDb, $iFeedId))->getLastUpdate());
$iLastUpdate = INF; }
$asFeedIds = $this->getFeedIds(); return $iLastUpdate;
foreach($asFeedIds as $iFeedId) { }
$iLastUpdate = min($iLastUpdate, (new Feed($this->oDb, $iFeedId))->getLastUpdate());
} public function getMaps() {
$sQuery =
return $iLastUpdate; "SELECT codename, geo_name, min_zoom, max_zoom, attribution ".
} "FROM ".Spot::MAPPING_TABLE." ".
"LEFT JOIN ".Spot::MAP_TABLE." USING(".Db::getId(Spot::MAP_TABLE).") ".
public function getLastMessageId($asConstraints=[]): int { "WHERE ".Db::getId(self::PROJ_TABLE)." = ".$this->getProjectId()." ".
$iLastMsg = 0; "OR ".Db::getId(self::PROJ_TABLE)." IS NULL ";
return $this->oDb->getArrayQuery($sQuery, true);
$asFeedIds = $this->getFeedIds(); }
foreach($asFeedIds as $iFeedId) {
$iLastMsg = max($iLastMsg, (new Feed($this->oDb, $iFeedId))->getLastMessageId($asConstraints)); private function setProjectInfo() {
} if($this->getProjectId() > 0) {
$asProject = $this->getProject();
return $iLastMsg;
} $this->sMode = $asProject['mode'];
$this->asActive = array('from'=>$asProject['active_from'], 'to'=>$asProject['active_to']);
private function setProjectInfo() { $this->sCodeName = $asProject['codename'];
if($this->getProjectId() > 0) { $this->sName = $asProject['name'];
$asProject = $this->getProject(); $this->asGeo = array('geofile'=>$asProject['geofilepath'], 'gpxfile'=>$asProject['gpxfilepath'], 'timezone'=>$asProject['timezone']);
}
$this->sName = $asProject['name']; else $this->addError('Error while setting project: no project ID');
$this->sCodeName = $asProject['codename']; }
$this->sMode = $asProject['mode'];
$this->asActive = ['from'=>$asProject['active_from'], 'to'=>$asProject['active_to']]; private function updateField($sField, $oValue) {
} $bResult = ($this->oDb->updateRow(self::PROJ_TABLE, $this->getProjectId(), array($sField=>$oValue)) > 0);
else $this->addError('Error while setting project: no project ID'); $this->setProjectInfo();
}
return $bResult;
private function updateField($sField, $oValue) { }
$bResult = ($this->oDb->updateRow(self::PROJ_TABLE, $this->getProjectId(), [$sField=>$oValue]) > 0);
$this->setProjectInfo(); public function delete() {
$sDesc = '';
return $bResult; if($this->getProjectId() > 0) {
} $bSuccess = $this->oDb->deleteRow(self::PROJ_TABLE, $this->getProjectId());
if(!$bSuccess) $sDesc = $this->oDb->getLastError();
public function delete() { }
$asResult = []; else $sDesc = 'Error while setting project: no project ID';
if($this->getProjectId() > 0) {
$asFeedIds = $this->getFeedIds(); return $sDesc;
foreach($asFeedIds as $iFeedId) { }
$asResult['feed'][] = (new Feed($this->oDb, $iFeedId))->delete(); }
}
$bDeleted = $this->oDb->deleteRow(self::PROJ_TABLE, $this->getProjectId());
$asResult['project'][] = [
'id' => $this->getProjectId(),
'del' => $bDeleted,
'desc_lang_id' => $bDeleted?'':'error.commit_db',
'desc_lang_params' => []
];
}
else $asResult['project'][] = ['del'=>false, 'desc_lang_id'=>'error.impossible_value', 'desc_lang_params'=>[$this->getProjectId(), 'project ID']];
return $asResult;
}
public function isEditable() {
return self::isModeEditable($this->getMode());
}
public static function isModeEditable($sMode) {
return ($sMode != self::MODE_HISTO);
}
}
Executable
+591
View File
@@ -0,0 +1,591 @@
<?php
/* Timezones
* - Feeds (table `feeds`):
* - last_update: timestamp in site time (see Settings::TIMEZONE)
* - Spot Messages (table `messages`):
* - unix_time: UNIX (int) in UTC
* - site_time: timestamp in site time (see Settings::TIMEZONE)
* - iso_time: raw ISO 8601 in local timezone
* - Medias (table `medias`):
* - posted_on: timestamp in site time (see Settings::TIMEZONE)
* - taken_on: timestamp in site time (see Settings::TIMEZONE)
* - Posts (table `posts`):
* - site_time: timestamp in site time (see Settings::TIMEZONE)
*/
class Spot extends Main
{
//Database
const POST_TABLE = 'posts';
const MAP_TABLE = 'maps';
const MAPPING_TABLE = 'mappings';
const FEED_CHUNK_SIZE = 15;
const MAIL_CHUNK_SIZE = 5;
const DEFAULT_LANG = 'en';
/**
* Active Project
* @var Project
*/
private $oProject;
/**
* Media Class
* @var Media
*/
private $oMedia;
/**
* User
* @var User
*/
private $oUser;
public function __construct($oClassManagement, $sProcessPage, $sTimezone)
{
$asClasses = array(
array('name'=>'feed', 'project'=>true),
array('name'=>'project', 'project'=>true),
array('name'=>'media', 'project'=>true),
array('name'=>'converter', 'project'=>true),
array('name'=>'user', 'project'=>true),
array('name'=>'email', 'project'=>true)
);
parent::__construct($oClassManagement, $sProcessPage, $asClasses, true, __FILE__, $sTimezone);
$this->oUser = new User($this->oDb);
$this->oClassManagement->incClass('translator');
$this->oLang = new Translator('', self::DEFAULT_LANG);
$this->oProject = new Project($this->oDb);
$this->oMedia = new Media($this->oDb, $this->oProject);
}
protected function install()
{
//Install DB
$this->oDb->install();
}
protected function getSqlOptions()
{
return array
(
'tables' => array
(
Feed::MSG_TABLE => array('ref_msg_id', Db::getId(Feed::FEED_TABLE), 'type', 'latitude', 'longitude', 'iso_time', 'site_time', 'unix_time', 'content', 'battery_state'),
Feed::FEED_TABLE => array('ref_feed_id', Db::getId(Feed::SPOT_TABLE), Db::getId(Project::PROJ_TABLE), 'name', 'description', 'status', 'last_update'),
Feed::SPOT_TABLE => array('ref_spot_id', 'name', 'model'),
Project::PROJ_TABLE => array('name', 'codename', 'active_from', 'active_to', 'timezone'),
self::POST_TABLE => array(Db::getId(Project::PROJ_TABLE), Db::getId(User::USER_TABLE), 'name', 'content', 'site_time'),
Media::MEDIA_TABLE => array(Db::getId(Project::PROJ_TABLE), 'filename', 'type', 'taken_on', 'posted_on', 'rotate', 'comment'),
User::USER_TABLE => array('name', 'email', 'language', 'timezone', 'active'),
self::MAP_TABLE => array('codename', 'geo_name', 'min_zoom', 'max_zoom', 'attribution'),
self::MAPPING_TABLE => array(Db::getId(self::MAP_TABLE) , Db::getId(Project::PROJ_TABLE))
),
'types' => array
(
'active' => "BOOLEAN",
'active_from' => "TIMESTAMP DEFAULT 0",
'active_to' => "TIMESTAMP DEFAULT 0",
'battery_state' => "VARCHAR(10)",
'codename' => "VARCHAR(100)",
'content' => "LONGTEXT",
'comment' => "LONGTEXT",
'description' => "VARCHAR(100)",
'email' => "VARCHAR(320) NOT NULL",
'filename' => "VARCHAR(100) NOT NULL",
'iso_time' => "VARCHAR(24)",
'language' => "VARCHAR(2)",
'last_update' => "TIMESTAMP DEFAULT 0",
'latitude' => "DECIMAL(7,5)",
'longitude' => "DECIMAL(8,5)",
'model' => "VARCHAR(20)",
'name' => "VARCHAR(100)",
'posted_on' => "TIMESTAMP DEFAULT 0",
'ref_feed_id' => "VARCHAR(40)",
'ref_msg_id' => "INT",
'ref_spot_id' => "VARCHAR(10)",
'rotate' => "SMALLINT",
'site_time' => "TIMESTAMP DEFAULT 0", //DEFAULT 0 removes auto-set to current time
'status' => "VARCHAR(10)",
'taken_on' => "TIMESTAMP DEFAULT 0",
'timezone' => "CHAR(64)", //see mysql.time_zone_name
'type' => "VARCHAR(20)",
'unix_time' => "INT",
'geo_name' => "VARCHAR(100)",
'min_zoom' => "TINYINT UNSIGNED",
'max_zoom' => "TINYINT UNSIGNED",
'attribution' => "VARCHAR(100)"
),
'constraints' => array
(
Feed::MSG_TABLE => array("UNIQUE KEY `uni_ref_msg_id` (`ref_msg_id`)", "INDEX(`ref_msg_id`)"),
Feed::FEED_TABLE => array("UNIQUE KEY `uni_ref_feed_id` (`ref_feed_id`)", "INDEX(`ref_feed_id`)"),
Feed::SPOT_TABLE => array("UNIQUE KEY `uni_ref_spot_id` (`ref_spot_id`)", "INDEX(`ref_spot_id`)"),
Project::PROJ_TABLE => "UNIQUE KEY `uni_proj_name` (`codename`)",
Media::MEDIA_TABLE => "UNIQUE KEY `uni_file_name` (`filename`)",
User::USER_TABLE => "UNIQUE KEY `uni_email` (`email`)"
),
'cascading_delete' => array
(
Feed::SPOT_TABLE => array(Feed::MSG_TABLE)
)
);
}
public function getMainPage($asGlobalVars = array(), $sMainPage = 'index', $asMainPageTags=array())
{
return parent::getMainPage(
array(
'vars' => array(
'chunk_size' => self::FEED_CHUNK_SIZE,
'default_project_codename' => $this->oProject->getProjectCodeName(),
'projects' => $this->oProject->getProjects(),
'user' => $this->oUser->getUserInfo()
),
'consts' => array(
'server' => $this->asContext['serv_name'],
'geo_server' => Settings::GEO_SERVER,
'modes' => Project::MODES,
'default_timezone' => Settings::TIMEZONE
)
),
$sMainPage,
array(
'host_url' => $this->asContext['serv_name'],
'filepath_css' => self::addTimestampToFilePath('style/spot.css'),
'filepath_js_d3' => self::addTimestampToFilePath('script/d3.min.js'),
'filepath_js_leaflet' => self::addTimestampToFilePath('script/leaflet.min.js'),
'filepath_js_jquery' => self::addTimestampToFilePath('script/jquery.min.js'),
'filepath_js_jquery_mods' => self::addTimestampToFilePath('script/jquery.mods.js'),
'filepath_js_spot' => self::addTimestampToFilePath('script/spot.js'),
'filepath_js_lightbox' => self::addTimestampToFilePath('script/lightbox.js')
)
);
}
/* Managing projects */
public function setProjectId($iProjectId=0) {
$this->oProject->setProjectId($iProjectId);
}
public function updateProject() {
$bNewMsg = false;
//Update all feeds belonging to the project
$asFeeds = $this->oProject->getFeedIds();
foreach($asFeeds as $iFeedId) {
$oFeed = new Feed($this->oDb, $iFeedId);
$bNewMsg = $bNewMsg || $oFeed->checkUpdateFeed($this->oProject->getMode());
}
//Send Update Email
if($bNewMsg) {
$oEmail = new Email($this->asContext['serv_name'], 'email_update');
$oEmail->setDestInfo($this->oUser->getActiveUsersInfo());
//Add Position
$asMessages = $this->getSpotMessages();
$asLastMessage = end($asMessages);
$asLastMessage['token'] = Settings::GEO_SERVER_TOKEN;
$oEmail->oTemplate->setTags($asLastMessage);
$oEmail->oTemplate->setTag('date_time', 'time:'.$asLastMessage['unix_time'], 'd/m/Y, H:i');
//Add latest news feed
$asNews = $this->getNewsFeed(0, true);
$iPostCount = 0;
foreach($asNews as $asPost) {
if($asPost['type'] != 'message') {
$oEmail->oTemplate->newInstance('news');
$oEmail->oTemplate->setInstanceTags('news', array(
'local_server' => $this->asContext['serv_name'],
'project' => $this->oProject->getProjectCodeName(),
'type' => $asPost['type'],
'id' => $asPost['id_'.$asPost['type']])
);
$oEmail->oTemplate->addInstance($asPost['type'], $asPost);
$oEmail->oTemplate->setInstanceTag($asPost['type'], 'local_server', $this->asContext['serv_name']);
$iPostCount++;
}
if($iPostCount == self::MAIL_CHUNK_SIZE) break;
}
$oEmail->send();
}
}
public function genCronFile() {
//$bSuccess = (file_put_contents('spot_cron.sh', '#!/bin/bash'."\n".'cd '.dirname($_SERVER['SCRIPT_FILENAME'])."\n".'php -f index.php a=update_feed')!==false);
$sFileName = 'spot_cron.sh';
$sContent =
'#!/bin/bash'."\n".
'wget -qO- '.$this->asContext['serv_name'].'index.php?a=update_project > /dev/null'."\n".
'#Crontab job: 0 * * * * . '.dirname($_SERVER['SCRIPT_FILENAME']).'/'.$sFileName.' > /dev/null'."\n";
$bSuccess = (file_put_contents($sFileName, $sContent)!==false);
return self::getJsonResult($bSuccess, '');
}
public function getMarkers()
{
$asMessages = $this->getSpotMessages();
$bSuccess = !empty($this->getMedias('posted_on') + $asMessages + $this->getPosts());
$sDesc = $bSuccess?'':self::NO_DATA;
//Add medias
if($bSuccess) {
$asMedias = $this->getMedias('taken_on');
//Assign medias to closest message
$iIndex = 0;
$iMaxIndex = count($asMessages) - 1;
foreach($asMedias as $asMedia) {
while($iIndex <= $iMaxIndex && $asMedia['unix_time'] > $asMessages[$iIndex]['unix_time']) {
$iIndex++;
}
if($iIndex == 0) $iMsgIndex = $iIndex;
elseif($iIndex > $iMaxIndex) $iMsgIndex = $iMaxIndex;
else {
$iHalfWayPoint = ($asMessages[$iIndex - 1]['unix_time'] + $asMessages[$iIndex]['unix_time'])/2;
$iMsgIndex = ($asMedia['unix_time'] >= $iHalfWayPoint)?$iIndex:($iIndex - 1);
}
$asMessages[$iMsgIndex]['medias'][] = $asMedia;
}
}
//Add Project Last Update
$asLastUpdate = array();
$this->addTimeStamp($asLastUpdate, $this->oProject->getLastUpdate());
return self::getJsonResult($bSuccess, $sDesc, array(
'messages' => $asMessages,
'maps' => $this->oProject->getMaps(),
'last_update' => $asLastUpdate));
}
public function subscribe($sEmail) {
$asResult = $this->oUser->addUser($sEmail, $this->oLang->getLanguage(), date_default_timezone_get());
$asUserInfo = $this->oUser->getUserInfo();
//Send Confirmation Email
if($asResult['result'] && $asResult['desc']=='lang:nl_subscribed') {
$oConfEmail = new Email($this->asContext['serv_name'], 'email_conf');
$oConfEmail->setDestInfo($asUserInfo);
$oConfEmail->send();
}
return self::getJsonResult($asResult['result'], $asResult['desc'], $asUserInfo);
}
public function unsubscribe() {
$asResult = $this->oUser->removeUser();
return self::getJsonResult($asResult['result'], $asResult['desc'], $asResult['data']);
}
public function unsubscribeFromEmail($iUserId) {
$this->oUser->setUserId($iUserId);
$this->oLang->setLanguage($this->oUser->getLang(), self::DEFAULT_LANG);
$asResult = $this->oUser->removeUser();
$sDesc = $asResult['desc'];
if($sDesc=='') $sDesc = $this->oLang->getTranslation('nl_unsubscribed');
return $sDesc;
}
private function getSpotMessages()
{
$asMessages = array();
//Get messages from all feeds belonging to the project
$asFeeds = $this->oProject->getFeedIds();
foreach($asFeeds as $iFeedId) {
$oFeed = new Feed($this->oDb, $iFeedId);
$asMessages = $oFeed->getMessages($this->oProject->getActivePeriod());
foreach($asMessages as &$asMessage)
{
$asMessage['latitude'] = floatval($asMessage['latitude']);
$asMessage['longitude'] = floatval($asMessage['longitude']);
$asMessage['lat_dms'] = self::decToDms($asMessage['latitude'], 'lat');
$asMessage['lon_dms'] = self::decToDms($asMessage['longitude'], 'lon');
$this->addTimeStamp($asMessage, $asMessage['unix_time']);
}
}
//Sort chronologically
usort($asMessages, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
//Add Display ID
$asSortedMessages = array_values($asMessages);
foreach($asSortedMessages as $iIndex=>&$asSortedMessage) $asSortedMessage['displayed_id'] = $iIndex + 1;
return $asSortedMessages;
}
/**
* Get valid medias based on $sTimeRefField:
* - taken_on: Date/time on which the media was taken
* - posted_on: Date/time on which the media was uploaded
* @param String $sTimeRefField Field to calculate relative times
* @return Array Medias info
*/
private function getMedias($sTimeRefField)
{
$asMedias = $this->oMedia->getMediasInfo();
$asValidMedias = array();
foreach($asMedias as $asMedia) {
$sTimeRef = $asMedia[$sTimeRefField];
if($sTimeRef >= $this->oProject->getActivePeriod('from') && $sTimeRef <= $this->oProject->getActivePeriod('to')) {
$asMedia['taken_on_formatted'] = $this->getTimeFormat(strtotime($asMedia['taken_on']));
$asMedia['posted_on_formatted'] = $this->getTimeFormat(strtotime($asMedia['posted_on']));
$this->addTimeStamp($asMedia, strtotime($sTimeRef));
$asValidMedias[] = $asMedia;
}
}
usort($asValidMedias, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
$asSortedMedias = array_values($asValidMedias);
foreach($asSortedMedias as $iIndex=>&$asSortedMedia) $asSortedMedia['displayed_id'] = $iIndex + 1;
return $asSortedMedias;
}
private function getPosts()
{
$asInfo = array(
'from' => self::POST_TABLE,
'constraint'=> array(Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId()),
'constOpe' => array(Db::getId(Project::PROJ_TABLE) => "=")
);
if($this->oProject->getMode()==Project::MODE_HISTO) {
$asInfo['constraint']['site_time'] = $this->oProject->getActivePeriod();
$asInfo['constOpe']['site_time'] = "BETWEEN";
}
$asPosts = $this->oDb->selectRows($asInfo);
foreach($asPosts as &$asPost) {
$iUnixTimeStamp = strtotime($asPost['site_time']); //assumes site timezone (Settings::TIMEZONE)
$asPost['formatted_name'] = Toolbox::mb_ucwords($asPost['name']);
unset($asPost[Db::getId(User::USER_TABLE)]);
$this->addTimeStamp($asPost, $iUnixTimeStamp);
}
usort($asPosts, function($a, $b){return $a['unix_time'] > $b['unix_time'];});
return $asPosts;
}
private function addTimeStamp(&$asData, $iTime) {
$asData['unix_time'] = (int) $iTime;
$asData['relative_time'] = Toolbox::getDateTimeDesc($iTime, $this->oLang->getLanguage());
$asData['formatted_time'] = $this->getTimeFormat($iTime);
}
public function getNewsFeed($iChunk=0, $bInternal=false)
{
$bHistoMode = ($this->oProject->getMode() == Project::MODE_HISTO);
$asFeeds = array();
$asFeedTypes = array(
'message' => array(
'table' => Feed::MSG_TABLE,
'feed' => $this->getSpotMessages(),
'priority' => 0
),
'media' => array(
'table' => Media::MEDIA_TABLE,
'feed' => $this->getMedias('posted_on'),
'priority' => 1
),
'post' => array(
'table' => self::POST_TABLE,
'feed' => $this->getPosts(),
'priority' => 2
)
);
foreach($asFeedTypes as $sFeedType=>$asFeedTypeInfo) {
foreach($asFeedTypeInfo['feed'] as $asFeed) {
$sPriority = $asFeedTypeInfo['priority'];
if($bHistoMode) $sPriority = count($asFeedTypes) - 1 - $asFeedTypeInfo['priority'];
$iTableId = $asFeed[Db::getId($asFeedTypeInfo['table'])];
$iId = ($asFeed['unix_time'] * -1).'.'.$sPriority.$iTableId;
$asFeeds[$iId] = $asFeed;
$asFeeds[$iId]['type'] = $sFeedType;
$asFeeds[$iId]['id'] = $iTableId;
}
}
//Sort by key
if($bHistoMode) krsort($asFeeds);
else ksort($asFeeds);
//Split chunks
$asFeeds = array_slice($asFeeds, $iChunk * self::FEED_CHUNK_SIZE, self::FEED_CHUNK_SIZE);
return $bInternal?$asFeeds:self::getJsonResult(true, '', $asFeeds);
}
public function syncMedias() {
return $this->oMedia->syncFileFolder();
}
public function addPost($sName, $sPost)
{
$asData = array(
Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId(),
'name' => mb_strtolower(trim($sName)),
'content' => trim($sPost),
'site_time' => date(Db::TIMESTAMP_FORMAT) //site time (Settings::TIMEZONE)
);
if($this->oUser->getUserId() > 0) $asData[Db::getId(User::USER_TABLE)] = $this->oUser->getUserId();
$iPostId = $this->oDb->insertRow(self::POST_TABLE, $asData);
$this->oUser->updateNickname($sName);
return self::getJsonResult(($iPostId > 0), '');
}
public function upload()
{
$this->oClassManagement->incClass('uploader', true);
$oUploader = new Uploader($this->oMedia, $this->oLang);
return $oUploader->sBody;
}
public function addComment($iMediaId, $sComment) {
$oMedia = new Media($this->oDb, $this->oProject, $iMediaId);
$asResult = $oMedia->setComment($sComment);
return self::getJsonResult($asResult['result'], $asResult['desc'], $asResult['data']);
}
public function getAdminSettings() {
$oFeed = new Feed($this->oDb);
$asData = array(
'project' => $this->oProject->getProjects(),
'feed' => $oFeed->getFeeds(),
'spot' => $oFeed->getSpots()
);
return self::getJsonResult(true, '', $asData);
}
public function setAdminSettings($sType, $iId, $sField, $sValue) {
$bSuccess = false;
$sDesc = '';
$asResult = array();
switch($sType) {
case 'project':
$oProject = new Project($this->oDb, $iId);
switch($sField) {
case 'name':
$bSuccess = $oProject->setProjectName($sValue);
break;
case 'codename':
$bSuccess = $oProject->setProjectCodeName($sValue);
break;
case 'active_from':
$bSuccess = $oProject->setActivePeriod($sValue.' 00:00:00', 'from');
break;
case 'active_to':
$bSuccess = $oProject->setActivePeriod($sValue.' 23:59:59', 'to');
break;
case 'timezone':
$bSuccess = $oProject->setTimeZone($sValue);
break;
}
$asResult = $oProject->getProject();
break;
case 'feed':
case 'spot':
$oFeed = new Feed($this->oDb, $iId);
switch($sField) {
case 'ref_feed_id':
$bSuccess = $oFeed->setRefFeedId($sValue);
break;
case 'spot_id':
$bSuccess = $oFeed->setSpotId($sValue);
break;
case 'project_id':
$bSuccess = $oFeed->setProjectId($sValue);
break;
}
$asResult = $oFeed->getFeed();
break;
}
if(!$bSuccess) $sDesc = Mask::LANG_PREFIX.'error_commit_db';
return self::getJsonResult($bSuccess, $sDesc, array($sType=>array($asResult)));
}
public function delAdminSettings($sType, $iId) {
$bSuccess = false;
$sDesc = '';
switch($sType) {
case 'project':
$oProject = new Project($this->oDb, $iId);
$sDesc = $oProject->delete();
break;
case 'feed':
$oFeed = new Feed($this->oDb, $iId);
$sDesc = $oFeed->delete();
break;
}
$bSuccess = ($sDesc=='');
return self::getJsonResult($bSuccess, $sDesc, array($sType=>array(array('id'=>$iId, 'del'=>$bSuccess))));
}
public function createProject() {
$oProject = new Project($this->oDb);
$iNewProjectId = $oProject->createProjectId();
$oFeed = new Feed($this->oDb);
$oFeed->createFeedId($iNewProjectId);
return self::getJsonResult($iNewProjectId>0, '', array(
'project' => array($oProject->getProject()),
'feed' => array($oFeed->getFeed())
));
}
public function convertGpxToGeojson($sGeoFileName) {
return Converter::convertToGeoJson($sGeoFileName);
}
public static function decToDms($dValue, $sType) {
if($sType=='lat') $sDirection = ($dValue >= 0)?'N':'S'; //Latitude
else $sDirection = ($dValue >= 0)?'E':'W'; //Longitude
$dLeft = abs($dValue);
//Degrees
$iDegree = floor($dLeft);
$dLeft -= $iDegree;
//Minutes
$iMinute = floor($dLeft * 60);
$dLeft -= $iMinute / 60;
//Seconds
$fSecond = round($dLeft * 3600, 1);
return $iDegree.'°'.$iMinute."'".$fSecond.'"'.$sDirection;
}
public function getTimeFormat($iTime) {
$sDate = date('d/m/Y', $iTime);
$sTime = date('H:i', $iTime);
return $this->oLang->getTranslation('date_time', array($sDate, $sTime));
}
}
+1527
View File
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
<?php
class User extends PhpObject {
//DB Tables
const USER_TABLE = 'users';
//Cookie
const COOKIE_ID_USER = 'subscriber';
const COOKIE_DURATION = 60 * 60 * 24 * 365; //1 year
/**
* Database Handle
* @var Db
*/
private $oDb;
//User Info
private $iUserId;
private $asUserInfo;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__, Settings::DEBUG);
$this->oDb = &$oDb;
$this->iUserId = 0;
$this->asUserInfo = array(Db::getId(self::USER_TABLE)=>0, 'name'=>'', 'email'=>'', 'language'=>'', 'timezone'=>'', 'active'=>'0');
$this->checkUserCookie();
}
public function getLang() {
return $this->asUserInfo['language'];
}
public function addUser($sEmail, $sLang, $sTimezone) {
$bSuccess = false;
$sDesc = '';
$sEmail = trim($sEmail);
//Check Email availability
$iUserId = $this->oDb->selectValue(self::USER_TABLE, Db::getId(self::USER_TABLE), array('email'=>$sEmail, 'active'=>'1'));
if($iUserId > 0) {
//Log user in
$sDesc = 'lang:nl_email_exists';
$bSuccess = true;
}
else {
//Add/Reactivate user
$iUserId = $this->oDb->insertUpdateRow(self::USER_TABLE, array('email'=>$sEmail, 'language'=>$sLang, 'timezone'=>$sTimezone, 'active'=>'1'), array('email'));
if($iUserId==0) $sDesc = 'lang:error_commit_db';
else {
$sDesc = 'lang:nl_subscribed';
$bSuccess = true;
}
}
//Set Cookie (valid 1 year)
if($bSuccess) {
$this->setUserId($iUserId);
$this->updateCookie(self::COOKIE_DURATION);
}
return Spot::getResult($bSuccess, $sDesc);
}
public function removeUser() {
$bSuccess = false;
$sDesc = '';
if($this->iUserId > 0) {
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $this->iUserId, array('active'=>'0'));
if($iUserId==0) $sDesc = 'lang:error_commit_db';
else {
$sDesc = 'lang:nl_unsubscribed';
$this->updateCookie(-60 * 60); //Set Cookie in the past, deleting it
$bSuccess = true;
}
}
else $sDesc = 'lang:nl_unknown_email';
return Spot::getResult($bSuccess, $sDesc);
}
public function updateNickname($sNickname) {
if($this->iUserId > 0 && $sNickname!='') $this->oDb->updateRow(self::USER_TABLE, $this->iUserId, array('name'=>$sNickname));
}
private function checkUserCookie() {
if(isset($_COOKIE[self::COOKIE_ID_USER])){
$this->setUserId($_COOKIE[self::COOKIE_ID_USER]);
//Extend cookie life
if($this->iUserId > 0) $this->updateCookie(self::COOKIE_DURATION);
}
}
public function getUserId() {
return $this->iUserId;
}
public function setUserId($iUserId) {
$this->iUserId = 0;
$asUser = $this->getActiveUsersInfo($iUserId);
if(!empty($asUser)) {
$this->iUserId = $iUserId;
$this->asUserInfo = array_shift($asUser);
}
}
public function getUserInfo() {
return $this->asUserInfo;
}
public function getActiveUsersInfo($iUserId=-1) {
$asInfo = array(
'select' => array_keys($this->asUserInfo),
'from' => self::USER_TABLE,
'constraint'=> array('active'=>'1')
);
if($iUserId != -1) $asInfo['constraint'][Db::getId(self::USER_TABLE)] = $iUserId;
return $this->oDb->selectRows($asInfo);
}
private function updateCookie($iDeltaTime) {
setcookie(self::COOKIE_ID_USER, ($iDeltaTime < 0)?'':$this->iUserId, array('samesite' => 'Lax', 'expires' => time() + $iDeltaTime));
}
}
Executable
+96
View File
@@ -0,0 +1,96 @@
<?php
/* Requests Handler */
//Start buffering
ob_start();
require_once '../objects/class_management.php';
$oClassManagement = new ClassManagement('spot');
ToolBox::cleanPost($_POST);
ToolBox::cleanPost($_GET);
ToolBox::cleanPost($_REQUEST);
ToolBox::fixGlobalVars(isset($argv)?$argv:array());
//Available variables
$sAction = isset($_REQUEST['a'])?$_REQUEST['a']:'';
$sTimezone = isset($_REQUEST['t'])?$_REQUEST['t']:'';
$sName = isset($_GET['name'])?$_GET['name']:'';
$sContent = isset($_GET['content'])?$_GET['content']:'';
$iChunk = isset($_GET['chunk'])?$_GET['chunk']:0;
$iProjectId = isset($_REQUEST['project_id'])?$_REQUEST['project_id']:0;
$sField = isset($_REQUEST['field'])?$_REQUEST['field']:'';
$oValue = isset($_REQUEST['value'])?$_REQUEST['value']:'';
$iId = isset($_REQUEST['id'])?$_REQUEST['id']:0;
$sType = isset($_REQUEST['type'])?$_REQUEST['type']:'';
$sEmail = isset($_REQUEST['email'])?$_REQUEST['email']:'';
//Initiate class
$oSpot = new Spot($oClassManagement, __FILE__, $sTimezone);
$oSpot->setProjectId($iProjectId);
$sResult = '';
if($sAction!='')
{
switch($sAction)
{
case 'markers':
$sResult = $oSpot->getMarkers();
break;
case 'feed':
$sResult = $oSpot->getNewsFeed($iChunk);
break;
case 'update_project':
$sResult = $oSpot->updateProject();
break;
case 'upload':
$sResult = $oSpot->upload();
break;
case 'add_comment':
$sResult = $oSpot->addComment($iId, $sContent);
break;
case 'add_post':
$sResult = $oSpot->addPost($sName, $sContent);
break;
/*case 'sql':
$sResult = $oSpot->getDbBuildScript();
break;*/
case 'sync_pics':
$sResult = $oSpot->syncPics();
break;
case 'admin_new':
$sResult = $oSpot->createProject();
break;
case 'admin_get':
$sResult = $oSpot->getAdminSettings();
break;
case 'admin_set':
$sResult = $oSpot->setAdminSettings($sType, $iId, $sField, $oValue);
break;
case 'admin_del':
$sResult = $oSpot->delAdminSettings($sType, $iId);
break;
case 'build_geojson':
$sResult = $oSpot->convertGpxToGeojson($sName);
break;
case 'subscribe':
$sResult = $oSpot->subscribe($sEmail);
break;
case 'unsubscribe':
$sResult = $oSpot->unsubscribe();
break;
case 'unsubscribe_email':
$sResult = $oSpot->unsubscribeFromEmail($iId);
break;
case 'generate_cron':
$sResult = $oSpot->genCronFile();
break;
default:
$sResult = Main::getJsonResult(false, Main::NOT_FOUND);
}
}
else $sResult = $oSpot->getMainPage();
$sDebug = ob_get_clean();
if(Settings::DEBUG && $sDebug!='') $oSpot->addUncaughtError($sDebug);
echo $sResult;
+111
View File
@@ -0,0 +1,111 @@
locale = en_NZ
page_og_desc = Keep contact with François when he is off hiking
error_commit_db = Issue committing to DB
save = Save
admin_save_success = Saved
track_main = Main track
track_off-track = Off-track
track_hitchhiking = Hitchhiking
track_download = Download GPX Track
upload_title = Picture & Video Uploads
upload_wrong_mode = Project "$0" is not in blog mode. No upload allowed
upload_success = $0 uploaded successfully
upload_media_exist = Picture $0 already exists
post_message = Message
post_name = Name
post_new_message = New message
and = and
counter = #$0
maps = Base Maps
map_satellite = Satellite
map_otm = Open Topo Map
map_ign_france = IGN (France)
map_ign_spain = IGN (Spain)
map_linz = LINZ (New Zealand)
map_usgs = USGS (USA)
map_natgeo = National Geographic (USA)
image = Picture
images = Pictures
image_taken = taken on $0
video = Video
video_taken = shot on $0
add_on = added on $0
click_watch = Click to watch video
click_zoom = Click to zoom
media_count = Media $0 / $1
media_no_id = Missing Media ID in request
media_comment_update= Comment of media "$0" updated
see_on_google = See on Google Maps
copy_to_clipboard = Copy direct link to clipboard
link_copied = Link copied!
city_time = $0 Time
project_id = Project ID
project = Project
projects = Projects
mode = Mode
code_name = Code name
start = Start
end = End
feed_id = Feed ID
ref_feed_id = Ref. Feed ID
spot_id = Spot ID
name = Name
status = Status
last_update = Last Spot update
ref_spot_id = Ref. Spot ID
model = Model
delete = Delete
date_time = $0 at $1
time_zone = Time zone
unit_day = day
unit_days = days
unit_hour = h
newsletter = Keep in touch!
nl_email_placeholder= my@email.com
nl_invalid_email = It doesn't look like an email
nl_subscribed_desc = You're all set. We'll send you updates as soon as we get them
nl_unsubscribed_desc= Write down your email address and we'll send you François' position as soon as we get it :)
nl_email_exists = This email is already subscribed. You can unsubscribe by clicking on the button above.
nl_subscribe = Subscribe
nl_subscribed = Thanks! You'll receive a confirmation email shortly
nl_unsubscribe = Unsubscribe
nl_unsubscribed = Done. No more junk mail from us
nl_unknown_email = Unknown email address
email_unsubscribe = PS: Changed your mind?
email_unsub_btn = Unsubscribe
email_conf_subject = Successful Registration
conf_preheader = Thanks for keeping in touch!
conf_thanks_sub = You're all set!
conf_body_para_1 = Thank you for checking in on my wanderings :). I'll make sure to keep you posted on my progress along the trail.
conf_body_para_2 = I usually check-in once a day, plus sometimes on special events, like successful peak ascents. I am using a GPS-based device (PLB) which does not require phone reception to work. Thus the messages should be pretty frequent, but, being awestruck by the beauty of nature, I could also just forget to send a signal once in a while. So do not worry if you don't receive anything for a couple of days.
conf_body_para_3 = If I've posted some pictures recently, you should also get them in this email.
conf_body_conclusion= Happy Trails!
conf_signature = --François
email_update_subject= Spotted!
update_preheader = New position received
update_title = Message
update_latest_news = Latest news:
distance = Distance
elevation = Elevation
segment_length = Segment length
type = Track Type
legend = Legend
credits_project = Spotty Project
credits_git = Git Repository
credits_license = under GPLv3 license
+111
View File
@@ -0,0 +1,111 @@
locale = fr_FR
page_og_desc = Gardez le contact avec François lorsqu'il part sur les chemins
error_commit_db = Error lors de la requête SQL
save = Sauvegarder
admin_save_success = Sauvegardé
track_main = Trajet principal
track_off-track = Variante
track_hitchhiking = Hors rando
track_download = Télécharger la trace GPX
upload_title = Uploader photos & vidéos
upload_wrong_mode = Le projet "$0" n'est pas en mode blog. Aucun upload possible
upload_success = $0 a été uploadé
upload_media_exist = l'image $0 existe déjà
post_message = Message
post_name = Nom
post_new_message = Nouveau message
and = et
counter = N° $0
maps = Fonds de carte
map_satellite = Satellite
map_otm = Open Topo Map
map_ign_france = IGN (France)
map_ign_spain = IGN (Espagne)
map_linz = LINZ (Nouvelle-Zélande)
map_usgs = USGS (États-Unis)
map_natgeo = National Geographic (États-Unis)
image = Photo
images = Photos
image_taken = prise le $0
video = Vidéo
video_taken = filmée le $0
add_on = ajoutée le $0
click_watch = Click pour voir la vidéo
click_zoom = Click pour zoomer
media_count = Média $0 sur $1
media_no_id = ID du média manquant
media_comment_update= Commentaire du media "$0" mis-à-jour
see_on_google = Voir la position sur Google Maps
copy_to_clipboard = Copie le lien dans le presse-papier
link_copied = Lien copié !
city_time = heure de $0
project_id = ID projet
project = Projet
projects = Projets
mode = Mode
code_name = Nom de code
start = Départ
end = Arrivée
feed_id = ID Feed
ref_feed_id = ID Feed ref.
spot_id = ID Spot
name = Description
status = Statut
last_update = Dernière maj depuis Spot
ref_spot_id = ID Spot ref.
model = Modèle
delete = Supprimer
date_time = $0 à $1
time_zone = Fuseau horaire
unit_day = jour
unit_days = jours
unit_hour = h
newsletter = Rester en contact
nl_email_placeholder= mon@email.com
nl_invalid_email = Ceci ne ressemble pas à une adresse email
nl_subscribed_desc = C'est tout bon. On t'envoie des nouvelles fraiches dès qu'on les reçoit. Parole de scout.
nl_unsubscribed_desc= Ajoute ton adresse email et on t'enverra la nouvelle position de François dès qu'on la reçoit :)
nl_email_exists = Cette adresse email est déjà enregistrée. Vous pouvez vous désinscrire en cliquant sur le bouton ci-dessus.
nl_subscribe = S'abonner
nl_subscribed = Merci ! Tu vas recevoir un email de confirmation très bientôt
nl_unsubscribe = Se désinscrire
nl_unsubscribed = C'est fait. Fini le spam!
nl_unknown_email = Adresse email inconnue
email_unsubscribe = PS: Trop d'emails ?
email_unsub_btn = Se désinscrire
email_conf_subject = Confirmation
conf_preheader = Merci de rester en contact !
conf_thanks_sub = C'est tout bon !
conf_body_para_1 = C'est gentil de venir voir où j'en suis. Promis, je vous tiendrais au courant de mon avancée.
conf_body_para_2 = En général, j'envoie un message une fois par jour. Lorsque je passe à des endroits sympas, j'en envoie un supplémentaire (ascension de sommets, ce genre de choses). J'utilise une balise GPS pour envoyer le signal, je n'ai donc pas besoin de réseau téléphonique pour que cela fonctionne. Cependant, il peut m'arriver d'appuyer sur le bouton. Donc pas de raison de s'inquiéter si vous ne recevez pas de messages pendant une journée ou deux.
conf_body_para_3 = Si j'ai ajouté des photos sur le site récemment, vous devriez aussi les retrouver dans cet email.
conf_body_conclusion= A bientôt sur les chemins !
conf_signature = --François
email_update_subject= Nouvelle position reçue
update_preheader = Nouvelle position !
update_title = Message
update_latest_news = Dernières nouvelles :
distance = Distance
elevation = Dénivelé
segment_length = Taille du segment
type = Type de rando
legend = Légende
credits_project = Projet Spotty
credits_git = Dépôt Git
credits_license = sous licence GPLv3
-172
View File
@@ -1,172 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\ToolBox;
//TODO Keep only local specificities and move bulk to Franzz\Objects\Controller
class Controller extends PhpObject {
private const MUTATING_ACTIONS = [
'add_post',
'subscribe',
'unsubscribe',
'login',
'logout',
'update_project',
'upload',
'add_comment',
'add_position',
'admin_set',
'admin_create',
'admin_delete'
];
private const SESSION_WRITING_ACTIONS = [
'login',
'logout'
];
private Livetrail $oLivetrail;
private array $asReq;
private string $sCsrfToken = '';
public function __construct() {
parent::__construct(__CLASS__);
}
private function setReqVal(string $sKey, $oValue, string $sValidation=''): void {
$this->asReq[$sKey] = $this->validateValue($sValidation, $oValue);
}
public function handle($sProcessPage, array $argv = []): string {
//Start buffering so warnings/notices can be collected
ob_start();
//Parse variables
$asReq = ToolBox::getRequest($argv);
$this->asReq = [];
$sAction = $asReq['a'] ?? '';
$this->setReqVal('t', $asReq['t'] ?? '');
$this->setReqVal('name', $asReq['name'] ?? '');
$this->setReqVal('content', $asReq['content'] ?? '');
$this->setReqVal('ref_type', $asReq['ref_type'] ?? '');
$this->setReqVal('ref_id', $asReq['ref_id'] ?? 0, 'positiveInt');
$this->setReqVal('id_project', $asReq['id_project'] ?? 0, 'positiveInt');
$this->setReqVal('id', $asReq['id'] ?? 0);
$this->setReqVal('id_entity', $asReq['id'] ?? 0, 'positiveInt');
$this->setReqVal('field', $asReq['field'] ?? '');
$this->setReqVal('value', $asReq['value'] ?? '');
$this->setReqVal('type', $asReq['type'] ?? '');
$this->setReqVal('email', $asReq['email'] ?? '');
$this->setReqVal('password', $asReq['password'] ?? '');
$this->setReqVal('latitude', $asReq['latitude'] ?? '');
$this->setReqVal('longitude', $asReq['longitude'] ?? '');
$this->setReqVal('timestamp', $asReq['timestamp'] ?? 0, 'positiveInt');
$this->setReqVal('csrf_token', $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['csrf_token'] ?? ''));
//Authentication and CSRF protection share the same server-side session.
$this->initCsrfToken();
//Create Livetrail instance
$this->oLivetrail = new Livetrail($sProcessPage, $this->asReq['t']);
//Validate CSRF, then release the session lock before long-running work.
$bValidMutationRequest = $this->validateMutationRequest($sAction);
if(!$bValidMutationRequest || !in_array($sAction, self::SESSION_WRITING_ACTIONS, true)) $this->closeSession();
if(!$bValidMutationRequest) $sResult = Livetrail::getJsonResult(false, Livetrail::UNAUTHORIZED);
else {
$this->oLivetrail->setProjectId($this->asReq['id_project']);
$sResult = ($sAction == '')?$this->oLivetrail->getAppMainPage($this->getCsrfToken()):$this->dispatch($sAction);
}
//Clean errors
$sDebug = ob_get_clean();
if($sDebug != '') $this->oLivetrail->addUncaughtError($sDebug);
$this->closeSession();
return $sResult;
}
private function validateMutationRequest(string $sAction): bool {
return
PHP_SAPI === 'cli'
||
!in_array($sAction, self::MUTATING_ACTIONS, true)
||
($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && $this->checkCsrfToken($this->asReq['csrf_token'])
;
}
private function getCsrfToken(): string {
if($this->sCsrfToken === '') $this->initCsrfToken();
return $this->sCsrfToken;
}
private function setCsrfToken(): void {
if(empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$this->sCsrfToken = $_SESSION['csrf_token'];
}
private function initCsrfToken(): void {
if(PHP_SAPI === 'cli') return;
if(session_status() !== PHP_SESSION_ACTIVE) {
$bSecure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
session_set_cookie_params(['httponly' => true, 'secure' => $bSecure, 'samesite' => 'Lax']);
session_start();
}
$this->setCsrfToken();
}
private function checkCsrfToken(string $sClientToken): bool {
$sServerToken = $this->getCsrfToken();
return PHP_SAPI === 'cli' || ($sServerToken !== '' && is_string($sClientToken) && hash_equals($sServerToken, $sClientToken));
}
private function closeSession(): void {
if(session_status() === PHP_SESSION_ACTIVE) session_write_close();
}
private function dispatch(string $sAction): string {
return match($sAction) {
'markers' => $this->oLivetrail->getMarkers(),
'last_update' => $this->oLivetrail->getLastUpdate(),
'next_feed' => $this->oLivetrail->getNextFeed($this->asReq['id']),
'new_feed' => $this->oLivetrail->getNewFeed($this->asReq['id']),
'add_post' => $this->oLivetrail->addPost($this->asReq['name'], $this->asReq['content'], $this->asReq['ref_type'], $this->asReq['ref_id']),
'subscribe' => $this->oLivetrail->subscribe(),
'unsubscribe' => $this->oLivetrail->unsubscribe(),
'login' => $this->oLivetrail->login($this->asReq['email'], $this->asReq['password'], $this->asReq['name']),
'logout' => $this->oLivetrail->logout(),
'update_project' => $this->oLivetrail->updateProject(),
default => $this->dispatchAdmin($sAction)
};
}
private function dispatchAdmin(string $sAction): string {
if(!$this->oLivetrail->checkUserClearance(User::CLEARANCE_ADMIN)) {
return Livetrail::getJsonResult(false, Livetrail::NOT_FOUND);
}
return match($sAction) {
'upload' => $this->oLivetrail->upload(),
'add_comment' => $this->oLivetrail->addComment($this->asReq['id_entity'], $this->asReq['content']),
'add_position' => $this->oLivetrail->addPosition($this->asReq['latitude'], $this->asReq['longitude'], $this->asReq['timestamp']),
'admin_get' => $this->oLivetrail->getAdminSettings(),
'admin_set' => $this->oLivetrail->setAdminSettings($this->asReq['type'], $this->asReq['id_entity'], $this->asReq['field'], $this->asReq['value']),
'admin_create' => $this->oLivetrail->createAdminSettings($this->asReq['type']),
'admin_delete' => $this->oLivetrail->deleteAdminSettings($this->asReq['type'], $this->asReq['id_entity']),
'sql' => $this->oLivetrail->getDbBuildScript(),
default => Livetrail::getJsonResult(false, Livetrail::NOT_FOUND)
};
}
private static function validateValue(string $sValidation, $oValue=0) {
return match($sValidation) {
'' => $oValue,
'positiveInt' => filter_var($oValue, FILTER_VALIDATE_INT, ['options' => ['default' => 0, 'min_range' => 0]])
};
}
}
-48
View File
@@ -1,48 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
/**
* GPX to GeoJSON Converter
*
* To convert a gpx file:
* 1. Add file <file_name>.gpx to geo/ folder
* 2. Assign file to project: UPDATE projects SET codename = '<file_name>' WHERE id_project = <id_project>;
* 3. Load any page
*/
class Converter extends PhpObject {
public function __construct() {
parent::__construct(__CLASS__);
}
public static function convertToGeoJson(string $sCodeName) {
$oGpx = new Gpx($sCodeName);
$oGeoJson = new GeoJson($sCodeName);
$oGeoJson->buildTracks($oGpx->getTracks());
if($oGeoJson->isSimplicationRequired()) $oGeoJson->buildTracks($oGpx->getTracks(), true);
$oGeoJson->sortOffTracks();
$oGeoJson->saveFile();
return [
'logs' => $oGpx->getLog().'<br />'.$oGeoJson->getLog(),
'center' => $oGeoJson->getCenter()
];
}
public static function hasGpxFile(string $sCodeName) {
return ($sCodeName != '' && file_exists(Gpx::getBackendFilePath($sCodeName)));
}
public static function isGeoJsonValid(string $sCodeName) {
$sGpxFilePath = Gpx::getBackendFilePath($sCodeName);
$sGeoJsonFilePath = GeoJson::getBackendFilePath($sCodeName);
return
!self::hasGpxFile($sCodeName) //No GPX, no need for geoJSON
||
file_exists($sGeoJsonFilePath) && filemtime($sGeoJsonFilePath) >= filemtime($sGpxFilePath); //geoJSON needs to be (re)generated
}
}
-341
View File
@@ -1,341 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use Franzz\Objects\Translator;
use \Settings;
/**
* Feed Class
* Also manages spots (devices) & messages
*/
class Feed extends PhpObject {
//Spot feed
private const FEED_HOOK = 'https://api.findmespot.com/spot-main-web/consumer/rest-api/2.0/public/feed/';
private const FEED_TYPE_XML = '/message.xml';
private const FEED_TYPE_JSON = '/message.json';
private const FEED_MAX_REFRESH = 5 * 60; //Seconds
//Weather
private const WEATHER_HOOK = 'https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline';
private const WEATHER_PARAM = [
'key' => Settings::WEATHER_TOKEN,
'unitGroup' => 'metric',
'lang' => 'en',
'include' => 'current',
'iconSet' => 'icons2'
];
//Timezone
private const TIMEZONE_HOOK = 'http://api.geonames.org/timezoneJSON';
//DB Tables
public const SPOT_TABLE = 'spots';
public const FEED_TABLE = 'feeds';
public const MSG_TABLE = 'messages';
//Hide/Display values
public const MSG_HIDDEN = 0;
public const MSG_DISPLAYED = 1;
/**
* Database Handle
* @var Db
*/
private $oDb;
private $iFeedId;
private $sRefFeedId;
private $iLastUpdate;
public function __construct(Db &$oDb, $iFeedId=0) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
if($iFeedId > 0) $this->setFeedId($iFeedId);
}
public function getFeedId() {
return $this->iFeedId;
}
public function getLastUpdate(): int {
return $this->iLastUpdate;
}
public function setFeedId($iFeedId) {
$this->iFeedId = $iFeedId;
$asFeed = $this->getFeed();
$this->sRefFeedId = $asFeed['ref_feed_id'];
$this->iLastUpdate = $asFeed['last_update']=='0000-00-00 00:00:00'?0:strtotime($asFeed['last_update']);
}
public function createFeedId($oProjectId) {
$this->setFeedId($this->oDb->insertRow(self::FEED_TABLE, [
Db::getId(Project::PROJ_TABLE) => $oProjectId,
'status' => 'INACTIVE'
]));
return $this->getFeedId();
}
public function setRefFeedId($sRefFeedId) {
return $this->updateField('ref_feed_id', $sRefFeedId);
}
public function setSpotId($iSpotId) {
return $this->updateField(Db::getId(self::SPOT_TABLE), $iSpotId);
}
public function setProjectId($iProjectId) {
return $this->updateField(Db::getId(Project::PROJ_TABLE), $iProjectId);
}
public function getSpots() {
$asSpots = $this->oDb->selectRows(['from'=>self::SPOT_TABLE]);
foreach($asSpots as &$asSpot) $asSpot['id'] = $asSpot[Db::getId(self::SPOT_TABLE)];
return $asSpots;
}
public function getFeeds($iFeedId=0) {
$asInfo = ['from'=>self::FEED_TABLE];
if($iFeedId > 0) $asInfo['constraint'] = [Db::getId(self::FEED_TABLE)=>$iFeedId];
$asFeeds = $this->oDb->selectRows($asInfo);
foreach($asFeeds as &$asFeed) $asFeed['id'] = $asFeed[Db::getId(self::FEED_TABLE)];
return $asFeeds;
}
public function getFeed() {
$asFeeds = $this->getFeeds($this->getFeedId());
return array_shift($asFeeds);
}
public function getMessages($asConstraints=[]) {
$sFeedIdCol = Db::getId(self::FEED_TABLE, true);
$asInfo = [
'select' => [
Db::getId(self::MSG_TABLE), 'ref_msg_id', 'type', //ID
'latitude', 'longitude', //Position
'site_time', 'timezone', 'unix_time', //Time
'weather_icon', 'weather_cond', 'weather_temp' //Weather
],
'from' => self::MSG_TABLE,
'join' => [self::FEED_TABLE => Db::getId(self::FEED_TABLE)],
'constraint'=> [$sFeedIdCol => $this->getFeedId(), 'display' => self::MSG_DISPLAYED],
'constOpe' => [$sFeedIdCol => '=', 'display' => '='],
'orderBy' => ['site_time'=>'ASC']
];
if(!empty($asConstraints)) $asInfo = array_merge($asInfo, $asConstraints);
$asResult = $this->oDb->selectRows($asInfo);
/* Temporary lookup - Start */
$iCount = 0;
foreach($asResult as &$asMsg) {
if($asMsg['weather_icon'] == '' && $iCount < 3) {
$asWeather = $this->getWeather([$asMsg['latitude'], $asMsg['longitude']], $asMsg['unix_time']);
$asMsg = array_merge($asMsg, $asWeather);
$this->oDb->updateRow(self::MSG_TABLE, $asMsg[Db::getId(self::MSG_TABLE)], $asWeather, false);
$iCount++;
}
}
/* Temporary lookup - End */
return $asResult;
}
public function getLastMessageId($asConstraints=[]) {
$asMessages = $this->getMessages($asConstraints);
return end($asMessages)[Db::getId(self::MSG_TABLE)] ?? 0;
}
public function checkUpdateFeed($sProjectMode) {
$bNewMsg = false;
//Spam Check: no more than 1 API request per 5 minutes
if($sProjectMode == Project::MODE_BLOG) {
$oLastUpdate = new \DateTime('@'.$this->iLastUpdate);
$oNow = new \DateTime('now');
$iSecDiff = $oNow->getTimestamp() - $oLastUpdate->getTimestamp();
if($iSecDiff > self::FEED_MAX_REFRESH) $bNewMsg = $this->updateFeed();
}
return $bNewMsg;
}
public function addManualPosition($sLat, $sLng, $iTimestamp) {
$sTimeZone = date_default_timezone_get();
$oDateTime = new \DateTime('@'.$iTimestamp);
$oDateTime->setTimezone(new \DateTimeZone($sTimeZone));
$asWeather = $this->getWeather([$sLat, $sLng], $iTimestamp);
$asMsg = [
'ref_msg_id' => $iTimestamp.'/man',
'id_feed' => $this->getFeedId(),
'type' => 'OK',
'latitude' => $sLat,
'longitude' => $sLng,
'iso_time' => $oDateTime->format("Y-m-d\TH:i:sO"), //Incorrect ISO 8601 format, but compliant with Spot data
'site_time' => $oDateTime->format(Db::TIMESTAMP_FORMAT),
'timezone' => $sTimeZone,
'unix_time' => $iTimestamp,
'content' => '',
'battery_state' => '',
'posted_on' => date(Db::TIMESTAMP_FORMAT),
];
$iMessageId = $this->oDb->insertRow(self::MSG_TABLE, array_merge($asMsg, $asWeather));
return $iMessageId;
}
private function updateFeed() {
$bNewMsg = false;
$asData = $this->retrieveFeed();
$sNow = date(Db::TIMESTAMP_FORMAT);
if(!isset($asData['response']['errors']) || empty($asData['response']['errors'])) {
$asMsgs = $asData['response']['feedMessageResponse']['messages'];
$asFeed = $asData['response']['feedMessageResponse']['feed'];
//Fix unstable Spot API Structure
if(array_key_exists('message', $asMsgs)) $asMsgs = $asMsgs['message']; //Sometimes adds an extra "message" level
if(!array_key_exists(0, $asMsgs)) $asMsgs = [$asMsgs]; //Jumps a level when there is only 1 message
//Update Spot, Feed & Messages
if(!empty($asMsgs) && array_key_exists('messengerId', $asMsgs[0])) {
//Update Spot Info from the first message
$asSpotInfo = [
'ref_spot_id' => $asMsgs[0]['messengerId'],
'name' => $asMsgs[0]['messengerName'],
'model' => $asMsgs[0]['modelId']
];
$iSpotId = $this->oDb->insertUpdateRow(self::SPOT_TABLE, $asSpotInfo, ['ref_spot_id']);
//Update Feed Info and last update date
$asFeedInfo = [
'ref_feed_id' => $asFeed['id'],
Db::getId(self::SPOT_TABLE) => $iSpotId,
'name' => $asFeed['name'],
'description' => $asFeed['description'],
'status' => $asFeed['status'],
'last_update' => $sNow
];
$iFeedId = $this->oDb->insertUpdateRow(self::FEED_TABLE, $asFeedInfo, ['ref_feed_id']);
//Update Messages
foreach($asMsgs as $asMsg) {
$asMsg = [
'ref_msg_id' => $asMsg['id'],
Db::getId(self::FEED_TABLE) => $iFeedId,
'type' => $asMsg['messageType'],
'latitude' => $asMsg['latitude'],
'longitude' => $asMsg['longitude'],
'iso_time' => $asMsg['dateTime'], //ISO 8601 time (backup)
'site_time' => date(Db::TIMESTAMP_FORMAT, $asMsg['unixTime']), //Conversion to Site Time
'timezone' => $this->getTimeZone($asMsg['latitude'], $asMsg['longitude']), //Get message time zone
'unix_time' => $asMsg['unixTime'], //UNIX Time (backup)
'content' => $asMsg['messageContent'],
'battery_state' => $asMsg['batteryState']
];
$iMsgId = $this->oDb->selectId(self::MSG_TABLE, ['ref_msg_id'=>$asMsg['ref_msg_id']]);
if(!$iMsgId) {
//First Catch
$asMsg['posted_on'] = $sNow;
//Weather Data
$asMsg = array_merge($asMsg, $this->getWeather([$asMsg['latitude'], $asMsg['longitude']], $asMsg['unix_time']));
$this->oDb->insertRow(self::MSG_TABLE, $asMsg);
$bNewMsg = true;
}
else $this->oDb->updateRow(self::MSG_TABLE, $iMsgId, $asMsg);
}
}
}
else $this->oDb->updateRow(self::FEED_TABLE, $this->getFeedId(), ['last_update'=>$sNow]);
return $bNewMsg;
}
private function getWeather($asLatLng, $iTimeStamp) {
$sApiUrl = self::WEATHER_HOOK.'/'.$asLatLng[0].','.$asLatLng[1].'/'.$iTimeStamp.'?'.http_build_query(self::WEATHER_PARAM);
$asWeather = json_decode(file_get_contents($sApiUrl), true);
if(array_key_exists('currentConditions', $asWeather)) { //Current conditions
$sWeatherIcon = $asWeather['currentConditions']['icon'];
$sWeatherCond = $asWeather['currentConditions']['conditions'];
$sWeatherTemp = $asWeather['currentConditions']['temp'];
}
elseif($asWeather['days'][0]['icon'] != '') { //Daily Conditions
$sWeatherIcon = $asWeather['days'][0]['icon'];
$sWeatherCond = $asWeather['days'][0]['conditions'];
$sWeatherTemp = $asWeather['days'][0]['temp'];
}
else {
$sWeatherIcon = 'unknown';
}
//Get Condition Language ID
$sCondLangId = (new Translator(self::WEATHER_PARAM['lang']))->getTranslationKey($sWeatherCond);
return [
'weather_icon' => $sWeatherIcon,
'weather_cond' => $sCondLangId,
'weather_temp' => floatval($sWeatherTemp)
];
}
private function getTimeZone($iLat, $iLng) {
$asParams = [
'username' => Settings::TIMEZONE_USER,
'lat' => $iLat,
'lng' => $iLng
];
$sApiUrl = self::TIMEZONE_HOOK.'?'.http_build_query($asParams);
$asTimeZone = json_decode(file_get_contents($sApiUrl), true);
return $asTimeZone['timezoneId'] ?? Settings::TIMEZONE;
}
private function retrieveFeed() {
if($this->sRefFeedId == '') {
$sContent = '{"response":{"errors":{"error":{"code":"","text":"Feed reference ID missing","description":"Feed reference ID missing"}}}}';
}
else {
$sUrl = self::FEED_HOOK.$this->sRefFeedId.self::FEED_TYPE_JSON;
$sContent = file_get_contents($sUrl);
}
return json_decode($sContent, true);
}
private function updateField($sField, $oValue) {
$bResult = ($this->oDb->updateRow(self::FEED_TABLE, $this->getFeedId(), [$sField=>$oValue]) > 0);
$this->setFeedId($this->getFeedId());
return $bResult;
}
public function delete() {
$bSuccess = false;
$sLangId = '';
$asLangParams = [];
$asData = [];
if($this->getFeedId() > 0) {
$asData['id'] = $this->getFeedId();
$bSuccess = $this->oDb->deleteRow(self::FEED_TABLE, $this->getFeedId());
if(!$bSuccess) $sLangId = 'error.commit_db';
}
else {
$sLangId = 'error.impossible_value';
$asLangParams = [$this->getFeedId(), 'feed ID'];
}
return Livetrail::getResult($bSuccess, $sLangId, $asData, $asLangParams);
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use \Settings;
abstract class Geo extends PhpObject {
protected const EXT = '';
protected const GEO_FOLDER = 'geo';
protected const OPT_SIMPLE = 'simplification';
protected array $asTracks;
protected string $sFilePath;
public function __construct(string $sCodeName) {
parent::__construct(get_class($this), Settings::DEBUG, PhpObject::MODE_HTML);
$this->sFilePath = self::getBackEndFilePath($sCodeName);
$this->asTracks = [];
}
//Access from backend
public static function getBackendFilePath(string $sCodeName) {
return '../resources/'.self::GEO_FOLDER.'/'.$sCodeName.static::EXT;
}
//Access from frontend (/public/geo is a symlink of /resources/geo)
public static function getFrontendFilePath(string $sCodeName) {
return self::GEO_FOLDER.'/'.$sCodeName.static::EXT;
}
public function getLog() {
return $this->getCleanMessageStack(PhpObject::NOTICE_TAB);
}
}
-52
View File
@@ -1,52 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\ToolBox;
class Gpx extends Geo {
public const EXT = '.gpx';
public function __construct($sCodeName) {
parent::__construct($sCodeName);
$this->parseFile();
}
public function getTracks() {
return $this->asTracks;
}
private function parseFile() {
$this->addNotice('Parsing: '.$this->sFilePath);
if(!file_exists($this->sFilePath)) $this->addError($this->sFilePath.' file missing');
else {
$oXml = simplexml_load_file($this->sFilePath);
//Tracks
$this->addNotice('Converting '.count($oXml->trk).' tracks');
foreach($oXml->trk as $aoTrack) {
$asTrack = [
'name' => (string) $aoTrack->name,
'desc' => str_replace("\n", '', ToolBox::fixEOL((strip_tags($aoTrack->desc)))),
'cmt' => ToolBox::fixEOL((strip_tags($aoTrack->cmt))),
'color' => (string) $aoTrack->extensions->children('gpxx', true)->TrackExtension->DisplayColor,
'points'=> []
];
foreach($aoTrack->trkseg as $asSegment) {
foreach($asSegment as $asPoint) {
$asTrack['points'][] = [
'lon' => (float) $asPoint['lon'],
'lat' => (float) $asPoint['lat'],
'ele' => (int) $asPoint->ele
];
}
}
$this->asTracks[] = $asTrack;
}
//Waypoints
$this->addNotice('Ignoring '.count($oXml->wpt).' waypoints');
}
}
}
-952
View File
@@ -1,952 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\Db;
use Franzz\Objects\Main;
use Franzz\Objects\Translator;
use Franzz\Objects\ToolBox;
use \Settings;
/* Timezones
* ---------
* Site Time: Timestamp converted to the Timezone from which the user is viewing the Site (default PHP/SQL Timezone)
* Local Time: Timestamp converted to the Timezone from which the content (media/post/message) has been sent (Local Timezone stored in timezone field)
*
* - Feeds (table `feeds`):
* - last_update: timestamp in Site Time
* - Spot Messages (table `messages`):
* - unix_time: UNIX (int) in UTC
* - site_time: timestamp in Site Time
* - iso_time: raw ISO 8601 in UTC or Local Time (spot messages are unreliable, timezone is then calculated from GPS coordinates)
* - posted_on: timestamp in Site Time
* - timezone: Local Timezone
* - Medias (table `medias`):
* - posted_on: timestamp in Site Time
* - taken_on: timestamp in Site Time
* - timezone: Local Timezone
* - Posts (table `posts`):
* - site_time: timestamp in Site Time
* - timezone: Local Timezone
* - Users (table `users`):
* - timezone: Site Timezone (stored user's timezone for emails)
*/
class Livetrail extends Main {
//Database
public const POST_TABLE = 'posts';
private const REF_TYPES = ['post', 'media', 'message'];
private const FEED_CHUNK_SIZE = 15;
private const MAIL_CHUNK_SIZE = 5;
public const DEFAULT_LANG = 'en';
public const PROJECT_NAME = 'LiveTrail';
private const MAIN_PAGE = 'index';
private const VITE_APP = 'src/app.js';
private Project $oProject;
private Media $oMedia;
private User $oUser;
private Map $oMap;
public function __construct($sProcessPage, $sTimezone) {
parent::__construct($sProcessPage, true, $sTimezone);
$this->oUser = new User($this->oDb);
$this->oLang = new Translator('', self::DEFAULT_LANG);
$this->oProject = new Project($this->oDb);
$this->oMedia = new Media($this->oDb, $this->oProject);
$this->oMap = new Map($this->oDb);
}
protected function install() {
//Install DB
$this->oDb->install();
//Add first user
$iUserId = $this->oDb->insertRow(User::USER_TABLE, [
'name' => 'Admin',
'email' => 'admin@admin.com',
'language' => self::DEFAULT_LANG,
'timezone' => date_default_timezone_get(),
'subscribed'=> User::USER_SUBSCRIBED,
'clearance' => User::CLEARANCE_ADMIN
]);
$this->oUser->setUserId($iUserId);
}
protected function getSqlOptions() {
return
[
'tables' =>
[
Feed::MSG_TABLE => ['ref_msg_id', Db::getId(Feed::FEED_TABLE), 'type', 'latitude', 'longitude', 'iso_time', 'site_time', 'timezone', 'unix_time', 'content', 'battery_state', 'posted_on', 'weather_icon', 'weather_cond', 'weather_temp', 'display'],
Feed::FEED_TABLE => ['ref_feed_id', Db::getId(Feed::SPOT_TABLE), Db::getId(Project::PROJ_TABLE), 'name', 'description', 'status', 'last_update'],
Feed::SPOT_TABLE => ['ref_spot_id', 'name', 'model'],
Project::PROJ_TABLE => ['name', 'codename', 'active_from', 'active_to'],
self::POST_TABLE => [Db::getId(Project::PROJ_TABLE), Db::getId(User::USER_TABLE), 'name', 'content', 'site_time', 'timezone', 'ref_id', 'ref_type'],
Media::MEDIA_TABLE => [Db::getId(Project::PROJ_TABLE), 'filename', 'type', 'taken_on', 'posted_on', 'timezone', 'latitude', 'longitude', 'altitude', 'width', 'height', 'rotate', 'comment'],
User::USER_TABLE => ['name', 'email', 'password', 'token', 'token_exp', 'gravatar', 'language', 'timezone', 'subscribed', 'clearance'],
Map::MAP_TABLE => ['codename', 'pattern', 'token', 'tile_size', 'min_zoom', 'max_zoom', 'attribution'],
Map::MAPPING_TABLE => [Db::getId(Map::MAP_TABLE) , Db::getId(Project::PROJ_TABLE)]
],
'types' =>
[
'clearance' => 'TINYINT(1) DEFAULT '.User::CLEARANCE_USER,
'active_from' => 'TIMESTAMP DEFAULT 0',
'active_to' => 'TIMESTAMP DEFAULT 0',
'battery_state' => 'VARCHAR(10)',
'codename' => 'VARCHAR(100)',
'content' => 'LONGTEXT',
'comment' => 'LONGTEXT',
'description' => 'VARCHAR(100)',
'email' => 'VARCHAR(320) NOT NULL',
'filename' => 'VARCHAR(100) NOT NULL',
'iso_time' => 'VARCHAR(24)',
'language' => 'VARCHAR(2)',
'last_update' => 'TIMESTAMP DEFAULT 0',
'latitude' => 'DECIMAL(8,6)',
'longitude' => 'DECIMAL(9,6)',
'altitude' => 'SMALLINT',
'model' => 'VARCHAR(20)',
'name' => 'VARCHAR(100)',
'pattern' => 'VARCHAR(200) NOT NULL',
'password' => "VARCHAR(255) NOT NULL DEFAULT ''",
'posted_on' => 'TIMESTAMP DEFAULT 0',
'ref_feed_id' => 'VARCHAR(40)',
'ref_msg_id' => 'VARCHAR(15)',
'ref_spot_id' => 'VARCHAR(10)',
'ref_id' => 'INT UNSIGNED',
'ref_type' => 'VARCHAR(20)',
'rotate' => 'SMALLINT',
'site_time' => 'TIMESTAMP DEFAULT 0', //DEFAULT 0 removes auto-set to current time
'status' => 'VARCHAR(10)',
'subscribed' => 'BOOLEAN DEFAULT '.User::USER_UNSUBSCRIBED,
'taken_on' => 'TIMESTAMP DEFAULT 0',
'timezone' => 'CHAR(64) NOT NULL', //see mysql.time_zone_name
'token' => 'VARCHAR(4096)',
'token_exp' => 'TIMESTAMP DEFAULT 0',
'type' => 'VARCHAR(20)',
'unix_time' => 'INT',
'min_zoom' => 'TINYINT UNSIGNED',
'max_zoom' => 'TINYINT UNSIGNED',
'attribution' => 'VARCHAR(100)',
'gravatar' => 'LONGTEXT',
'weather_icon' => 'VARCHAR(30)',
'weather_cond' => 'VARCHAR(30)',
'weather_temp' => 'DECIMAL(3,1)',
'tile_size' => 'SMALLINT UNSIGNED DEFAULT 256',
'width' => 'INT',
'height' => 'INT',
'display' => 'BOOLEAN DEFAULT '.Feed::MSG_DISPLAYED
],
'constraints' =>
[
Feed::MSG_TABLE => ['UNIQUE KEY `uni_ref_msg_id` (`ref_msg_id`)', 'INDEX(`ref_msg_id`)'],
Feed::FEED_TABLE => ['UNIQUE KEY `uni_ref_feed_id` (`ref_feed_id`)', 'INDEX(`ref_feed_id`)'],
Feed::SPOT_TABLE => ['UNIQUE KEY `uni_ref_spot_id` (`ref_spot_id`)', 'INDEX(`ref_spot_id`)'],
Project::PROJ_TABLE => 'UNIQUE KEY `uni_proj_name` (`codename`)',
Media::MEDIA_TABLE => 'UNIQUE KEY `uni_file_name` (`filename`)',
User::USER_TABLE => 'UNIQUE KEY `uni_email` (`email`)',
Map::MAP_TABLE => 'UNIQUE KEY `uni_map_name` (`codename`)',
Map::MAPPING_TABLE => 'default_on_generic_map_only CHECK (`default_map` = 0 OR `id_project` IS NULL)'
],
'cascading_delete' =>
[
Feed::SPOT_TABLE => [Feed::FEED_TABLE],
Feed::FEED_TABLE => [Feed::MSG_TABLE],
Project::PROJ_TABLE => [Feed::FEED_TABLE, Media::MEDIA_TABLE, self::POST_TABLE, Map::MAPPING_TABLE],
Map::MAP_TABLE => [Map::MAPPING_TABLE]
]
];
}
public function getAppMainPage(string $sCsrfToken='') {
$asViteAssets = $this->getViteAssets();
return parent::getMainPage(
[
'projects' => $this->oProject->getProjects(),
'user' => $this->oUser->getUserInfo(),
'consts' => [
'modes' => Project::MODES,
'clearances' => User::CLEARANCES,
'default_timezone' => Settings::TIMEZONE,
'default_maps' => $this->oMap->getProjectMaps(-1),
'chunk_size' => self::FEED_CHUNK_SIZE,
'hash_sep' => '-',
'title' => self::PROJECT_NAME,
'default_page' => 'project',
'csrf_token' => $sCsrfToken
]
],
self::MAIN_PAGE,
[
'tags' => [
'language' => $this->oLang->getLanguage(),
'title' => self::PROJECT_NAME,
'app_entry' => $asViteAssets['app']
],
'instances' => [
'css' => $asViteAssets['css'],
'module' => $asViteAssets['module']
]
]
);
}
private function getViteAssets() {
$asManifest = json_decode(file_get_contents(__DIR__.'/../public/.vite/manifest.json'), true);
$asAppImport = $asManifest[self::VITE_APP];
//Recursive search for chunk imports
$asImports = [];
$asSeenImports = [self::VITE_APP => true];
$this->appendViteImportedChunks($asManifest, $asAppImport, $asSeenImports, $asImports);
//CSS
$asCssFiles = [];
foreach(array_merge([$asAppImport], $asImports) as $asChunk) {
foreach($asChunk['css'] ?? [] as $sCssFile) $asCssFiles[] = $sCssFile;
}
//Modules
$asModuleFiles = [];
foreach($asImports as $asImport) {
if(str_ends_with($asImport['file'] ?? '', '.js')) $asModuleFiles[] = $asImport['file'];
}
return [
'app' => $asAppImport['file'],
'css' => $this->getViteAssetInstances($asCssFiles),
'module' => $this->getViteAssetInstances($asModuleFiles)
];
}
private function appendViteImportedChunks($asManifest, $asChunk, &$asSeenImports, &$asImports) {
foreach($asChunk['imports'] ?? [] as $sImport) {
if(isset($asSeenImports[$sImport]) || !isset($asManifest[$sImport])) continue;
$asSeenImports[$sImport] = true;
$this->appendViteImportedChunks($asManifest, $asManifest[$sImport], $asSeenImports, $asImports);
$asImports[] = $asManifest[$sImport];
}
}
private function getViteAssetInstances($asFilePaths) {
return array_map(
function($sFilePath) { return ['filename' => $sFilePath]; },
$asFilePaths
);
}
public function checkUserClearance($iClearance) {
return $this->oUser->checkUserClearance($iClearance);
}
/* Managing projects */
public function setProjectId($iProjectId=0) {
$this->oProject->setProjectId($iProjectId);
}
public function updateProject() {
$bNewMsg = false;
$bSuccess = true;
$sLangId = '';
//Update all feeds belonging to the project
$asFeeds = $this->oProject->getFeedIds();
foreach($asFeeds as $iFeedId) {
$oFeed = new Feed($this->oDb, $iFeedId);
$bNewMsg = $bNewMsg || $oFeed->checkUpdateFeed($this->oProject->getMode());
}
//Send Update Email
if($bNewMsg) {
$bSuccess = $this->sendEmail();
$sLangId = $bSuccess?'email.sent':'email.failure';
}
else $sLangId = 'spot.no_new_msg';
return self::getJsonResult($bSuccess, $sLangId);
}
private function sendEmail() {
$oEmail = new Email($this->asContext['serv_name'], 'email.update');
$oEmail->setDestInfo($this->oUser->getSubscribedUsersInfo());
//Add Position
$asSpotMessages = $this->getSpotMessages([$this->oProject->getLastMessageId($this->getFeedConstraints(Feed::MSG_TABLE))]);
$asLastMessage = array_shift($asSpotMessages);
$oEmail->oTemplate->setTags($asLastMessage);
$oEmail->oTemplate->setTag('date_time', 'time:'.$asLastMessage['unix_time'], 'd/m/Y, H:i');
//Add latest news feed
$asNews = $this->getNextFeed(0, true);
$iPostCount = 0;
foreach($asNews as $asPost) {
if($asPost['type'] != 'message') {
$oEmail->oTemplate->newInstance('news');
$oEmail->oTemplate->setInstanceTags('news', [
'local_server' => $this->asContext['serv_name'],
'project' => $this->oProject->getProjectCodeName(),
'type' => $asPost['type'],
'id' => $asPost['id_'.$asPost['type']]]
);
$oEmail->oTemplate->addInstance($asPost['type'], $asPost);
$oEmail->oTemplate->setInstanceTag($asPost['type'], 'local_server', $this->asContext['serv_name']);
$iPostCount++;
}
if($iPostCount == self::MAIL_CHUNK_SIZE) break;
}
return $oEmail->send();
}
public function getMarkers($asMessageIds=[], $asMediaIds=[], $bInternal=false) {
//Get messages
$asMessages = $this->getSpotMessages($asMessageIds);
foreach($asMessages as &$asMessage) {
$asMessage['id'] = $asMessage[Db::getId(Feed::MSG_TABLE)];
$asMessage['type'] = 'message';
$asMessage['subtype'] = 'message';
}
//Get Geo-positioned Medias
//FIXME Make more efficient than requesting images twice from DB
$asMedias = $this->getMedias('taken_on', $asMediaIds);
$asGeoMedias = $this->getMedias('posted_on', $asMediaIds, true);
foreach($asGeoMedias as &$asGeoMedia) {
$iId = $asGeoMedia[Db::getId(Media::MEDIA_TABLE)];
unset($asGeoMedia[Db::getId(Media::MEDIA_TABLE)]);
$asGeoMedia['id'] = $iId;
$asGeoMedia['type'] = 'media';
$asGeoMedia['medias'] = array_values(array_filter($asMedias, function($asMedia) use ($iId) {
return $asMedia['id_media'] == $iId;
}));
}
//Assign medias to closest message
if(!empty($asMessages)) {
usort($asMessages, function($a, $b) {return (int) $a['unix_time'] <=> (int) $b['unix_time'];});
usort($asMedias, function($a, $b) {return (int) $a['unix_time'] <=> (int) $b['unix_time'];});
$iIndex = 0;
$iMaxIndex = count($asMessages) - 1;
foreach($asMedias as $asMedia) {
while($iIndex <= $iMaxIndex && $asMedia['unix_time'] > $asMessages[$iIndex]['unix_time']) $iIndex++;
//All medias before first message or after last message are assigned to first/last message respectively
if($iIndex == 0) $iMsgIndex = $iIndex;
elseif($iIndex > $iMaxIndex) $iMsgIndex = $iMaxIndex;
else {
$iHalfWayPoint = ($asMessages[$iIndex - 1]['unix_time'] + $asMessages[$iIndex]['unix_time'])/2;
$iMsgIndex = ($asMedia['unix_time'] >= $iHalfWayPoint)?$iIndex:($iIndex - 1);
}
$asMessages[$iMsgIndex]['medias'][] = $asMedia;
}
}
//Combine markers
$asMarkers = [...$asMessages, ...$asGeoMedias];
usort($asMarkers, function($a, $b) {return (int) $a['unix_time'] <=> (int) $b['unix_time'];});
$asResult = [
'markers' => $asMarkers,
'maps' => $this->oMap->getProjectMaps($this->oProject->getProjectId())
];
return $bInternal?$asResult:self::getJsonResult(true, '', $asResult);
}
public function getLastUpdate() {
$asLastUpdate = [];
$this->addTimeStamp($asLastUpdate, $this->oProject->getLastUpdate());
return self::getJsonResult(true, '', $asLastUpdate);
}
public function login($sEmail, $sPassword, $sNickName) {
$asResult = $this->oUser->login($sEmail, $sPassword, $this->oLang->getLanguage(), date_default_timezone_get(), $sNickName);
if($asResult['result'] && $asResult['data']['subscribe']) return $this->subscribe();
else return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $this->oUser->getUserInfo(), $asResult['desc_lang_params']);
}
public function logout() {
$asResult = $this->oUser->logout();
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], User::DEFAULT_USER, $asResult['desc_lang_params']);
}
public function subscribe() {
$asResult = $this->oUser->setSubscription(true);
$asUserInfo = $this->oUser->getUserInfo();
if($asResult['result'] && !Settings::DEBUG) {
$oConfEmail = new Email($this->asContext['serv_name'], 'email.confirmation');
$oConfEmail->setDestInfo($asUserInfo);
$oConfEmail->send();
}
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asUserInfo, $asResult['desc_lang_params']);
}
public function unsubscribe() {
$asResult = $this->oUser->setSubscription(false);
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $this->oUser->getUserInfo(), $asResult['desc_lang_params']);
}
private function getSpotMessages($asMsgIds=[]) {
$asConstraints = $this->getFeedConstraints(Feed::MSG_TABLE);
if(!empty($asMsgIds)) {
$asConstraints['constraint'][Db::getId(Feed::MSG_TABLE)] = $asMsgIds;
$asConstraints['constOpe'][Db::getId(Feed::MSG_TABLE)] = 'IN';
}
$asCombinedMessages = [];
//Get messages from all feeds belonging to the project
$asFeeds = $this->oProject->getFeedIds();
foreach($asFeeds as $iFeedId) {
$oFeed = new Feed($this->oDb, $iFeedId);
$asMessages = $oFeed->getMessages($asConstraints);
foreach($asMessages as $asMessage) {
$asMessage['latitude'] = floatval($asMessage['latitude']);
$asMessage['longitude'] = floatval($asMessage['longitude']);
$asMessage['lat_dms'] = self::decToDms($asMessage['latitude'], 'lat');
$asMessage['lon_dms'] = self::decToDms($asMessage['longitude'], 'lon');
$asMessage['displayed_id'] = $asMessage[Db::getId(Feed::MSG_TABLE)];
$asMessage['static_img_url'] = $this->oMap->getMapUrl('static', ['x'=>$asMessage['longitude'], 'y'=>$asMessage['latitude']]);
$asMessage['marker_img_url'] = $this->oMap->getMapUrl('static_marker', ['x'=>$asMessage['longitude'], 'y'=>$asMessage['latitude']]);
$this->addTimeStamp($asMessage, $asMessage['unix_time'], $asMessage['timezone']);
$asCombinedMessages[] = $asMessage;
}
}
return $asCombinedMessages;
}
/**
* Get valid medias based on $sTimeRefField:
* - taken_on: Date/time on which the media was taken
* - posted_on: Date/time on which the media was uploaded
* @param String $sTimeRefField Field to calculate relative times: 'taken_on' or 'posted_on'
* @return Array Medias info
*/
private function getMedias($sTimeRefField, $asMediaIds=[], $bOnlyGeoMedia=false) {
//Constraints
$asConstraints = $this->getFeedConstraints(Media::MEDIA_TABLE, $sTimeRefField);
if(!empty($asMediaIds)) {
$asConstraints['constOpe'][Db::getId(Media::MEDIA_TABLE)] = 'IN';
$asConstraints['constraint'][Db::getId(Media::MEDIA_TABLE)] = $asMediaIds;
}
if($bOnlyGeoMedia) {
$asConstraints['constOpe']['latitude'] = ' IS NOT ';
$asConstraints['constraint']['latitude'] = 'NULL';
$asConstraints['constOpe']['longitude'] = ' IS NOT ';
$asConstraints['constraint']['longitude'] = 'NULL';
}
$asMedias = $this->oMedia->getMediasInfo($asConstraints);
foreach($asMedias as &$asMedia) {
$asMedia['displayed_id'] = $asMedia[Db::getId(Media::MEDIA_TABLE)];
$this->addTimeStamp($asMedia, strtotime($asMedia[$sTimeRefField]), $asMedia['timezone']);
$this->addTimeStamp($asMedia, strtotime($asMedia['taken_on']), $asMedia['timezone'], 'taken_on');
$this->addTimeStamp($asMedia, strtotime($asMedia['posted_on']), $asMedia['timezone'], 'posted_on');
if($asMedia['latitude'] != '' && $asMedia['longitude'] != '') {
$asMedia['lat_dms'] = self::decToDms($asMedia['latitude'], 'lat');
$asMedia['lon_dms'] = self::decToDms($asMedia['longitude'], 'lon');
}
unset($asMedia['taken_on']);
unset($asMedia['posted_on']);
}
return $asMedias;
}
private function getPosts($asPostIds=[], $bResolveRef=true) {
$asInfo = [
'select' => [Db::getFullColumnName(self::POST_TABLE, '*'), 'gravatar'],
'from' => self::POST_TABLE,
'join' => [User::USER_TABLE => Db::getId(User::USER_TABLE)]
];
$asInfo = array_merge($asInfo, $this->getFeedConstraints(self::POST_TABLE));
if(!empty($asPostIds)) {
$asInfo['constraint'][Db::getId(self::POST_TABLE)] = $asPostIds;
$asInfo['constOpe'][Db::getId(self::POST_TABLE)] = 'IN';
}
$asPosts = $this->oDb->selectRows($asInfo);
foreach($asPosts as &$asPost) {
$iUnixTimeStamp = strtotime($asPost['site_time']); //assumes site timezone
$asPost['formatted_name'] = Toolbox::mb_ucwords($asPost['name']);
unset($asPost[Db::getId(User::USER_TABLE)]);
$this->addTimeStamp($asPost, $iUnixTimeStamp, $asPost['timezone']);
if($bResolveRef && !empty($asPost['ref_type']) && !empty($asPost['ref_id'])) {
switch($asPost['ref_type']) {
case 'post': $asRefs = $this->getPosts([$asPost['ref_id']], false); break;
case 'media': $asRefs = $this->getMedias('posted_on', [$asPost['ref_id']]); break;
case 'message': $asRefs = $this->getSpotMessages([$asPost['ref_id']]); break;
default: $asRefs = [];
}
$asPost['ref_item'] = reset($asRefs);
$asPost['ref_item']['type'] = $asPost['ref_type'];
$asPost['ref_item']['id'] = $asPost['ref_id'];
}
unset($asPost['ref_id']);
unset($asPost['ref_type']);
}
return $asPosts;
}
private function addTimeStamp(&$asData, $iTime, $sTimeZone='', $sPrefix='') {
if($sPrefix != '') $sPrefix = $sPrefix.'_';
$asData[$sPrefix.'unix_time'] = (int) $iTime;
$asData[$sPrefix.'relative_time'] = Toolbox::getDateTimeDesc($iTime, $this->oLang->getLanguage());
$asData[$sPrefix.'formatted_time'] = $this->getTimeFormat($iTime);
if($sTimeZone != '') {
$asData[$sPrefix.'formatted_time_local'] = $this->getTimeFormat($iTime, $sTimeZone);
$asData[$sPrefix.'day_offset'] = self::getTimeZoneDayOffset($iTime, $sTimeZone);
}
}
private function getFeedConstraints($sType, $sTimeField='site_time', $sReturnFormat='array') {
$asConsArray = [];
$sConsSql = '';
$asActPeriod = $this->oProject->getActivePeriod();
//Filter on Project ID
$sConsSql = 'WHERE '.Db::getId(Project::PROJ_TABLE).' = '.$this->oProject->getProjectId();
$asConsArray = [
'constraint'=> [Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId()],
'constOpe' => [Db::getId(Project::PROJ_TABLE) => '=']
];
//Time Filter
switch($sType) {
case Feed::MSG_TABLE:
$asConsArray['constraint'][$sTimeField] = $asActPeriod;
$asConsArray['constOpe'][$sTimeField] = 'BETWEEN';
$asConsArray['constraint']['display'] = Feed::MSG_DISPLAYED;
$asConsArray['constOpe']['display'] = '=';
$sConsSql .= ' AND '.$sTimeField." BETWEEN '".$asActPeriod['from']."' AND '".$asActPeriod['to']."' AND display = ".Feed::MSG_DISPLAYED;
break;
case Media::MEDIA_TABLE:
$asConsArray['constraint'][$sTimeField] = $asActPeriod['to'];
$asConsArray['constOpe'][$sTimeField] = '<=';
$sConsSql .= ' AND '.$sTimeField." <= '".$asActPeriod['to']."'";
break;
case self::POST_TABLE:
$asConsArray['constraint'][$sTimeField] = $asActPeriod['to'];
$asConsArray['constOpe'][$sTimeField] = '<=';
$sConsSql .= ' AND '.$sTimeField." <= '".$asActPeriod['to']."'";
break;
}
return ($sReturnFormat=='array')?$asConsArray:$sConsSql;
}
public function getNewFeed($iRefIdFirst) {
$asResult = [];
$sLangId = '';
if($this->oProject->isEditable()) {
$asMessageIds = $asMediaIds = [];
//New Feed Items
$asResult = $this->getFeed($iRefIdFirst, '>', 'DESC');
foreach($asResult['feed'] as $asItem) {
switch($asItem['type']) {
case 'message':
$asMessageIds[] = $asItem['id'];
break;
case 'media':
$asMediaIds[] = $asItem['id'];
break;
}
}
//New Markers
$asMarkers = $this->getMarkers(
empty($asMessageIds)?[0]:$asMessageIds,
empty($asMediaIds)?[0]:$asMediaIds,
true
);
$asResult = array_merge($asResult, $asMarkers);
}
else $sLangId = 'project.modes.histo';
return self::getJsonResult(true, $sLangId, $asResult);
}
public function getNextFeed($iRefIdLast=0, $bInternal=false) {
if($this->oProject->getMode() == Project::MODE_HISTO) {
$sDirection = '>';
$sSort = 'ASC';
}
else {
$sDirection = '<';
$sSort = 'DESC';
}
$asResult = $this->getFeed($iRefIdLast, $sDirection, $sSort);
return $bInternal?$asResult['feed']:self::getJsonResult(true, '', $asResult);
}
private function getFeed($iRefId, $sDirection, $sSort) {
$sRefId = is_scalar($iRefId) && preg_match('/^\d+(?:\.\d+)?$/D', (string) $iRefId) ? (string) $iRefId : '0';
$sDirection = ($sDirection === '>')?'>':'<';
$sSort = ($sSort === 'ASC')?'ASC':'DESC';
$sProjectIdField = Db::getId(Project::PROJ_TABLE);
$sMsgIdField = Db::getId(Feed::MSG_TABLE);
$sMediaIdField = Db::getId(Media::MEDIA_TABLE);
$sPostIdField = Db::getId(self::POST_TABLE);
$sFeedIdField = Db::getId(Feed::FEED_TABLE);
$sQuery = implode(' ', [
'SELECT type, id, ref',
'FROM (',
"SELECT {$sProjectIdField}, {$sMsgIdField} AS id, 'message' AS type, CONCAT(UNIX_TIMESTAMP(site_time), '.0', {$sMsgIdField}) AS ref",
'FROM '.Feed::MSG_TABLE,
'INNER JOIN '.Feed::FEED_TABLE." USING({$sFeedIdField})",
$this->getFeedConstraints(Feed::MSG_TABLE, 'site_time', 'sql'),
'UNION',
"SELECT {$sProjectIdField}, {$sMediaIdField} AS id, 'media' AS type, CONCAT(UNIX_TIMESTAMP(posted_on), '.1', {$sMediaIdField}) AS ref",
'FROM '.Media::MEDIA_TABLE,
$this->getFeedConstraints(Media::MEDIA_TABLE, 'posted_on', 'sql'),
'UNION',
"SELECT {$sProjectIdField}, {$sPostIdField} AS id, 'post' AS type, CONCAT(UNIX_TIMESTAMP(site_time), '.2', {$sPostIdField}) AS ref",
'FROM '.self::POST_TABLE,
$this->getFeedConstraints(self::POST_TABLE, 'site_time', 'sql'),
') AS items',
($sRefId !== '0')?('WHERE ref '.$sDirection.' '.$sRefId):'',
'ORDER BY ref '.$sSort,
'LIMIT '.self::FEED_CHUNK_SIZE
]);
//Get new chunk
$asItems = $this->oDb->getArrayQuery($sQuery, true);
//Update Reference Point with latest/earliest value
$iRefIdFirst = $iRefIdLast = 0;
if(!empty($asItems)) {
$iRefIdLast = end($asItems)['ref'];
$iRefIdFirst = reset($asItems)['ref'];
}
//Sort Table IDs by type & Get attributes
$asFeedIds = ['message'=>[], 'media'=>[], 'post'=>[]];
foreach($asItems as $asItem) {
$asFeedIds[$asItem['type']][$asItem['id']] = $asItem;
}
$asFeedAttrs = [
'message' => empty($asFeedIds['message'])?[]:$this->getSpotMessages(array_keys($asFeedIds['message'])),
'media' => empty($asFeedIds['media'])?[]:$this->getMedias('posted_on', array_keys($asFeedIds['media'])),
'post' => empty($asFeedIds['post'])?[]:$this->getPosts(array_keys($asFeedIds['post']))
];
//Replace Array Key with Item ID
$asFeeds = [];
foreach($asFeedAttrs as $sType=>$asFeedAttr) {
foreach($asFeedAttr as $asFeed) {
$asFeeds[$sType][$asFeed['id_'.$sType]] = $asFeed;
}
}
//Assign
foreach($asItems as &$asItem) {
$asItem = array_merge($asFeeds[$asItem['type']][$asItem['id']], $asItem);
}
return ['ref_id_last'=>$iRefIdLast, 'ref_id_first'=>$iRefIdFirst, 'sort'=>$sSort, 'feed'=>$asItems];
}
public function addPost($sName, $sPost, $sRefType='', $iRefId=0) {
$iPostId = 0;
$sLangId = '';
if($this->oProject->isEditable()) {
$bHasRef = ($iRefId > 0) && in_array($sRefType, self::REF_TYPES, true);
$asData = [
Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId(),
'name' => mb_strtolower(trim($sName)),
'content' => trim($sPost),
'site_time' => date(Db::TIMESTAMP_FORMAT), //Now in Site Time
'timezone' => date_default_timezone_get(), //Site Time Zone
'ref_id' => $bHasRef?$iRefId:'NULL',
'ref_type' => $bHasRef?$sRefType:'NULL'
];
if($this->oUser->getUserId() > 0) $asData[Db::getId(User::USER_TABLE)] = $this->oUser->getUserId();
$iPostId = $this->oDb->insertRow(self::POST_TABLE, $asData);
if($iPostId == 0) $sLangId = 'error.commit_db';
$this->oUser->updateNickname($sName);
}
else $sLangId = 'project.modes.histo';
return self::getJsonResult(($iPostId > 0), $sLangId);
}
public function upload() {
$oUploader = new Uploader($this->oMedia);
return $oUploader->sBody;
}
public function addComment($iMediaId, $sComment) {
$oMedia = new Media($this->oDb, $this->oProject, $iMediaId);
$asResult = $oMedia->setComment($sComment);
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']);
}
public function addPosition($sLat, $sLng, $iTimestamp) {
$oFeed = new Feed($this->oDb, $this->oProject->getFeedIds()[0]);
$bSuccess = ($oFeed->addManualPosition($sLat, $sLng, $iTimestamp) > 0);
if($bSuccess) {
$bSuccess = $this->sendEmail();
$sLangId = $bSuccess?'email.sent':'email.failure';
}
else $sLangId = 'error.commit_db';
return self::getJsonResult($bSuccess, $sLangId);
}
public function getAdminSettings() {
$oFeed = new Feed($this->oDb);
$asData = [
'project' => $this->oProject->getProjects(),
'feed' => $oFeed->getFeeds(),
'spot' => $oFeed->getSpots(),
'user' => $this->oUser->getSubscribedUsersInfo()
];
foreach($asData['project'] as &$asProject) {
$asProject['active_from'] = substr($asProject['active_from'], 0, 10);
$asProject['active_to'] = substr($asProject['active_to'], 0, 10);
}
return self::getJsonResult(true, '', $asData);
}
public function setAdminSettings($sType, $iId, $sField, $sValue) {
$bSuccess = false;
$sLangId = '';
$asLangParams = [];
$asResult = [];
if($this->oDb->isId($sField) && $sValue <= 0) return self::getJsonResult(false, 'error.impossible_value', [], [$sValue, $sField]);
switch($sType) {
case 'project':
$oProject = new Project($this->oDb, $iId);
switch($sField) {
case 'name':
$bSuccess = $oProject->setProjectName($sValue);
break;
case 'codename':
$bSuccess = $oProject->setProjectCodeName($sValue);
break;
case 'active_from':
$bSuccess = $oProject->setActivePeriod($sValue.' 00:00:00', 'from');
break;
case 'active_to':
$bSuccess = $oProject->setActivePeriod($sValue.' 23:59:59', 'to');
break;
default:
$sLangId = 'error.unknown_field';
$asLangParams = [$sField];
}
//Identify missing GPX file
$sProjectCodeName = ($sField == 'codename')?$sValue:$oProject->getProjectCodeName();
if(!Converter::hasGpxFile($sProjectCodeName)) {
$bSuccess = true;
$sLangId = 'error.file_missing';
$asLangParams = ['GPX', $sProjectCodeName.Gpx::EXT];
}
$asResult = $oProject->getProject();
$asResult['active_from'] = substr($asResult['active_from'], 0, 10);
$asResult['active_to'] = substr($asResult['active_to'], 0, 10);
break;
case 'feed':
$oFeed = new Feed($this->oDb, $iId);
switch($sField) {
case 'ref_feed_id':
$bSuccess = $oFeed->setRefFeedId($sValue);
break;
case 'id_spot':
$bSuccess = $oFeed->setSpotId($sValue);
break;
case 'id_project':
$bSuccess = $oFeed->setProjectId($sValue);
break;
default:
$sLangId = 'error.unknown_field';
$asLangParams = [$sField];
}
$asResult = $oFeed->getFeed();
break;
case 'user':
switch($sField) {
case 'clearance':
$asReturnCode = $this->oUser->setUserClearance($iId, $sValue);
$bSuccess = $asReturnCode['result'];
$sLangId = $asReturnCode['desc_lang_id'];
$asLangParams = $asReturnCode['desc_lang_params'];
break;
default:
$sLangId = 'error.unknown_field';
$asLangParams = [$sField];
}
$asResult = $this->oUser->getUserById($iId);
break;
}
if(!$bSuccess && $sLangId=='') $sLangId = 'error.commit_db';
return self::getJsonResult($bSuccess, $sLangId, [$sType=>[$asResult]], $asLangParams);
}
public function createAdminSettings($sType) {
$bSuccess = false;
$sLangId = '';
$asResult = [];
switch($sType) {
case 'project':
$oProject = new Project($this->oDb);
$iNewProjectId = $oProject->createProjectId();
$oFeed = new Feed($this->oDb);
$oFeed->createFeedId($iNewProjectId);
$bSuccess = $iNewProjectId > 0;
$asResult = [
'project' => [$oProject->getProject()],
'feed' => [$oFeed->getFeed()]
];
break;
case 'feed':
$oFeed = new Feed($this->oDb);
$iNewFeedId = $oFeed->createFeedId($this->oProject->getProjectId());
$bSuccess = $iNewFeedId > 0;
$asResult = [
'feed' => [$oFeed->getFeed()]
];
break;
}
if(!$bSuccess && $sLangId=='') $sLangId = 'error.commit_db';
return self::getJsonResult($bSuccess, $sLangId, $asResult);
}
public function deleteAdminSettings($sType, $iId) {
$bSuccess = false;
$sLangId = '';
$asLangParams = [];
$asResult = [];
switch($sType) {
case 'project':
$oProject = new Project($this->oDb, $iId);
$asResult = $oProject->delete();
$sLangId = $asResult['project'][0]['desc_lang_id'];
$asLangParams = $asResult['project'][0]['desc_lang_params'];
$bSuccess = $asResult['project'][0]['del'];
break;
case 'feed':
$oFeed = new Feed($this->oDb, $iId);
$asResult = ['feed' => [$oFeed->delete()]];
$sLangId = $asResult['feed'][0]['desc_lang_id'];
$asLangParams = $asResult['feed'][0]['desc_lang_params'];
$bSuccess = $asResult['feed'][0]['result'];
break;
}
if(!$bSuccess && $sLangId=='') $sLangId = 'error.commit_db';
return self::getJsonResult($bSuccess, $sLangId, $asResult, $asLangParams);
}
public static function decToDms($dValue, $sType) {
if($sType=='lat') $sDirection = ($dValue >= 0)?'N':'S'; //Latitude
else $sDirection = ($dValue >= 0)?'E':'W'; //Longitude
$dLeft = abs($dValue);
//Degrees
$iDegree = floor($dLeft);
$dLeft -= $iDegree;
//Minutes
$iMinute = floor($dLeft * 60);
$dLeft -= $iMinute / 60;
//Seconds
$fSecond = round($dLeft * 3600, 1);
return
$iDegree.'°'.
self::getNumberWithLeadingZeros($iMinute, 2, 0)."'".
self::getNumberWithLeadingZeros($fSecond, 2, 1).'"'.
$sDirection;
}
public static function getNumberWithLeadingZeros($fValue, $iNbLeadingZeros, $iNbDigits) {
$sDecimalSeparator = '.';
if($iNbDigits > 0) $iNbLeadingZeros += mb_strlen($sDecimalSeparator) + $iNbDigits;
$sPattern = '%0'.$iNbLeadingZeros.$sDecimalSeparator.$iNbDigits.'f';
return sprintf($sPattern, $fValue);
}
public function getTimeFormat($iTime, $sTimeZone='') {
if($sTimeZone == '') $sTimeZone = date_default_timezone_get();
$oDate = new \DateTime('@'.$iTime);
$oDate->setTimezone(new \DateTimeZone($sTimeZone));
$sDate = $oDate->format('d/m/Y');
$sTime = $oDate->format('H:i');
return $this->oLang->getTranslation('time.date_time', [$sDate, $sTime]);
}
public static function getTimeZoneDayOffset($iTime, $sLocalTimeZone) {
$sSiteTimeZone = date_default_timezone_get();
$iLocalDate = (int) (new \DateTime('@'.$iTime))->setTimezone(new \DateTimeZone($sLocalTimeZone))->format('Ymd');
$iSiteDate = (int) (new \DateTime('@'.$iTime))->setTimezone(new \DateTimeZone($sSiteTimeZone ))->format('Ymd');
return ($iLocalDate == $iSiteDate)?'0':(($iLocalDate < $iSiteDate)?'+1':'-1');
}
public static function getTimeZoneFromDate($sDate) {
$sTimeZone = null;
preg_match('/(?<timezone>(\+|\-)\d{2}:?(\d{2}|))$/', $sDate, $asMatch);
if(array_key_exists('timezone', $asMatch)) {
$sTimeZone = $asMatch['timezone'];
//Complete short form: +12 => +1200
if(strlen($sTimeZone) == 3) $sTimeZone .= '00';
//Add colon: +1200 => +12:00
if(!strpos($sTimeZone, ':')) $sTimeZone = substr_replace($sTimeZone, ':', 3, 0);
}
return $sTimeZone;
}
}
-65
View File
@@ -1,65 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
class Map extends PhpObject {
public const MAP_TABLE = 'maps';
public const MAPPING_TABLE = 'mappings';
private Db $oDb;
private $asMaps;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->asMaps = [];
}
private function setMaps() {
$asMaps = $this->oDb->selectRows(['from'=>self::MAP_TABLE]);
foreach($asMaps as $asMap) $this->asMaps[$asMap['codename']] = $asMap;
}
private function getMaps($sCodeName='') {
if(empty($this->asMaps)) $this->setMaps();
return ($sCodeName=='')?$this->asMaps:$this->asMaps[$sCodeName];
}
public function getProjectMaps($iProjectId) {
$asMappings = $this->oDb->selectRows(
[
'select' => [Db::getId(self::MAP_TABLE), 'default_map'],
'from' => self::MAPPING_TABLE,
'constraint'=> ["IFNULL(id_project, {$iProjectId})" => $iProjectId]
],
Db::getId(self::MAP_TABLE)
);
$asProjectMaps = [];
foreach($this->getMaps() as $asMap) {
if(array_key_exists($asMap['id_map'], $asMappings)) {
$asMap['default_map'] = $asMappings[$asMap['id_map']];
$asProjectMaps[] = $asMap;
}
}
return $asProjectMaps;
}
public function getMapUrl($sCodeName, $asParams) {
$asMap = $this->getMaps($sCodeName);
$asParams['token'] = $asMap['token'];
return self::populateParams($asMap['pattern'], $asParams);
}
private static function populateParams($sUrl, $asParams) {
foreach($asParams as $sParam=>$sValue) {
$sUrl = str_replace('{'.$sParam.'}', $sValue, $sUrl);
}
return $sUrl;
}
}
-327
View File
@@ -1,327 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
use Franzz\Objects\ToolBox;
class Media extends PhpObject {
//DB Tables
public const MEDIA_TABLE = 'medias';
//Media folders (works because /public/files is a symlink of /files)
public const MEDIA_FOLDER = 'files';
public const THUMB_FOLDER = self::MEDIA_FOLDER.'/thumbs';
private const THUMB_MAX_WIDTH = 400;
private Db $oDb;
private Project $oProject;
private $asMedia;
private $asMedias;
private $iMediaId;
public function __construct(Db &$oDb, Project &$oProject, $iMediaId=0) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->oProject = &$oProject;
$this->asMedia = [];
$this->asMedias = [];
$this->setMediaId($iMediaId);
}
public function setMediaId($iMediaId) {
$this->iMediaId = $iMediaId;
}
public function getMediaId() {
return $this->iMediaId;
}
public function getProjectCodeName() {
return $this->oProject->getProjectCodeName();
}
public function setComment($sComment) {
$sLangId = '';
$asData = [];
if($this->iMediaId > 0) {
$bResult = $this->oDb->updateRow(self::MEDIA_TABLE, $this->iMediaId, ['comment'=>$sComment]);
if(!$bResult) $sLangId = 'error.commit_db';
else $asData = $this->getInfo();
}
else $sLangId = 'media.no_id';
return Livetrail::getResult(($sLangId==''), $sLangId, $asData);
}
public function getMediasInfo($oMediaIds=null) {
$bOwnMedia = is_numeric($oMediaIds); //1 value
$bConstraintArray = is_array($oMediaIds); //Custom Constraints
if($bOwnMedia && empty($this->asMedia) || !$bOwnMedia && empty($this->asMedias) || $bConstraintArray) {
if($this->oProject->getProjectId()) {
$asParams = [
'select' => [Db::getId(self::MEDIA_TABLE), 'filename', 'taken_on', 'posted_on', 'timezone', 'latitude', 'longitude', 'altitude', 'width', 'height', 'rotate', 'type AS subtype', 'comment'],
'from' => self::MEDIA_TABLE,
'constraint'=> [Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId()]
];
if($bOwnMedia) $asParams['constraint'][Db::getId(self::MEDIA_TABLE)] = $oMediaIds;
if($bConstraintArray) $asParams = array_merge($asParams, $oMediaIds);
$asMedias = $this->oDb->selectRows($asParams);
foreach($asMedias as &$asMedia) {
$asMedia['media_path'] = self::getMediaPath($asMedia['filename']);
$asMedia['thumb_path'] = $this->getMediaThumbnail($asMedia['filename']);
}
if(!empty($asMedias) && !$bConstraintArray) {
if($bOwnMedia) $this->asMedia = array_shift($asMedias);
else $this->asMedias = $asMedias;
}
}
}
return $bOwnMedia?$this->asMedia:$asMedias ?? [];
}
public function getInfo() {
return $this->getMediasInfo($this->iMediaId);
}
public function isProjectEditable() {
return $this->oProject->isEditable();
}
public function addMedia($sMediaName, $sMethod='upload') {
$sLangId = '';
$asParams = [];
if(!$this->isProjectEditable() && $sMethod!='sync') {
$sLangId = 'upload.mode_archived';
$asParams[] = $this->oProject->getProjectCodeName();
}
elseif($this->oDb->pingValue(self::MEDIA_TABLE, ['filename'=>$sMediaName]) && $sMethod!='sync') {
$sLangId = 'upload.media.exists';
$asParams[] = $sMediaName;
}
else {
$asMediaInfo = $this->getMediaInfoFromFile($sMediaName);
//Converting times to Site Time Zone, by using date()
//Media Timezone is kept in a separate field for later conversion to Local Time
$asDbInfo = [
Db::getId(Project::PROJ_TABLE) => $this->oProject->getProjectId(),
'filename' => $sMediaName,
'taken_on' => date(Db::TIMESTAMP_FORMAT, ($asMediaInfo['taken_ts'] > 0)?$asMediaInfo['taken_ts']:$asMediaInfo['file_ts']),
'posted_on' => date(Db::TIMESTAMP_FORMAT, $asMediaInfo['file_ts']),
'timezone' => $asMediaInfo['timezone'],
'latitude' => is_null($asMediaInfo['latitude'])?'NULL':$asMediaInfo['latitude'],
'longitude' => is_null($asMediaInfo['longitude'])?'NULL':$asMediaInfo['longitude'],
'altitude' => is_null($asMediaInfo['altitude'])?'NULL':$asMediaInfo['altitude'],
'width' => $asMediaInfo['width'],
'height' => $asMediaInfo['height'],
'rotate' => $asMediaInfo['rotate'],
'type' => $asMediaInfo['type']
];
if($sMethod=='sync') $iMediaId = $this->oDb->insertUpdateRow(self::MEDIA_TABLE, $asDbInfo, ['filename']);
else $iMediaId = $this->oDb->insertRow(self::MEDIA_TABLE, $asDbInfo);
if(!$iMediaId) $sLangId = 'error.commit_db';
else {
$this->setMediaId($iMediaId);
$asParams = $this->getInfo(); //Creates thumbnail
}
}
return Livetrail::getResult(($sLangId==''), $sLangId, $asParams);
}
private function getMediaInfoFromFile($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sType = self::getMediaType($sMediaName);
$iPostedOn = filemtime($sMediaPath);
$sTimeZone = date_default_timezone_get();
$iWidth = 0;
$iHeight = 0;
$sRotate = '0';
$sTakenOn = '';
$fLat = null;
$fLng = null;
$iAlt = null;
switch($sType) {
case 'video':
$asResult = [];
$sParams = implode(' ', [
'-loglevel error', //Remove comments
'-select_streams v:0', //First video channel
'-show_entries '. //filter tags : Width, Height, Creation Time, Location & Rotation
'format_tags=creation_time,com.apple.quicktime.creationdate,com.apple.quicktime.location.ISO6709:'.
'stream_tags=rotate,creation_time:'.
'stream=width,height',
'-print_format json', //output format: json
'-i' //input file
]);
exec('ffprobe '.$sParams.' '.escapeshellarg($sMediaPath), $asResult);
$asExif = json_decode(implode('', $asResult), true);
//Taken On
if(isset($asExif['format']['tags']['com.apple.quicktime.creationdate'])) {
$sTakenOn = $asExif['format']['tags']['com.apple.quicktime.creationdate']; //contains Time Zone
$sTimeZone = Livetrail::getTimeZoneFromDate($sTakenOn) ?? $sTimeZone;
}
else $sTakenOn = $asExif['format']['tags']['creation_time'] ?? $asExif['streams'][0]['tags']['creation_time'];
//Location
if(isset($asExif['format']['tags']['com.apple.quicktime.location.ISO6709'])) {
list($fLat, $fLng, $iAlt) = self::getLatLngAltFromISO6709($asExif['format']['tags']['com.apple.quicktime.location.ISO6709']);
}
//Width & Height
$iWidth = $asExif['streams'][0]['width'];
$iHeight = $asExif['streams'][0]['height'];
//Orientation
if(isset($asExif['streams'][0]['tags']['rotate'])) $sRotate = $asExif['streams'][0]['tags']['rotate'];
break;
case 'image':
$asExif = @exif_read_data($sMediaPath, 0, true);
if($asExif === false) $asExif = [];
list($iWidth, $iHeight) = getimagesize($sMediaPath);
//Posted On
if(array_key_exists('FILE', $asExif) && array_key_exists('FileDateTime', $asExif['FILE'])) $iPostedOn = $asExif['FILE']['FileDateTime'];
//Taken On & Timezone
if(array_key_exists('EXIF', $asExif)) {
if(array_key_exists('DateTimeOriginal', $asExif['EXIF'])) $sTakenOn = $asExif['EXIF']['DateTimeOriginal'];
/* Priorities:
* 1. OffsetTimeOriginal: timezone for DateTimeOriginal (exif version >= 2.31)
* 2. 0x9011: same as above, but unidentified
* 3. Timezone extracted from DateTimeOriginal
* 4. Uploader Browser Time Zone (PHP Default Time Zone)
*/
$sTimeZone = $asExif['EXIF']['OffsetTimeOriginal'] ?? $asExif['EXIF']['UndefinedTag:0x9011'] ?? Livetrail::getTimeZoneFromDate($sTakenOn) ?? $sTimeZone;
}
//Location
if(array_key_exists('GPS', $asExif)) {
$asGps = $asExif['GPS'];
$fLat = self::getLatLngFromExif($asGps['GPSLatitudeRef'] ?? $asGps['UndefinedTag:0x0001'], $asGps['GPSLatitude'] ?? $asGps['UndefinedTag:0x0002']);
$fLng = self::getLatLngFromExif($asGps['GPSLongitudeRef'] ?? $asGps['UndefinedTag:0x0003'], $asGps['GPSLongitude'] ?? $$asGps['UndefinedTag:0x0004']);
$iAlt = (($asGps['GPSAltitudeRef'] ?? $asGps['UndefinedTag:0x0005'] ?? '0') == '1'?-1:1) * ($asGps['GPSAltitude'] ?? $asGps['UndefinedTag:0x0006'] ?? 0);
}
//Orientation
if(array_key_exists('IFD0', $asExif) && array_key_exists('Orientation', $asExif['IFD0'])) {
switch($asExif['IFD0']['Orientation']) {
case 1: $sRotate = '0'; break; //None
case 3: $sRotate = '180'; break; //Flip over
case 6: $sRotate = '90'; break; //Clockwise
case 8: $sRotate = '-90'; break; //Trigo
}
}
break;
}
//Assign the correct Time Zone to $sTakenOn if it is not already contained in it. Then get Unix Timestamp
//Time Zone (2nd parameter) will be ignored if already contained in $sTakenOn
$iTakenOn = 0;
if($sTakenOn != '') {
$oTakenOn = new \DateTime($sTakenOn, new \DateTimeZone($sTimeZone));
$iTakenOn = $oTakenOn->format('U');
}
return [
'timezone' => $sTimeZone,
'latitude' => $fLat,
'longitude' => $fLng,
'altitude' => $iAlt,
'taken_ts' => $iTakenOn,
'file_ts' => $iPostedOn,
'width' => $iWidth,
'height' => $iHeight,
'rotate' => $sRotate,
'type' => $sType
];
}
private function getMediaThumbnail($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sThumbPath = self::getMediaPath($sMediaName, 'thumbnail');
if(!file_exists($sThumbPath)) {
$sType = self::getMediaType($sMediaName);
switch($sType) {
case 'image':
$asThumbInfo = ToolBox::createThumbnail($sMediaPath, self::THUMB_MAX_WIDTH, 0, $sThumbPath);
break;
case 'video':
//Get a screenshot of the video 1 second in
$sTempPath = self::getMediaPath(uniqid('temp_').'.png');
$asResult = [];
$sParams = implode(' ', [
'-i '.escapeshellarg($sMediaPath), //input file
'-ss 00:00:01.000', //Image taken after x seconds
'-vframes 1', //number of video frames to output
escapeshellarg($sTempPath), //output file
]);
exec('ffmpeg '.$sParams, $asResult);
//Resize
$asThumbInfo = ToolBox::createThumbnail($sTempPath, self::THUMB_MAX_WIDTH, 0, $sThumbPath, true);
break;
default:
$asThumbInfo = ['error'=>'error.unknown_type', 'out'=>''];
}
}
else $asThumbInfo = ['error'=>'', 'out'=>$sThumbPath];
return ($asThumbInfo['error']=='')?$asThumbInfo['out']:$sMediaPath;
}
private static function getMediaPath($sMediaName, $sFileType='media') {
if($sFileType=='thumbnail') return self::THUMB_FOLDER.'/'.$sMediaName.(strtolower(substr($sMediaName, -3))=='mov'?'.png':'');
else return self::MEDIA_FOLDER.'/'.$sMediaName;
}
private static function getMediaType($sMediaName) {
$sMediaPath = self::getMediaPath($sMediaName);
$sMediaMime = mime_content_type($sMediaPath);
switch($sMediaMime) {
case 'video/quicktime':
case 'video/mp4': $sType = 'video'; break;
default: $sType = 'image'; break;
}
return $sType;
}
private static function getLatLngFromExif($sDirection, $asCoords) {
$fCoord = 0;
foreach($asCoords as $iIndex=>$sCoord) {
$fValue = 0;
$asCoordParts = explode('/', $sCoord);
switch(count($asCoordParts)) {
case 1:
$fValue = $asCoordParts[0];
break;
case 2:
$fValue = floatval($asCoordParts[0]) / floatval($asCoordParts[1]);
break;
}
$fCoord += $fValue / pow(60, $iIndex);
}
return $fCoord * (($sDirection == 'W' || $sDirection == 'S')?-1:1);
}
private static function getLatLngAltFromISO6709($sIso6709) {
preg_match('/^(?P<lat>[\+\-][0,1]?\d{2}\.\d+)(?P<lng>[\+\-][0,1]?\d{2}\.\d+)(?P<alt>[\+\-]\d+)?/', $sIso6709, $asMatches);
return [floatval($asMatches['lat']), floatval($asMatches['lng']), floatval($asMatches['alt'] ?? 0)];
}
}
-70
View File
@@ -1,70 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\UploadHandler;
class Uploader extends UploadHandler {
private Media $oMedia;
public string $sBody;
public function __construct(Media &$oMedia) {
$this->oMedia = &$oMedia;
$this->sBody = '';
parent::__construct([
'upload_dir' => Media::MEDIA_FOLDER.'/',
'image_versions' => [],
'accept_file_types' => '/\.(gif|jpe?g|png|mov|mp4)$/i'
]);
}
protected function validate($uploaded_file, $file, $error, $index, $content_range) {
$bResult = parent::validate($uploaded_file, $file, $error, $index, $content_range);
//Check project mode
if(!$this->oMedia->isProjectEditable()) {
$file->error = true;
$file->desc_lang_id = 'upload.mode_archived';
$file->desc_lang_params = [$this->oMedia->getProjectCodeName()];
$bResult = false;
}
return $bResult;
}
protected function handle_file_upload($uploaded_file, $name, $size, $type, $error, $index = null, $content_range = null) {
$sExt = strtolower(pathinfo((string) $name, PATHINFO_EXTENSION));
$sStoredName = bin2hex(random_bytes(16)).($sExt !== ''?'.'.$sExt:'');
$file = parent::handle_file_upload($uploaded_file, $sStoredName, $size, $type, $error, $index, $content_range);
if(empty($file->error)) {
$asResult = $this->oMedia->addMedia($file->name);
if(!$asResult['result']) {
$file->error = true;
$file->desc_lang_id = $asResult['desc_lang_id'];
$file->desc_lang_params = $asResult['data'];
}
else {
$file->original_name = basename((string) $name);
$file->id = $this->oMedia->getMediaId();
$file->thumbnail = $asResult['data']['thumb_path'];
}
}
if(!empty($file->error)) {
if(empty($file->desc_lang_id)) $file->desc_lang_id = is_string($file->error)?$file->error:'upload.error';
if(empty($file->desc_lang_params)) $file->desc_lang_params = [];
$file->error = true;
}
return $file;
}
protected function body($sBodyPart) {
$this->sBody .= $sBodyPart;
}
protected function get_error_message($sLangId, $asParams=[]) {
return array_key_exists($sLangId, $this->error_messages)?'upload.error':$sLangId;
}
}
-341
View File
@@ -1,341 +0,0 @@
<?php
namespace Franzz\Livetrail;
use Franzz\Objects\PhpObject;
use Franzz\Objects\Db;
class User extends PhpObject {
//DB Tables
public const USER_TABLE = 'users';
//Clearance Levels
public const CLEARANCE_USER = 0;
public const CLEARANCE_ADMIN = 9;
public const CLEARANCES = ['user'=>self::CLEARANCE_USER, 'admin'=>self::CLEARANCE_ADMIN];
public const USER_SUBSCRIBED = 1;
public const USER_UNSUBSCRIBED = 0;
public const DEFAULT_USER = [
'id' => 0,
'id_user' => 0,
'name' => '',
'email' => '',
'language' => '',
'timezone' => '',
'subscribed'=> self::USER_UNSUBSCRIBED,
'clearance' => self::CLEARANCE_USER
];
//Session & Cookie
private const SESSION_ID_USER = 'id_user';
private const SESSION_ADMIN = 'admin_authenticated';
private const COOKIE_TOKEN = 'login';
private const COOKIE_DURATION = 60 * 60 * 24 * 365; //1 year
/**
* Database Handle
* @var Db
*/
private $oDb;
//User Info
private $iUserId;
private $asUserInfo;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->setUserId(0);
$this->checkSession();
}
public function getUserId() {
return $this->iUserId;
}
public function setUserId($iUserId) {
$this->iUserId = 0;
$this->asUserInfo = self::DEFAULT_USER;
if($iUserId > 0) {
$asUser = $this->getUserById($iUserId);
if(!empty($asUser)) {
$this->iUserId = $iUserId;
$this->asUserInfo = $asUser;
}
}
}
public function getUserInfo() {
return $this->asUserInfo;
}
public function getUserById($iUserId) {
$asUsersInfo = [];
if($iUserId > 0) $asUsersInfo = $this->getUsersInfo($iUserId);
return empty($asUsersInfo)?[]:array_shift($asUsersInfo);
}
public function getUsersInfo($iUserId=-1) {
//Mapping between user fields and DB fields
$asSelect = array_keys($this->asUserInfo);
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE).' AS id';
$asInfo = [
'select' => $asSelect,
'from' => self::USER_TABLE
];
if($iUserId != -1) $asInfo['constraint'] = [Db::getId(self::USER_TABLE) => $iUserId];
return $this->oDb->selectRows($asInfo);
}
public function getLang() {
return $this->asUserInfo['language'];
}
private function addUser($sEmail, $sLang, $sTimezone, $sNickName='') {
$bSuccess = false;
$sLangId = '';
$iUserId = $this->oDb->insertRow(
self::USER_TABLE,
['email'=>$sEmail, 'language'=>$sLang, 'timezone'=>$sTimezone]
);
if($iUserId == 0) $sLangId = 'error.commit_db';
else $bSuccess = true;
//Extra optional values
if($bSuccess) {
$this->updateNickname($sNickName);
$this->updateGravatar($iUserId, $sEmail);
}
return Livetrail::getResult($bSuccess, $sLangId, [Db::getId(self::USER_TABLE) => $iUserId]);
}
public function setSubscription($bSubscribed) {
if($this->getUserId() > 0) {
$iSubscribed = $bSubscribed?1:0;
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), ['subscribed'=>$iSubscribed]);
if(!$iUserId) return Livetrail::getResult(false, 'error.commit_db');
$this->asUserInfo['subscribed'] = $iSubscribed;
return Livetrail::getResult(true, $iSubscribed?'account.subscribed':'account.unsubscribed');
}
}
public function getSubscribedUsersInfo() {
$asSelect = array_keys($this->asUserInfo);
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE).' AS id';
return $this->oDb->selectRows([
'select'=>$asSelect,
'from'=>self::USER_TABLE,
'constraint'=>['subscribed'=>self::USER_SUBSCRIBED]
]);
}
public function login($sEmail, $sPassword, $sLang, $sTimezone, $sNickName='') {
$bSuccess = false;
$bSubscribe = false;
$sLangId = '';
$sEmail = strtolower(trim($sEmail));
//Check email value
if(!filter_var($sEmail, FILTER_VALIDATE_EMAIL)) {
$sLangId = 'account.invalid_email';
}
else {
//Check Email presence in DB
$asDBUser = $this->oDb->selectRow(
self::USER_TABLE,
['email' => $sEmail],
[Db::getId(self::USER_TABLE), 'password', 'clearance']
);
$iUserId = $asDBUser[Db::getId(self::USER_TABLE)] ?? 0;
//User exists
if($iUserId > 0) {
//Is Admin
if($asDBUser['clearance'] >= self::CLEARANCE_ADMIN) {
//Request a password
if($sPassword === '') $sLangId = empty($asDBUser['password'])?'account.set_password':'account.password_required';
//Set password
elseif(empty($asDBUser['password'])) {
if(!$this->oDb->updateRow(self::USER_TABLE, $iUserId, ['password' => password_hash($sPassword, PASSWORD_DEFAULT)])) $sLangId = 'error.commit_db';
else {
$sLangId = 'account.password_set';
$bSuccess = true;
}
}
//Check password
elseif(password_verify($sPassword, $asDBUser['password'])) {
$bSuccess = true;
$sLangId = 'account.logged_in';
}
else $sLangId = 'account.invalid_credentials';
}
else {
$bSuccess = true;
$sLangId = 'account.logged_in';
}
}
else {
//Unknown user, create it
$asAddResult = $this->addUser($sEmail, $sLang, $sTimezone, $sNickName);
$bSuccess = $asAddResult['result'];
$bSubscribe = $bSuccess;
$sLangId = $bSuccess?'':$asAddResult['desc_lang_id'];
$iUserId = $asAddResult['data'][Db::getId(self::USER_TABLE)] ?? 0;
}
}
if($bSuccess) {
$this->setUserId($iUserId);
$this->setSession();
$this->setTokenCookie();
}
return Livetrail::getResult($bSuccess, $sLangId, ['subscribe'=>$bSubscribe]);
}
public function logout() {
if($this->getUserId() > 0) $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), ['token' => '', 'token_exp' => '0000-00-00 00:00:00']);
$this->clearSession();
$this->clearCookie();
$this->setUserId(0);
return Livetrail::getResult(true, 'account.logged_out');
}
public function updateNickname($sNickname) {
if($this->getUserId() > 0 && $sNickname!='') $this->oDb->updateRow(self::USER_TABLE, $this->getUserId(), ['name'=>$sNickname]);
}
private function updateGravatar($iUserId, $sEmail) {
$sImage = ($sEmail != '')?@file_get_contents('https://www.gravatar.com/avatar/'.md5($sEmail).'.png?d=404&s=24'):'';
$this->oDb->updateRow(self::USER_TABLE, $iUserId, ['gravatar' => base64_encode($sImage)]);
}
public function checkUserClearance($iClearance) {
return ($this->asUserInfo['clearance'] >= $iClearance);
}
public function setUserClearance($iUserId, $iClearance) {
$bSuccess = false;
$sLangId = '';
$asLangParams = [];
if(!$this->checkUserClearance(self::CLEARANCE_ADMIN)) $sLangId = 'error.no_auth';
else {
if(!in_array($iClearance, self::CLEARANCES)) {
$sLangId = 'error.impossible_value';
$asLangParams = [$iClearance, 'clearance'];
}
else {
$iUserId = $this->oDb->updateRow(self::USER_TABLE, $iUserId, ['clearance'=>$iClearance]);
if(!$iUserId) $sLangId = 'error.commit_db';
else $bSuccess = true;
}
}
return Livetrail::getResult($bSuccess, $sLangId, [], $asLangParams);
}
/* Session */
private function checkSession() {
$iUserId = (int) ($_SESSION[self::SESSION_ID_USER] ?? 0);
if($iUserId > 0) {
$this->setUserId($iUserId);
if($this->checkUserClearance(self::CLEARANCE_ADMIN) && empty($_SESSION[self::SESSION_ADMIN])) $this->logout();
}
else $this->checkTokenCookie();
}
private function setSession() {
session_regenerate_id(true);
$_SESSION[self::SESSION_ID_USER] = $this->getUserId();
$_SESSION[self::SESSION_ADMIN] = $this->checkUserClearance(self::CLEARANCE_ADMIN);
}
private function clearSession() {
unset($_SESSION[self::SESSION_ID_USER], $_SESSION[self::SESSION_ADMIN]);
}
/* Cookie */
private function checkTokenCookie() {
$sCookieValue = $this->getCookie();
//Check cookie structure
$asToken = explode(':', $sCookieValue, 3);
$iUserId = $asToken[0];
if(count($asToken) !== 3 || !ctype_digit($iUserId) || !ctype_digit($asToken[1]) || !preg_match('/^[a-f0-9]{64}$/', $asToken[2])) {
if($sCookieValue !== '') $this->clearCookie();
return;
}
//Get user info
$asUser = $this->oDb->selectRow(
self::USER_TABLE,
$iUserId,
['clearance', 'token', 'token_exp']
);
//Check token value
if(!empty($asUser)
&& $asUser['clearance'] == $asToken[1]
&& time() <= strtotime($asUser['token_exp'])
&& hash_equals($asUser['token'], hash('sha256', $sCookieValue))
) {
$this->setUserId($iUserId);
$this->setSession();
}
else $this->clearCookie();
}
private function setTokenCookie() {
$sToken = bin2hex(random_bytes(32));
$iClearance = $this->asUserInfo['clearance'];
$sCookieValue = implode(':', [$this->getUserId(), $iClearance, $sToken]);
$this->oDb->updateRow(
self::USER_TABLE,
$this->getUserId(),
[
'token' => hash('sha256', $sCookieValue),
'token_exp' => date(Db::TIMESTAMP_FORMAT, time() + self::COOKIE_DURATION)
]
);
$this->setCookie($sCookieValue, time() + self::COOKIE_DURATION);
}
private function getCookie() {
return $_COOKIE[self::COOKIE_TOKEN] ?? '';
}
private function setCookie($sValue, $iExpires) {
setcookie(
self::COOKIE_TOKEN,
$sValue,
[
'expires' => $iExpires,
'path' => '/',
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'),
'httponly' => true,
'samesite' => 'Lax'
]
);
}
private function clearCookie() {
$this->setCookie('', time() - 3600);
unset($_COOKIE[self::COOKIE_TOKEN]);
}
}
+172
View File
@@ -0,0 +1,172 @@
<div id="admin">
<h1>Projects</h1>
<div id="projects">
<table>
<thead>
<tr>
<th>[#]lang:project_id[#]</th>
<th>[#]lang:project[#]</th>
<th>[#]lang:mode[#]</th>
<th>[#]lang:code_name[#]</th>
<th>[#]lang:start[#]</th>
<th>[#]lang:end[#]</th>
<th>[#]lang:time_zone[#]</th>
<th>[#]lang:delete[#]</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div id="new"></div>
</div>
<h1>Feeds</h1>
<div id="feeds">
<table>
<thead>
<tr>
<th>[#]lang:feed_id[#]</th>
<th>[#]lang:ref_feed_id[#]</th>
<th>[#]lang:spot_id[#]</th>
<th>[#]lang:project_id[#]</th>
<th>[#]lang:name[#]</th>
<th>[#]lang:status[#]</th>
<th>[#]lang:last_update[#]</th>
<th>[#]lang:delete[#]</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<h1>Spots</h1>
<div id="spots">
<table>
<thead>
<tr>
<th>[#]lang:spot_id[#]</th>
<th>[#]lang:ref_spot_id[#]</th>
<th>[#]lang:name[#]</th>
<th>[#]lang:model[#]</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="feedback" class="feedback"></div>
</div>
<script type="text/javascript">
oSpot.pageInit = function(asHash) {
self.get('admin_get', setProjects);
$('#new').addButton('new', 'New Project', 'new', createProject, 'main-btn');
};
oSpot.onFeedback = function(sType, sMsg, asContext) {
delete asContext.a;
delete asContext.t;
sMsg += ' (';
$.each(asContext, function(sKey, sElem) {
sMsg += sKey+'='+sElem+' / ' ;
});
sMsg = sMsg.slice(0, -3)+')';
$('#feedback').append($('<p>', {'class': sType}).text(sMsg));
};
function setProjects(asElemTypes) {
var aoEvents = [{on:'change', callback:commit}, {on:'keyup', callback:waitAndCommit}];
$.each(asElemTypes, function(sElemType, aoElems) {
$.each(aoElems, function(iKey, oElem) {
var sElemId = sElemType+'_'+oElem.id;
var bNew = ($('#'+sElemId).length == 0);
var $Elem = (bNew?$('<tr>', {'id': sElemId}):$('#'+sElemId).empty())
.data('type', sElemType)
.data('id', oElem.id)
.append($('<td>').text(oElem.id || ''));
if(oElem.del) $Elem.remove();
else {
switch(sElemType) {
case 'project':
$Elem
.append($('<td>').addInput('text', 'name', oElem.name, aoEvents))
.append($('<td>', {'class': 'mode'}).text(oElem.mode))
.append($('<td>').addInput('text', 'codename', oElem.codename, aoEvents))
.append($('<td>').addInput('date', 'active_from', oElem.active_from.substr(0, 10), aoEvents))
.append($('<td>').addInput('date', 'active_to', oElem.active_to.substr(0, 10), aoEvents))
.append($('<td>').addInput('text', 'timezone', oElem.timezone, aoEvents))
.append($('<td>').addButton('close fa-lg', '', 'del_proj', del));
break;
case 'feed':
$Elem
.append($('<td>').addInput('text', 'ref_feed_id', oElem.ref_feed_id, aoEvents))
.append($('<td>').addInput('number', 'spot_id', oElem.id_spot, aoEvents))
.append($('<td>').addInput('number', 'project_id', oElem.id_project, aoEvents))
.append($('<td>').text(oElem.name))
.append($('<td>').text(oElem.status))
.append($('<td>').text(oElem.last_update))
.append($('<td>').addButton('close fa-lg', '', 'del_feed', del));
break;
case 'spot':
$Elem
.append($('<td>').text(oElem.ref_spot_id))
.append($('<td>').text(oElem.name))
.append($('<td>').text(oElem.model))
break;
}
if(bNew) $Elem.appendTo($('#'+sElemType+'s').find('table tbody'));
}
});
});
}
function createProject() {
self.get('admin_new', setProjects);
}
function commit(event, $This) {
$This = $This || $(this);
if(typeof self.tmp('wait') != 'undefined') clearTimeout(self.tmp('wait'));
var sOldVal = $This.data('old_value');
var sNewVal = $This.val();
if(sOldVal!=sNewVal) {
$This.data('old_value', sNewVal);
var $Record = $This.closest('tr');
var asInputs = {type: $Record.data('type'), id: $Record.data('id'), field: $This.attr('name'), value: sNewVal};
self.get(
'admin_set',
function(asData){
oSpot.onFeedback('success', self.lang('admin_save_success'), asInputs);
setProjects(asData);
},
asInputs,
function(sError){
$This.data('old_value', sOldVal);
oSpot.onFeedback('error', sError, asInputs);
}
);
}
}
function waitAndCommit(event) {
if(typeof self.tmp('wait') != 'undefined') clearTimeout(self.tmp('wait'));
self.tmp('wait', setTimeout(()=>{commit(event,$(this));}, 2000));
}
function del() {
var $Record = $(this).closest('tr');
var asInputs = {type: $Record.data('type'), id: $Record.data('id')};
self.get(
'admin_del',
function(asData){
oSpot.onFeedback('success', self.lang('admin_save_success'), asInputs);
setProjects(asData);
},
asInputs,
function(sError){
oSpot.onFeedback('error', sError, asInputs);
}
);
}
</script>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<title>[#]lang:email_conf_subject[#]</title>
</head>
<body>
<span style="color: transparent; display: none !important; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">[#]lang:conf_preheader[#]</span>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="width:100%;max-width:600px;">
<tr>
<td width="20%"><img src="[#]local_server[#]images/icons/mstile-144x144.png" width="90%" border="0" alt="logo" /></td>
<td><h1>[#]lang:conf_thanks_sub[#]</h1></td>
</tr>
<tr>
<td colspan="2">
<p align="justify">[#]lang:conf_body_para_1[#]</p>
<p align="justify">[#]lang:conf_body_para_2[#]</p>
<p align="justify">[#]lang:conf_body_para_3[#]</p>
</td>
</tr>
<tr>
<td colspan="2">
<p>[#]lang:conf_body_conclusion[#]<br />[#]lang:conf_signature[#]</p>
</td>
</tr>
<tr>
<td colspan="2">
<p>[#]lang:email_unsubscribe[#] <a href="[#]unsubscribe_link[#]" target="_blank">[#]lang:email_unsub_btn[#]</a></p>
</td>
</tr>
</table>
</body>
</html>
@@ -2,19 +2,19 @@
<html xmlns="http://www.w3.org/1999/xhtml"> <html xmlns="http://www.w3.org/1999/xhtml">
<head> <head>
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<title>[#]lang:email.update.subject[#]</title> <title>[#]lang:email_update_subject[#]</title>
</head> </head>
<body> <body>
<span style="color: transparent; display: none !important; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">[#]lang:email.update.preheader[#]</span> <span style="color: transparent; display: none !important; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">[#]lang:update_preheader[#]</span>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="width:100%;max-width:600px;"> <table border="0" cellpadding="0" cellspacing="0" width="100%" style="width:100%;max-width:600px;">
<tr> <tr>
<td width="20%"><img src="[#]local_server[#]assets/images/icons/favicon-96x96.png" width="90%" border="0" alt="logo" /></td> <td width="20%"><img src="[#]local_server[#]images/icons/mstile-144x144.png" width="90%" border="0" alt="logo" /></td>
<td><h1>[#]lang:email.update.title[#] [#]type[#] #[#]displayed_id[#]</h1></td> <td><h1>[#]lang:update_title[#] [#]type[#] #[#]displayed_id[#]</h1></td>
</tr> </tr>
<tr> <tr>
<td colspan="2"> <td colspan="2">
<div style="background-color:#6dff58;color:#326526;border-radius:3px;padding:1rem;margin-top:1rem;display:inline-block;box-shadow: 2px 2px 3px 0px rgba(0,0,0,.5);"> <div style="background-color:#6dff58;color:#326526;border-radius:3px;padding:1rem;margin-top:1rem;display:inline-block;box-shadow: 2px 2px 3px 0px rgba(0,0,0,.5);">
<a href="[#]local_server[#]" target="_blank" rel="noopener"><img style="border-radius:3px;" src="[#]marker_img_url[#]" alt="position" /></a> <a href="[#]local_server[#]" target="_blank"><img style="border-radius:3px;" src="[#]geo_server[#]?a=tile&amp;id=static.marker&amp;z=13&amp;x=[#]longitude[#]&amp;y=[#]latitude[#]&amp;marker=[#]local_server[#]images/footprint_mapbox.png&amp;token=[#]token[#]" alt="position" /></a>
<br />[#]lat_dms[#] [#]lon_dms[#] <br />[#]lat_dms[#] [#]lon_dms[#]
<br />[#]date_time[#] ([#]timezone[#]) <br />[#]date_time[#] ([#]timezone[#])
</div> </div>
@@ -22,12 +22,12 @@
</tr> </tr>
<tr> <tr>
<td colspan="2"> <td colspan="2">
<h2>[#]lang:email.update.latest_news[#]</h2> <h2>[#]lang:update_latest_news[#]</h2>
<table border="0" cellpadding="0" cellspacing="0" width="100%"> <table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- [PART] news [START] --> <!-- [PART] news [START] -->
<tr> <tr>
<td> <td>
<a href="[#]local_server[#]#project-[#]project[#]-[#]type[#]-[#]id[#]" target="_blank" rel="noopener" style="text-decoration:none;background-color:#EEE;color:#333;margin-bottom:1rem;border-radius:3px;padding:5%;display:inline-block;width:90%;box-shadow: 2px 2px 3px 0px rgba(0,0,0,.5);"> <a href="[#]local_server[#]#project-[#]project[#]-[#]type[#]-[#]id[#]" target="_blank" style="text-decoration:none;background-color:#EEE;color:#333;margin-bottom:1rem;border-radius:3px;padding:5%;display:inline-block;width:90%;box-shadow: 2px 2px 3px 0px rgba(0,0,0,.5);">
<!-- [PART] media [START] --><img src="[#]local_server[#][#]thumb_path[#]" style="max-height:200px;image-orientation:from-image;" /><br /><span>[#]comment[#]</span><!-- [PART] media [END] --> <!-- [PART] media [START] --><img src="[#]local_server[#][#]thumb_path[#]" style="max-height:200px;image-orientation:from-image;" /><br /><span>[#]comment[#]</span><!-- [PART] media [END] -->
<!-- [PART] post [START] --><span>[#]content[#]</span><br /><span style="margin-top:0.5em;float:right;">--[#]formatted_name[#]</span><!-- [PART] post [END] --> <!-- [PART] post [START] --><span>[#]content[#]</span><br /><span style="margin-top:0.5em;float:right;">--[#]formatted_name[#]</span><!-- [PART] post [END] -->
</a> </a>
@@ -39,7 +39,7 @@
</tr> </tr>
<tr> <tr>
<td colspan="2"> <td colspan="2">
<p>[#]lang:email.unsubscribe[#] <a href="[#]unsubscribe_link[#]" target="_blank" rel="noopener">[#]lang:email.unsubscribe_button[#]</a></p> <p>[#]lang:email_unsubscribe[#] <a href="[#]unsubscribe_link[#]" target="_blank">[#]lang:email_unsub_btn[#]</a></p>
</td> </td>
</tr> </tr>
</table> </table>
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="[#]lang:page_og_desc[#]">
<meta property="og:title" content="Spotty" />
<meta property="og:description" content="[#]lang:page_og_desc[#]" />
<meta property="og:type" content="website" />
<meta property="og:url" content="[#]host_url[#]" />
<meta property="og:image" content="images/ogp.png" />
<meta property="og:locale" content="[#]lang:locale[#]" />
<link rel="apple-touch-icon" sizes="180x180" href="images/icons/apple-touch-icon.png?v=GvmqYyKwbb">
<link rel="icon" type="image/png" sizes="32x32" href="images/icons/favicon-32x32.png?v=GvmqYyKwbb">
<link rel="icon" type="image/png" sizes="16x16" href="images/icons/favicon-16x16.png?v=GvmqYyKwbb">
<link rel="manifest" href="images/icons/site.webmanifest?v=GvmqYyKwbb">
<link rel="mask-icon" href="images/icons/safari-pinned-tab.svg?v=GvmqYyKwbb" color="#44d15a">
<link rel="shortcut icon" href="images/icons/favicon.ico?v=GvmqYyKwbb">
<meta name="msapplication-TileColor" content="#00a300">
<meta name="msapplication-config" content="images/icons/browserconfig.xml?v=GvmqYyKwbb">
<meta name="theme-color" content="#ffffff">
<link type="text/css" href="[#]filepath_css[#]" rel="stylesheet" media="all" />
<script type="text/javascript" src="[#]filepath_js_d3[#]"></script>
<script type="text/javascript" src="[#]filepath_js_leaflet[#]"></script>
<script type="text/javascript" src="[#]filepath_js_jquery[#]"></script>
<script type="text/javascript" src="[#]filepath_js_jquery_mods[#]"></script>
<script type="text/javascript" src="[#]filepath_js_spot[#]"></script>
<script type="text/javascript" src="[#]filepath_js_lightbox[#]"></script>
<script type="text/javascript">
var oSpot = new Spot([#]GLOBAL_VARS[#]);
$(document).ready(oSpot.init);
</script>
<title></title>
</head>
<body>
<div id="container">
<header></header>
<div id="main"></div>
<footer></footer>
</div>
</body>
</html>
+947
View File
@@ -0,0 +1,947 @@
<div id="projects">
<div id="submap">
<div class="loader fa fa-fw fa-map flicker" id="map_loading"></div>
</div>
<div id="map"></div>
<div id="settings">
<div id="settings-panel">
<div class="settings-header">
<div class="logo"><img src="images/logo_black.png" /></div>
<div id="last_update"><p><img src="images/spot-logo-only.svg" /><abbr></abbr></p></div>
</div>
<div class="settings-sections">
<div id="settings-sections-scrollbox">
<div class="settings-section">
<h1><i class="fa fa-fw push fa-map"></i>[#]lang:maps[#]</h1>
<div id="layers"></div>
</div>
<div class="settings-section newsletter">
<h1><i class="fa fa-fw push fa-newsletter"></i>[#]lang:newsletter[#]</h1>
<input type="email" name="email" id="email" placeholder="[#]lang:nl_email_placeholder[#]" /><button id="nl_btn"><span><i class="fa"></i></span></button>
<div id="settings-feedback" class="feedback"></div>
<div id="nl_desc"></div>
</div>
<div class="settings-section">
<h1><i class="fa fa-fw push fa-project"></i>[#]lang:projects[#]</h1>
<div id="settings-projects"></div>
</div>
</div>
</div>
<div class="settings-footer"><a href="https://git.lutran.fr/franzz/spot" title="[#]lang:credits_git[#]" target="_blank"><i class="fa fa-credits push"></i>[#]lang:credits_project[#]</a> [#]lang:credits_license[#]</div>
</div>
</div>
<div id="feed">
<div id="posts">
<div id="poster"></div>
<div id="posts_list"></div>
<div id="loading"></div>
</div>
</div>
<div id="elems">
<div id="settings-button" class="spot-control"><i class="fa fa-menu fa-fw"></i></div>
<div id="post-button" class="spot-control"><i class="fa fa-fw"></i></div>
<div id="legend" class="leaflet-control-layers leaflet-control leaflet-control-layers-expanded">
<div class="track"><span class="line main"></span><span class="desc">[#]lang:track_main[#]</span></div>
<div class="track"><span class="line off-track"></span><span class="desc">[#]lang:track_off-track[#]</span></div>
<div class="track"><span class="line hitchhiking"></span><span class="desc">[#]lang:track_hitchhiking[#]</span></div>
</div>
</div>
</div>
<div id="mobile" class="mobile"></div>
<script type="text/javascript">
oSpot.onSamePageMove = function(asHash) {
if(self.tmp('first_exec')) initPage(asHash);
else if(asHash.items[0] != self.vars(['project', 'codename'])) {
self.tmp('map').remove();
self.tmp('$Map').empty();
self.tmp('map', null);
self.tmp('$PostList').empty();
setFeedUpdateTimer(false);
initProject(asHash.items[0]);
}
return false;
};
oSpot.pageInit = function(asHash) {
self.tmp('first_exec', true);
initPage(asHash);
};
oSpot.onResize = function() {
self.tmp('map_offset', -1 * (isFeedPanelOpen()?self.tmp('$Feed').outerWidth(true):0) / $('body').outerWidth(true));
if(typeof self.tmp('elev') != 'undefined') {
var bElevOpened = self.tmp('elev')._showState;
if(bElevOpened) self.tmp('elev')._expand();
self.tmp('elev').remove();
self.tmp('elev').options.width = getElevWidth();
self.tmp('elev').addTo(self.tmp('map'));
self.tmp('elev').addData(self.tmp('elev-data'));
if(bElevOpened) self.tmp('elev')._expand();
}
};
oSpot.onQuitPage = function() {
setFeedUpdateTimer(false);
return true;
}
oSpot.onKeydown = function(oEvent) {
switch(oEvent.which) {
case 27:
if(!$('#lightboxOverlay').is(':visible')) toggleFeedPanel(false);
break;
}
}
function toggleFeedPanel(bShow, sMapAction) {
self.tmp('$Projects').toggleClass('with-feed', (typeof bShow === 'undefined')?null:bShow);
oSpot.onResize();
if(isMobile()) $('#settings-button').toggle(!isFeedPanelOpen());
sMapAction = sMapAction || 'panTo';
switch(sMapAction) {
case 'none': break;
case 'panTo': oSpot.tmp('map').panBy([(isFeedPanelOpen()?1:-1)*self.tmp('$Feed').outerWidth(true)/2, 0], {duration: 0.5}); break;
case 'panToInstant': oSpot.tmp('map').panBy([(isFeedPanelOpen()?1:-1)*self.tmp('$Feed').outerWidth(true)/2, 0]); break;
case 'fitBounds': oSpot.tmp('map').fitBounds(self.tmp('track').getBounds(), {paddingTopLeft: L.point(5, self.tmp('marker_size').height + 5), paddingBottomRight: L.point(self.tmp('$Feed').outerWidth(true) + 5, 5)}); break;
}
}
function isFeedPanelOpen() {
return self.tmp('$Projects').hasClass('with-feed');
}
function toggleSettingsPanel(bShow) {
self.tmp('$Projects').toggleClass('with-settings', (typeof bShow === 'undefined')?null:bShow);
oSpot.onResize();
if(isMobile()) $('#post-button').toggle(!isSettingsPanelOpen());
oSpot.tmp('map').panBy([(isSettingsPanelOpen()?-1:1)*self.tmp('$Settings').outerWidth(true)/2, 0], {duration: 0.5});
}
function isSettingsPanelOpen() {
return self.tmp('$Projects').hasClass('with-settings');
}
function updateSettingsPanel(asLastUpdate) {
var $LastUpdate = self.tmp('$Settings').find('#last_update').toggle(self.vars(['project', 'mode']) == self.consts.modes.blog);
$LastUpdate.find('abbr')
.attr('title', asLastUpdate.formatted_time)
.text(oSpot.lang('last_update')+' '+asLastUpdate.relative_time);
}
function isMobile() {
return $('#mobile').is(':visible');
}
function initPage(asHash) {
if(asHash.items.length==0) self.setHash(asHash.page, [self.vars('default_project_codename')]);
else {
self.tmp('$Projects', $('#projects'));
self.tmp('$Map', $('#map'));
self.tmp('$Poster', $('#poster'));
self.tmp('$PostList', $('#posts_list'));
self.tmp('$Feed', $('#feed'));
self.tmp('$Settings', $('#settings'));
self.tmp('updatable', true);
self.tmp('out-of-data', false);
self.tmp('tile_api', getWmtsApiUrl('{id}', '{y}', '{x}', '{z}'));
self.tmp('markers', 'object');
self.tmp('trail-markers', 'object');
self.tmp('marker_size', {width: 32, height: 32});
if(!isMobile()) toggleFeedPanel(true, 'none');
//Lightbox options
lightbox.option({
alwaysShowNavOnTouchDevices: true,
albumLabel: '<i class="fa fa-fw fa-lg fa-media push"></i> %1 / %2',
fadeDuration: 300,
imageFadeDuration: 400,
positionFromTop: 0,
resizeDuration: 600,
hasVideo: true
});
//Assign Track Type Colors
self.tmp('track-type-styles', 'object');
$('#legend').find('.line').each(function(iKey, oLegend){
var $Legend = $(oLegend);
var sTrackType = $Legend.attr('class').replace('line', '').trim();
self.tmp(['track-type-styles', sTrackType], {weight: parseInt($Legend.css('height')), color: $Legend.css('background-color'), opacity: 1});
});
//Post Panel one-off init (see initPosts for project related init)
//Scrollbar
self.tmp('simple-bar', new SimpleBar($('#posts')[0]));
self.tmp('simple-bar').getScrollElement().addEventListener('scroll', onFeedScroll);
//Add "Loading" Post
getPost({type: 'loading', headerless: true, formatted_time: '', relative_time: ''}).appendTo($('#loading'));
//Mobile events
$("#feed").onSwipe(function(aiDelta){
if(aiDelta.x > self.tmp('$Feed').outerWidth(true)/3 && aiDelta.x > Math.abs(aiDelta.y)) toggleFeedPanel(false);
});
//Feed Panel
initPosts();
//Settings Panel
initSettings();
//project Bootstrap
var oFocusPost = asHash.items[1]?{type: asHash.items[1], id: asHash.items[2]}:null;
initProject(asHash.items[0], oFocusPost);
}
}
function initProject(sProjectCodeName, oFocusPost){
self.tmp('first_exec', false);
self.vars('project', self.vars(['projects', sProjectCodeName]));
//Page Title
self.setPageTitle(oSpot.vars(['project', 'name']));
//Timezone difference notice
var bSameTime = (
(new Date()).toLocaleString([], {timeZone: oSpot.consts.timezone}) ==
(new Date()).toLocaleString([], {timeZone: oSpot.vars(['project', 'timezone'])})
);
self.tmp('site_tz_notice', bSameTime?'':getTimeZoneDesc(oSpot.consts.timezone));
self.tmp('proj_tz_notice', bSameTime?'':getTimeZoneDesc(self.vars(['project', 'timezone'])));
//Load Track & Markers
$.when(
//Markers: Spot Messages & Medias
self.get(
'markers',
function(){},
{project_id: self.vars(['project', 'id'])},
function(e){console.log(e);}
),
//Project Geojson: Hike track
$.ajax({
dataType: 'json',
url: self.vars(['project', 'geofilepath']),
mimeType: 'application/json'
})
).done(function(aoMessages, aoTracks) {
var asData = aoMessages[0]['data'];
setMapLayers(asData['maps']);
initSpotMessages(asData['messages'] || [], aoTracks[0], aoMessages[0]['desc']=='No Data');
updateSettingsPanel(asData['last_update']);
});
//Show/Hide Poster Panel
var bHistoMode = (self.vars(['project', 'mode']) == self.consts.modes.histo);
self.tmp('$Poster').toggle(!bHistoMode);
//Feed auto-update
updateFeed(true, false, function(){focusOnPost(oFocusPost);});
self.tmp('simple-bar').getScrollElement().scrollTop = 0;
if(!bHistoMode) onAutoUpdate(true);
}
function initPosts() {
//Add post form
var asPoster = {
type: 'poster',
formatted_time: '',
relative_time: oSpot.lang('post_new_message')
};
self.tmp('$Poster').append(getPost(asPoster));
//Auto-adjust text area height
autosize($('#post'));
//Add post Event handling
$('#submit').click(function(){
if(self.tmp('$Poster').checkForm())
{
self.get(
'add_post',
function()
{
$('#post').val('');
updateFeed(true);
},
{
project_id: self.vars(['project', 'id']),
name: $('#name').val(),
content: $('#post').val()
}
);
}
});
}
function initSettings(){
//Scrollbar
new SimpleBar($('#settings-sections-scrollbox')[0]);
//Feedback display function
var settingsFeedback = function(sType, sMsg){
$('<p>', {'class': sType})
.append($('<i>', {'class':'fa push fa-'+sType}))
.append(sMsg)
.appendTo($('#settings-feedback'))
.slideDown('fast')
.delay(5000)
.slideUp('fast', function(){$(this).remove();});
};
//Newsletter Subscription
$('#nl_btn').click(function(){
var sAction = $(this).prop('name');
var sEmail = $('#email').val();
var regexEmail = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if(!regexEmail.test(sEmail)) settingsFeedback('error', oSpot.lang('nl_invalid_email'));
else {
oSpot.get(
sAction,
function(asData, sDesc) {
settingsFeedback('success', sDesc);
oSpot.vars('user', asData);
setUserInterface();
},
{'email': sEmail},
function(sDesc) {settingsFeedback('error', sDesc);},
function(sState) {
var bLoading = (sState=='start');
$('#nl_btn')
.prop('disabled', bLoading)
.toggleClass('loading', bLoading);
}
);
}
});
//Twink interface with user data
setUserInterface();
}
function setUserInterface() {
var asUserInfo = oSpot.vars('user');
if(asUserInfo.id_user) { //Subscribed User
$('#email').val(asUserInfo.email).prop('disabled', true);
$('#nl_btn').attr({name: 'unsubscribe', title: oSpot.lang('nl_unsubscribe'), 'class':'unsubscribe'});
$('#nl_desc').text(oSpot.lang('nl_subscribed_desc'));
//Populate nickname
if(asUserInfo.name) $('#name').val(asUserInfo.name);
}
else { //Unsubscribed User
$('#email').val('').prop('disabled', false);
$('#nl_btn').attr({name: 'subscribe', title: oSpot.lang('nl_subscribe'), 'class':'subscribe'});
$('#nl_desc').text(oSpot.lang('nl_unsubscribed_desc'));
}
}
function onAutoUpdate(bStart) {
if(!bStart && self.tmp('simple-bar').getScrollElement().scrollTop == 0) updateFeed(true, true);
setFeedUpdateTimer(60, onAutoUpdate);
}
function getElevWidth() {
var
iPageWidth = self.tmp('$Projects').width(),
iFeedPanelWidth = isFeedPanelOpen()?self.tmp('$Feed').outerWidth(true):0,
iSettingsPanelWidth = isSettingsPanelOpen()?self.tmp('$Settings').outerWidth(true):0,
iLegendWidth = $('.leaflet-bottom.leaflet-left > .leaflet-control-layers').outerWidth(true),
oElevRightMarging = parseInt($('.leaflet-bottom.leaflet-right > .leaflet-control-scale').css('margin-right').slice(0, -2));
return iPageWidth - iFeedPanelWidth - iSettingsPanelWidth - iLegendWidth - oElevRightMarging;
}
function setMapLayers(asLayers) {
oSpot.tmp('layers', {});
$.each(asLayers, function(iKey, asLayer) {
oSpot.tmp('layers')[oSpot.lang('map_'+asLayer.codename)] = L.tileLayer(self.tmp('tile_api'), {
id: asLayer.geo_name,
minZoom: parseInt(asLayer.min_zoom),
maxZoom: parseInt(asLayer.max_zoom),
attribution: asLayer.attribution
});
});
}
function initSpotMessages(aoMessages, aoTracks, bNoFeed) {
//Map
var oMap = L.map(self.tmp('$Map')[0], {
layers: [oSpot.tmp('layers')[oSpot.lang('map_satellite')]],
attributionControl: false,
zoomControl: false
});
self.tmp('map', oMap);
//Controls: Settings Panel
var oSettingsPanel = L.control({position: 'topleft'});
var $SettingsButton = $('#settings-button').clone();
$SettingsButton.click(toggleSettingsPanel);
oSettingsPanel.onAdd = function(oMap) {return $SettingsButton[0];};
oSettingsPanel.addTo(oMap);
//Controls: Feed Panel
var oFeedPanel = L.control({position: 'topright'});
var $PostButton = $('#post-button').clone();
$PostButton.click(toggleFeedPanel);
oFeedPanel.onAdd = function(oMap) {return $PostButton[0];};
oFeedPanel.addTo(oMap);
//Controls: Legend
var oLegend = L.control({position: 'bottomleft'});
oLegend.onAdd = function(oMap) {return $('#legend').clone()[0];};
oLegend.addTo(oMap);
//Controls: Projects
var $Labels = $('<div>', {'class': 'leaflet-control-layers-base'});
$.each(self.vars('projects'), function(sCodeName, asProject){
var asRadioAttrs = {'type': 'radio', 'class': 'leaflet-control-layers-selector', 'name':'project', 'value': sCodeName};
if(asProject.id == self.vars(['project', 'id'])) asRadioAttrs.checked = 'checked';
var $Radio = $('<input>', asRadioAttrs).change(function(){
toggleSettingsPanel(false);
self.setHash(self.vars('page'), [$(this).val()]);
});
var $Label = $('<label>').append($('<div>')
.append($Radio)
.append($('<span>').text(' '+asProject.name))
.append($('<a>', {'class':'fa fa-download push-left', href:asProject.gpxfilepath, title:oSpot.lang('track_download')}).click(function(e){e.stopPropagation();}))
);
$Labels.append($Label);
});
$('#settings-projects').empty().append($Labels);
//Controls: Scale
oScale = L.control.scale({imperial: false, 'position':'bottomright'}).addTo(oMap);
//Controls: Elevation
if(!isMobile()) {
var aoElevTracks = {type: 'FeatureCollection', features:[], properties: {summary: 'Elevation'}};
var aoLegend = {Elevation: {}};
for(var i in aoTracks.features) {
if(aoTracks.features[i].properties.type != 'hitchhiking') {
//Feature
var oTrack = jQuery.extend(true, {}, aoTracks.features[i]);
var sType = aoTracks.features[i].properties.type;
oTrack.properties.attributeType = sType;
aoElevTracks.features.push(oTrack);
//Legend
aoLegend.Elevation[sType] = {text: oSpot.lang('track_'+sType), color: self.tmp(['track-type-styles', sType, 'color'])};
}
}
var oElev = L.control.heightgraph({
position: 'bottomright',
expand: false,
width: getElevWidth(),
height: 200,
margins: {
top: 10,
right: 30,
bottom: 55,
left: 50
},
translation: {
distance: oSpot.lang('distance'),
elevation: oSpot.lang('elevation'),
segment_length: oSpot.lang('segment_length'),
type: oSpot.lang('type'),
legend: oSpot.lang('legend')
},
xTicks: 3,
yTicks: 2,
mappings: aoLegend,
highlightStyle: {
weight: self.tmp(['track-type-styles', 'main', 'weight']),
opacity: 1,
color: '#326526'
},
expandCallback: function(bExpanded){$('.leaflet-control-scale').toggle(!bExpanded);}
});
oElev.addTo(oMap);
oElev.addData([aoElevTracks]);
self.tmp('elev', oElev);
self.tmp('elev-data', [aoElevTracks]);
}
//Controls: Tiles (layers): Add & Move to Settings Panel
L.control.layers(oSpot.tmp('layers'), null, {position: 'topleft'}).addTo(oMap);
$('#layers').empty().append($('.leaflet-control-layers-list .leaflet-control-layers-base'));
//Actual Tracks: Track with corresponding colors
var oActualTracks = L.geoJson(aoTracks, {
style: function(oTrack) {
return self.tmp(['track-type-styles', oTrack.properties.type]);
}
}).addTo(oMap);
//"Hover" Tracks: Wider track (thus hover area) to avoid flickering popups
self.tmp('track', L.geoJson(aoTracks, {
style: {weight: 20, opacity: 0},
onEachFeature: function(feature, oLayer) {
var asProperties = feature.properties;
var $Tooltip = $('<div>', {'class':'track_tooltip'});
$('<p>', {'class':'name'}).addIcon('fa-track-'+asProperties.type, true).append(asProperties.name).appendTo($Tooltip);
if(asProperties.Name != asProperties.description) $('<p>', {'class':'description'}).text(asProperties.description).appendTo($Tooltip);
var aiCoords = feature.geometry.coordinates;
if(aiCoords && asProperties.type != 'hitchhiking') {
var iDistance = 0, iElevDrop = 0, iElevGain = 0, iTime = 0;
//Track duration (in hours)
for (var i = 1; i < aiCoords.length; i++) {
var oCurrPoint = new L.LatLng(aiCoords[i][1], aiCoords[i][0]);
var oPrevPoint = new L.LatLng(aiCoords[i - 1][1], aiCoords[i - 1][0]);
var iElevDelta = aiCoords[i][2] - aiCoords[i - 1][2];
var iSegDistance = oCurrPoint.distanceTo(oPrevPoint);
iDistance += iSegDistance;
iElevDrop += Math.min(iElevDelta, 0);
iElevGain += Math.max(iElevDelta, 0);
var iSpeedCorrecRatio = 0;
var iAngle = iElevDelta / iSegDistance;
if(iAngle < -1) iSpeedCorrecRatio = 0.5;
else if(iAngle < -0.2) iSpeedCorrecRatio = 1.25;
else if(iAngle < 0.1) iSpeedCorrecRatio = 1;
else if(iAngle < 0.25) iSpeedCorrecRatio = 0.85;
else if(iAngle < 0.5) iSpeedCorrecRatio = 0.6;
else if(iAngle < 1) iSpeedCorrecRatio = 0.5;
else iSpeedCorrecRatio = 0.25;
iTime += iSegDistance / 1000 * iSpeedCorrecRatio / 3.5 ; //default speed: 3.5km/h
}
//Conversion of hours into natural language
var iTimeMinutes = 0, iTimeHours = 0, iTimeDays = Math.floor(iTime/8); //8 hours a day
if(iTimeDays > 1) iTimeDays = Math.round(iTimeDays * 2) / 2; //Round down to the closest half day
else {
iTimeDays = 0;
iTimeHours = Math.floor(iTime);
iTime -= iTimeHours;
iTimeMinutes = Math.floor(iTime * 4) * 15; //Round down to the closest 15 minutes
}
var sDuration = '~ '
+(iTimeDays>0?(iTimeDays+(iTimeDays%2==0?'':'½')+' '+oSpot.lang(iTimeDays>1?'unit_days':'unit_day')):'')//Days
+((iTimeHours>0 || iTimeDays==0)?iTimeHours+oSpot.lang('unit_hour'):'') //Hours
+((iTimeDays>0 || iTimeMinutes==0)?'':iTimeMinutes) //Minutes
//Tooltip info
$('<p>', {'class':'detail'}).addIcon('fa-distance fa-fw', true).append(Math.round(iDistance/1000)+'km').appendTo($Tooltip);
$('<p>', {'class':'detail'}).addIcon('fa-time fa-fw', true).append(sDuration).appendTo($Tooltip);
$('<p>', {'class':'detail'}).addIcon('fa-elev-gain fa-fw', true).append(iElevGain+'m').appendTo($Tooltip);
$('<p>', {'class':'detail'}).addIcon('fa-elev-drop fa-fw', true).append(iElevDrop+'m').appendTo($Tooltip);
//Trail Start/End
self.tmp(['trail-markers', asProperties.name], {
'start' : L.marker(new L.latLng(aiCoords[0][1], aiCoords[0][0]), {icon: getDivIcon('track-start')}),
'end' : L.marker(new L.latLng(aiCoords[aiCoords.length - 1][1], aiCoords[aiCoords.length - 1][0]), {icon: getDivIcon('track-end')})
});
oLayer
.bindPopup($Tooltip[0])
.on('click', function(e) {
toggleSoftPopup(e, 'open');
})
.on('popupclose', function(e) {
toggleSoftPopup(e, 'close');
});
}
}
}).addTo(oMap));
//Centering map
var bWithFeedPanel = (!bNoFeed && !isMobile());
if(
self.vars(['project', 'mode']) == self.consts.modes.blog &&
aoMessages.length > 0 &&
self.getHash()[2] != 'message'
) {
//Zoom on last message
var oLastMsg = aoMessages[aoMessages.length-1];
oMap.setView(L.latLng(oLastMsg.latitude, oLastMsg.longitude), 15);
oMap.panBy([(bWithFeedPanel?1:0)*self.tmp('$Feed').outerWidth(true)/2, 0]);
}
else oMap.fitBounds(self.tmp('track').getBounds(), {paddingTopLeft: L.point(5, self.tmp('marker_size').height + 5), paddingBottomRight: L.point(5 + parseInt(bWithFeedPanel?self.tmp('$Feed').outerWidth(true):0), 5)});
//Spot Messages
$.each(aoMessages, function(iKey, oMsg){
//Marker
var oMarker = L.marker(L.latLng(oMsg.latitude, oMsg.longitude), {
id: oMsg.id_message,
riseOnHover: true,
icon: getDivIcon('message-in fa-rotate-270')
}).addTo(oMap);
//Tooltip
$Tooltip = $('<div>', {'class':'info-window'})
.append($('<h1>')
.addIcon('fa-message fa-fw fa-lg')
.append('Message '+oMsg.type+' '+oSpot.lang('counter', oMsg.displayed_id)))
.append($('<p>', {'class':'coordinates'})
.addIcon('fa-coords fa-fw fa-lg', false)
.append(getGoogleMapsLink(oMsg)))
.append($('<p>', {'class':'time'})
.addIcon('fa-time fa-fw fa-lg')
.append(oMsg.formatted_time+(self.vars(['project', 'mode'])==self.consts.modes.blog?' ('+oMsg.relative_time+')':'')));
//Tooltip: Time Zone
if(self.tmp('site_tz_notice')!='') {
$Tooltip.append($('<p>', {'class':'timezone'})
.addIcon('fa-timezone fa-fw fa-lg')
.append(self.tmp('site_tz_notice')));
}
//Tooltip: Medias
if(oMsg.medias) {
var $Medias = $('<div>', {'class':'medias'});
$.each(oMsg.medias, function(iKey, asMedia) {
$Medias.append(getMediaLink(asMedia, 'marker'));
});
$Tooltip
.append($('<p>').addIcon('fa-media fa-fw fa-lg').append(oSpot.lang('images')))
.append($Medias);
}
oMarker.bindPopup($Tooltip[0], {
maxWidth: self.tmp('$Projects').width(),
autoPan: false,
closeOnClick: true,
offset: new L.Point(0, -30)
});
//Open tooltip on latest message in mobile mode
if(
iKey === (aoMessages.length - 1) &&
self.vars(['project', 'mode']) == self.consts.modes.blog &&
(!oMsg.medias || oMsg.medias.length < 3) &&
isMobile()
) oMarker.openPopup();
oSpot.tmp(['markers', oMsg.id_message], oMarker);
});
/*
oSpot.tmp('tracks', aoTracks);
next(24, 0, 0, 5);
*/
}
function toggleSoftPopup(e, sMode) {
switch(sMode) {
case 'open':
var oLatLng = L.GeometryUtil.closest(self.tmp('map'), e.sourceTarget, e.latlng);
var oPopup = e.target.getPopup();
if(!oPopup.isOpen()) oPopup.setLatLng(oLatLng).openOn(self.tmp('map'));
var asTrailMarkers = self.tmp(['trail-markers', e.target.feature.properties.name]);
var oPointStart = self.tmp('map').latLngToLayerPoint(asTrailMarkers.start.getLatLng());
var oPointEnd = self.tmp('map').latLngToLayerPoint(asTrailMarkers.end.getLatLng());
if(oPointStart.distanceTo(oPointEnd) > 200) {
asTrailMarkers.start.addTo(self.tmp('map'));
asTrailMarkers.end.addTo(self.tmp('map'));
}
break;
case 'close':
//e.target.closePopup();
var asTrailMarkers = self.tmp(['trail-markers', e.target.feature.properties.name]);
asTrailMarkers.start.remove();
asTrailMarkers.end.remove();
break;
}
}
/*
function next(iCurrTrack, iCurrIndex, iCurrOffset, iCurrZoom) {
var aoTracks = oSpot.tmp('tracks');
var aoOffset = {0:[0,0], 1:[-1,0], 2:[-1,1], 3:[0,1], 4:[1,1], 5:[1,0], 6:[1,-1], 7:[0,-1], 8:[-1,-1]};
console.log('Getting Track '+iCurrTrack+'/'+(aoTracks.features.length - 1)+', '+
'Point '+iCurrIndex+'/'+(aoTracks.features[iCurrTrack].geometry.coordinates.length - 1)+', '+
'Zoom '+iCurrZoom+'/14, '+
'Offset ['+aoOffset[iCurrOffset][0]+','+aoOffset[iCurrOffset][1]+']');
//Position map
var iLat = aoTracks.features[iCurrTrack].geometry.coordinates[iCurrIndex][1] + aoOffset[iCurrOffset][1] * 0.0347910214271;
var iLng = aoTracks.features[iCurrTrack].geometry.coordinates[iCurrIndex][0] + aoOffset[iCurrOffset][0] * 0.1016235351560;
oSpot.tmp('map').setView(L.latLng(iLat, iLng), iCurrZoom);
//Go to next
iCurrOffset++;
if(iCurrZoom < 13 || iCurrOffset > 8) {
iCurrOffset = 0;
iCurrZoom++;
if(iCurrZoom > 14) {
iCurrZoom = 5;
iCurrIndex += 100;
if(!(iCurrIndex in aoTracks.features[iCurrTrack].geometry.coordinates)) {
iCurrIndex = 0;
iCurrTrack++;
if(!(iCurrTrack in aoTracks.features)) return true;
}
}
}
timer = setTimeout(function(){next(iCurrTrack, iCurrIndex, iCurrOffset, iCurrZoom);}, 1000);
}
function stop() {
clearTimeout(timer);
}
*/
function getDivIcon(sIcon) {
return L.divIcon({
className: '',
html: '<span class="fa-stack"><i class="fa fa-message fa-stack-2x"></i><i class="fa fa-'+sIcon+' fa-stack-1x"></i></span>',
iconSize: [self.tmp('marker_size').width, self.tmp('marker_size').height],
iconAnchor: [self.tmp('marker_size').width / 2, self.tmp('marker_size').height] //position from icon's top left corner (iconAnchor = marker's position)
});
}
function onFeedScroll() {
var $Box = $(this);
var $BoxContent = $Box.find('.simplebar-content');
if(($Box.scrollTop() + $(window).height()) / $BoxContent.height() >= 0.8) updateFeed();
}
function updateFeed(bFirstChunk, bDiscrete, fCallback) {
bFirstChunk = bFirstChunk || false;
bDiscrete = bDiscrete || false;
fCallback = fCallback || function(){};
if(self.tmp('updatable')) {
if(!self.tmp('out-of-data') || bFirstChunk) {
self.tmp('updatable', false);
if(!bDiscrete) $('#loading').show();
var $Posts = $('<div>');
if(bFirstChunk===true) {
self.tmp('news_chunk', 0);
}
self.get(
'feed',
function(asData) {
$('#loading').hide();
$.each(asData, function(iKey, asPost){
$Posts.append(getPost(asPost));
});
self.tmp('news_chunk', self.tmp('news_chunk') + 1);
self.tmp('out-of-data', Object.keys(asData).length != self.vars('chunk_size'));
if(bFirstChunk===true) self.tmp('$PostList').empty();
self.tmp('$PostList').append($Posts.children());
self.tmp('$PostList').find('img').waitForImages(true).done(fCallback);
self.tmp('updatable', true);
}, {
'project_id': self.vars(['project', 'id']),
'chunk': self.tmp('news_chunk')
}
);
}
}
else if(bFirstChunk) setFeedUpdateTimer(0.2);
}
function focusOnPost(oFocusPost) {
if(oFocusPost) {
var sElemId = '#'+oFocusPost.type+'-'+oFocusPost.id;
var $Post = oSpot.tmp('$PostList').find(sElemId);
var bGetToTop = (oSpot.tmp('$PostList').height() - (($Post.length > 0)?$Post.position().top:0) >= $(window).height());
if($Post.length > 0 && (bGetToTop || self.tmp('out-of-data'))) {
self.tmp('simple-bar').getScrollElement().scrollTop =
parseInt($Post.position().top) +
parseInt($Post.css('margin-top')) +
(self.tmp('$Poster').is(':visible')?self.tmp('$Poster').outerHeight(true):0);
if(oFocusPost.type=='media' || oFocusPost.type=='message') $Post.find('a.drill').click();
}
else if(!self.tmp('out-of-data')) updateFeed(false, false, function() {focusOnPost(oFocusPost);});
else console.log('Missing element ID '+sElemId);
//Reset Hash
flushHash(['post', 'message']);
}
}
function setFeedUpdateTimer(iSeconds, fCallback) {
fCallback = fCallback || function(){updateFeed(true);};
if(typeof self.tmp('update_timer') != 'undefined') clearTimeout(self.tmp('update_timer'));
if(iSeconds > 0) self.tmp('update_timer', setTimeout(fCallback, iSeconds * 1000));
}
function getPost(asPost) {
asPost.headerless = asPost.headerless || false;
var bLink = false;
var asHash = oSpot.getHash();
var sHash = '#'+[asHash.page, asHash.items[0], asPost.type, asPost.id].join(self.consts.hash_sep);
var $Post = $('<div>', {'class':'post-item '+asPost.type+(asPost.headerless?' headerless':'')});
if(asPost.id) $Post.prop('id', asPost.type+'-'+asPost.id);
var sRelTime = (asPost.relative_time!='')?((self.vars('project') && self.vars(['project', 'mode'])==self.consts.modes.histo)?asPost.formatted_time.substr(0, 10):asPost.relative_time):'';
var sAbsTime = asPost.formatted_time;
var sType = asPost.subtype || asPost.type;
var $Body = {};
switch(asPost.type) {
case 'message':
bLink = true;
$Body = $('<div>', {'class':'body-box'})
.data('id', asPost.id_message)
.data('clicked', false)
.append($('<p>').addIcon('fa-coords', true).append(getGoogleMapsLink(asPost)))
.append($('<p>').addIcon('fa-time', true).append(sAbsTime))
.append((self.tmp('site_tz_notice')!='')?$('<p>').addIcon('fa-timezone', true).append(self.tmp('site_tz_notice')):'')
.append($('<a>', {'class':'drill'})
.append($('<img>', {'class':'staticmap', title: oSpot.lang('click_zoom'), src: getWmtsApiUrl('static', asPost.latitude, asPost.longitude, 13)}))
.append($('<span>', {'class': 'drill-icon fa-stack'})
.addIcon('fa-message-in fa-stack-1x fa-rotate-270')
.addIcon('fa-message fa-stack-2x')
)
.click(function(){
var $Parent = $(this).parent();
var oMarker = oSpot.tmp(['markers', $Parent.data('id')]);
if(isMobile()) {
toggleFeedPanel(false, 'panToInstant');
oSpot.tmp('map').setView(oMarker.getLatLng(), 15);
}
else {
var iOffset = (isFeedPanelOpen()?1:-1)*self.tmp('$Feed').outerWidth(true)/2 - (isSettingsPanelOpen()?1:-1)*self.tmp('$Settings').outerWidth(true)/2;
var iRatio = -1 * iOffset / $('body').outerWidth(true);
oSpot.tmp('map').setOffsetView(iRatio, oMarker.getLatLng(), 15);
}
$Parent.data('clicked', true);
if(!oMarker.isPopupOpen()) oMarker.openPopup();
})
)
.hover(
function(){
var oMarker = oSpot.tmp(['markers', $(this).data('id')]);
if(oSpot.tmp('map').getBounds().contains(oMarker.getLatLng()) && !oMarker.isPopupOpen()) oMarker.openPopup();
},
function(){
var oMarker = oSpot.tmp(['markers', $(this).data('id')]);
if(oMarker.isPopupOpen() && !$(this).data('clicked')) oMarker.closePopup();
$(this).data('clicked', false);
}
);
break;
case 'media':
bLink = true;
$Body = $('<div>', {'class':'body-box'}).append(getMediaLink(asPost, 'post'));
if(asPost.comment) $Body.find('a.drill').append($('<span>', {'class':'comment'}).text(asPost.comment));
break;
case 'post':
bLink = true;
$Body = $('<div>')
.append($('<p>', {'class':'message'}).text(asPost.content))
.append($('<p>', {'class':'signature'}).text('-- '+asPost.formatted_name));
break;
case 'poster':
$Body = $('<p>', {'class':'message'})
.append($('<textarea>', {id:'post', name:'post', placeholder:oSpot.lang('post_message'), 'class':'autoExpand', rows:'1'}))
.append($('<input>', {type:'text', id:'name', name:'name', placeholder:oSpot.lang('post_name')}))
.append($('<button>', {type:'button', id:'submit', name:'submit'}).addIcon('fa-send'));
break;
case 'loading':
$Body = $('<p>', {'class':'flicker'}).addIcon('fa-post');
break;
}
$Post
.append($('<div>', {'class':'header'})
.append($('<span>', {'class':'index'})
.addIcon('fa-'+sType)
.append(asPost.displayed_id?' '+oSpot.lang('counter', asPost.displayed_id):'')
)
.append($('<span>', {'class':'time'}).hoverSwap(sRelTime, sAbsTime+((self.tmp('site_tz_notice')!='')?' ('+self.tmp('site_tz_notice')+')':''))))
.append($('<div>', {'class':'body'}).append($Body));
if(bLink) {
$Post.find('.index').append($('<a>', {'class':'link', 'href':sHash, 'title':oSpot.lang('copy_to_clipboard')})
.data('url', self.consts.server+sHash)
.addIcon('fa-link')
.click(function(){
copyTextToClipboard($(this).data('url'));
$(this).parent().append($('<span>', {'class':'link_copied'})
.text(oSpot.lang('link_copied'))
.delay(5000)
.fadeOut(400, function(){$(this).remove();})
);
})
);
}
return $Post;
}
function getWmtsApiUrl(sMapId, iLat, iLng, iZoom) {
return self.consts.geo_server+'?a=tile&id='+sMapId+'&z='+iZoom+'&x='+iLng+'&y='+iLat;
}
function getMediaLink(asData, sType) {
var $PostedOn =
$('<span>', {'class': 'lb-caption-line', title: oSpot.lang(asData.subtype)+' '+oSpot.lang('add_on', asData.posted_on_formatted)})
.addIcon('fa-upload fa-lg fa-fw', true)
.append(asData.posted_on_formatted);
var $TakenOn = (asData.taken_on == '0000-00-00 00:00:00')?'':
$('<span>', {'class': 'lb-caption-line', title: oSpot.lang(asData.subtype)+' '+oSpot.lang(asData.subtype+'_taken', asData.taken_on_formatted)})
.addIcon('fa-'+asData.subtype+'-shot fa-lg fa-fw', true)
.append(asData.taken_on_formatted);
var $Timezone = (self.tmp('site_tz_notice') == '')?'':
$('<span>', {'class': 'lb-caption-line'})
.addIcon('fa-timezone fa-lg fa-fw', true)
.append(self.tmp('site_tz_notice'));
var $Title = $('<div>').append(sType=='marker'?$TakenOn:$PostedOn).append(sType=='marker'?$PostedOn:$TakenOn).append($Timezone);
var $Link =
$('<a>', {
'class': 'drill',
'href': asData.media_path,
'data-lightbox': sType+'-medias',
'data-type': asData.subtype,
'data-id': asData.id_media,
'data-title': $Title.html(),
'data-orientation': asData.rotate
})
.append($('<img>', {'src': asData.thumb_path, title: oSpot.lang((asData.subtype == 'video')?'click_watch':'click_zoom')}))
.append($('<span>', {'class': 'drill-icon'}).addIcon('fa-drill-'+asData.subtype));
return $Link;
}
function getGoogleMapsLink(asInfo) {
return $('<a>', {
href:'https://www.google.com/maps/place/'+asInfo.lat_dms+'+'+asInfo.lon_dms+'/@'+asInfo.latitude+','+asInfo.longitude+',10z',
title: oSpot.lang('see_on_google'),
target: '_blank',
rel: 'noreferrer'
}).text(asInfo.lat_dms+' '+asInfo.lon_dms);
}
function updateHash(sType, iId) {
sType = sType || '';
iId = iId || 0;
var asHash = self.getHash();
if(iId) self.setHash(asHash.page, [asHash.items[0], sType, iId]);
}
function flushHash(asTypes) {
asTypes = asTypes || [];
var asHash = self.getHash();
if(asHash.items.length > 1 && (asTypes.length == 0 || asTypes.indexOf(asHash.items[1]) != -1)) self.setHash(asHash.page, [asHash.items[0]]);
}
</script>
+68
View File
@@ -0,0 +1,68 @@
<div id="upload">
<h1>[#]lang:upload_title[#]</h1>
<input id="fileupload" type="file" name="files[]" multiple>
<div id="progress">
<div class="bar" style="width: 0%;"></div>
</div>
<div id="comments"></div>
<div id="status"></div>
</div>
<script type="text/javascript">
oSpot.pageInit = function(asHash) {
var asProject = self.vars(['projects', self.vars('default_project_codename')]);
self.tmp('status-box', $('#status'));
if(asProject.mode == self.consts.modes.blog) {
$('#fileupload')
.attr('data-url', self.getActionLink('upload'))
.fileupload({
dataType: 'json',
acceptFileTypes: /(\.|\/)(gif|jpe?g|png|mov)$/i,
done: function (e, asData) {
$.each(asData.result.files, function(iKey, oFile) {
var bError = ('error' in oFile);
//Feedback
addStatus(bError?oFile.error:(self.lang('upload_success', [oFile.name])));
//Comments
if(!bError) addCommentBox(oFile.id, oFile.thumbnail);
});
},
progressall: function (e, data) {
var progress = parseInt(data.loaded / data.total * 100, 10);
$('#progress .bar').css('width', progress+'%');
}
});
}
else addStatus(self.lang('upload_wrong_mode', [asProject.name]), true);
};
function addCommentBox(iMediaId, sThumbnailPath) {
$('#comments').append($('<div>', {'class':'comment'})
.append($('<img>', {'class':'thumb', 'src':sThumbnailPath}))
.append($('<form>')
.append($('<input>', {'class':'content', 'name':'content', 'type':'text'}))
.append($('<input>', {'class':'id', 'name':'id', 'type':'hidden', 'value':iMediaId}))
.append($('<button>', {'class':'save', 'type':'button'})
.click(function(){
var $Form = $(this).parent();
oSpot.get(
'add_comment',
function(asData){addStatus(self.lang('media_comment_update', asData.filename));},
{id:$Form.find('.id').val(), content:$Form.find('.content').val()},
function(sMsgId){addStatus(self.lang(sMsgId));},
);
})
.text(self.lang('save'))
)
)
);
}
function addStatus(sMsg, bClear) {
bClear = bClear || false;
if(bClear) self.tmp('status-box').empty();
self.tmp('status-box').append($('<p>').text(sMsg));
}
</script>
-3034
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
{
"name": "livetrail",
"description": "Self-hosted live journey map combining SPOT GPS tracking, GPX routes, posts, and media",
"version": "3.0.1",
"main": "index.js",
"private": true,
"scripts": {
"dev": "vite build --mode development --watch",
"prod": "vite build",
"lint": "eslint src"
},
"keywords": [],
"author": "Franzz",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.8",
"eslint": "^10.9.1",
"eslint-plugin-vue": "^10.10.0",
"globals": "^17.11.0",
"vite": "^8.1.5"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.2.0",
"@uppy/core": "^6.0.0",
"@uppy/xhr-upload": "^6.0.0",
"autosize": "^6.0.1",
"maplibre-gl": "^6.0.0",
"sass": "^1.97.2",
"simplebar-vue": "^2.3.3",
"vue": "^3.5.40"
},
"allowScripts": {
"@parcel/watcher@2.6.0": true,
"vue-demi@0.13.11": true
}
}
-7
View File
@@ -1,7 +0,0 @@
<?php
require __DIR__.'/../vendor/autoload.php';
use Franzz\Livetrail\Controller;
echo (new Controller())->handle(__FILE__, $argv ?? []);
+18 -67
View File
@@ -1,67 +1,18 @@
# Live Trail Project # Spot Project
[Spot](https://www.findmespot.com) & GPX integration
Live Trail is a self-hosted web application for sharing journeys on an interactive map. It combines live GPS positions from a [SPOT](https://www.findmespot.com) tracker with GPX routes, posts, photos, videos, and a chronological activity feed, supporting both real-time tracking and historical travel stories. ## Dependencies
* php-mbstring
## Dependencies * php-imagick
* ffprobe (ffmpeg)
* npm 24+ * STARTTLS Email Server (use Gmail if none available)
* composer * Optional: Geo Caching Server (WMTS Caching Service)
* php-mbstring ## Getting started
* php-imagick 1. Clone Git onto web server
* php-gd 2. Copy settings-sample.php to settings.php and populate
* php-mysql 3. Go to #admin and create a new project
* php-exif ## To Do List
* ffprobe & ffmpeg * ECMA import/export
* STARTTLS Email Server (use Gmail if none available) * Reset zoom on image closing (lightbox)
* Optional: Geo Caching Server (WMTS Caching Service) * Add mail frequency slider
* Replace Project Time Zone with browser Time Zone when uploading media?
## PHP Configuration * Use WMTS servers directly when not using Geo Caching Server
* max_execution_time = 300
* memory_limit = 500M
* post_max_size = 4G
* upload_max_filesize = 4G
* max_file_uploads = 50
## Getting started
1. Clone Git onto web server
2. Update php.ini parameters
3. Copy timezone data: mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql
4. Copy settings-sample.php to settings.php and populate
5. Follow CI/CD script in .gitea/workflows/deploy.yml
6. Add a GPX file named <project_codename>.gpx to /resources/geo/
7. Go to #admin and create a new project (with code name = <project_codename>) & feed
## Web Root
The web server should serve `public/` as the application document root. PHP source, configuration, Composer dependencies, uploaded files, and GPX data stay outside the public tree; `public/index.php` is the front controller and Vite writes generated frontend assets to `public/assets/`.
Runtime data is exposed through symlinks only: `public/files -> ../files` and `public/geo -> ../resources/geo`. The build must not copy uploaded media or GPX data into `public/`.
## Local Development
`npm run dev` watches and builds development assets into `public/assets/`. `npm run prod` builds hashed production assets into `public/assets/`. PHP reads `public/.vite/manifest.json` and populates the CSS, module, and preload parts defined in `resources/masks/index.html` while rendering the request.
When developing Livetrail and the sibling `objects` library together, install dependencies through the local Composer manifest:
```bash
COMPOSER=composer.dev.json composer update
```
This makes Composer link `vendor/franzz/objects` to `../objects` and autoload that namespace directly from the local source path. Production continues to use `composer.json`, which installs `franzz/objects` from its Git repository. Commit and publish `objects` changes before updating/deploying a Livetrail version that relies on them.
## Before Committing
Run manually before pushing:
* `composer lint` - checks PHP style (php-cs-fixer, dry-run); `composer lint-fix` applies it. Requires the dev dependencies (`COMPOSER=composer.dev.json composer update`, see Local Development above).
* `npm run lint` - checks JS/Vue style (ESLint) on demand. It also already runs automatically as part of `npm run dev` (reports issues but never blocks the watcher) and `npm run prod` (aborts the build if any lint error is found).
## To Do List
* Add mail frequency slider
* Use WMTS servers directly when not using Geo Caching Server
* Allow HEIF picture format
* Garmin InReach Integration
-215376
View File
File diff suppressed because it is too large Load Diff
-69644
View File
File diff suppressed because it is too large Load Diff
-237
View File
@@ -1,237 +0,0 @@
{
"action": {
"back": "Back",
"delete": "Delete",
"save": "Save",
"send": "Send"
},
"account": {
"login": "Log in",
"logout": "Log out",
"confirm_password": "Confirm password",
"email_placeholder": "my@email.com",
"invalid_credentials": "Incorrect password",
"invalid_email": "This doesn't look like a valid email address",
"logged_in": "Logged in",
"logged_out": "Logged out",
"newsletter": "Email me new updates",
"password": "Password",
"password_mismatch": "The passwords don't match",
"password_required": "Enter your password to continue",
"password_set": "Your password has been set successfully.",
"set_password": "Set a password for this administrator account",
"subscribed": "Thanks! You'll receive a confirmation email shortly.",
"unsubscribed": "Done. No more junk mail from me.",
"title": "Account",
"status": {
"logged_in": "You're logged in.",
"logged_in_named": "Hello $0, you're logged in.",
"logged_out": "Enter your email address below to receive my position as soon as I find it :)",
"subscribed": "You're all set. I'll send you updates!"
}
},
"admin": {
"config": "Settings",
"create_success": "Created",
"delete_success": "Deleted",
"save_success": "Saved",
"title": "Admin Panel",
"toolbox": "Toolbox",
"upload": "Upload"
},
"credits": {
"git": "Git repository",
"license": "licensed under GPLv3",
"project": "$0 Project"
},
"email": {
"confirmation": {
"body_1": "Thank you for following my wanderings :). I'll make sure to keep you posted on my progress along the trail.",
"body_2": "I usually check in once a day, and sometimes at special moments too, like successful peak ascents. I'm using a GPS-based device (PLB), which doesn't require phone reception to work. So messages should be fairly frequent, but — being awestruck by the beauty of nature — I might also just forget to send a signal once in a while. So don't worry if you don't receive anything for a couple of days.",
"body_3": "If I've posted any pictures recently, you'll also find them in the same email.",
"conclusion": "See you down the road!",
"preheader": "Thanks for keeping in touch!",
"signature": "--François",
"subject": "Registration confirmed",
"thanks_subject": "You're all set!"
},
"failure": "Failed to send the update email",
"sent": "Update email sent",
"unsubscribe": "PS: Changed your mind?",
"unsubscribe_button": "Unsubscribe",
"update": {
"latest_news": "Latest news:",
"preheader": "New position received",
"subject": "Spotted!",
"title": "Message"
}
},
"error": {
"commit_db": "Error committing to database",
"file_missing": "$0 file \"$1\" is missing",
"impossible_value": "Value \"$0\" is not valid for field \"$1\"",
"no_auth": "Not authorized",
"not_found": "Unknown action",
"unknown_field": "Unknown field \"$0\"",
"unknown_type": "Unknown type \"$0\""
},
"feed": {
"counter": "#$0",
"id": "Feed ID",
"last_update": "Last SPOT update",
"name": "Name",
"new": "New feed",
"plural": "Feeds",
"ref_id": "Feed reference ID",
"status": "Status"
},
"map": {
"ign_france": "IGN (France)",
"ign_spain": "IGN (Spain)",
"linz": "LINZ",
"natgeo": "National Geographic",
"otm": "Open Topo Map",
"outdoors": "Mapbox Outdoors",
"satellite": "Satellite",
"see_on_google": "View position on Google Maps",
"terrain": "3D terrain",
"title": "Base maps",
"usgs": "USGS"
},
"media": {
"click_watch": "Click to watch the video",
"click_zoom": "Click to zoom",
"comment_update": "Comment for media \"$0\" updated",
"image": "Picture",
"image_taken_on": "Taken on $0",
"images": "Pictures",
"nearby": "Nearby pictures",
"no_id": "Missing media ID in request",
"posted_on": "Posted on $0",
"video": "Video",
"video_taken_on": "Shot on $0"
},
"meta": {
"locale": "en_NZ",
"page_og_desc": "Stay in touch while I'm away hiking."
},
"post": {
"copy_to_clipboard": "Copy direct link to clipboard",
"link_copied": "Link copied!",
"message": "Message",
"name": "Name",
"new_message": "New message",
"reply": "Reply to this message"
},
"project": {
"wip": "In progress",
"code_name": "Code name",
"end": "End",
"hikes": "Hikes",
"id": "Project ID",
"mode": "Mode",
"modes": {
"blog": "Active project",
"histo": "Archived project",
"previz": "Project in preparation"
},
"new": "New project",
"overview": "Overview",
"plural": "Projects",
"single": "Project",
"start": "Start",
"update_messages": "Update project messages"
},
"spot": {
"id": "Spot ID",
"model": "Model",
"name": "Spot name",
"no_new_msg": "No new messages",
"plural": "Spots",
"ref_id": "Ref. Spot ID"
},
"stats": {
"duration": "Duration",
"distance": "Distance",
"elevation": "Elevation",
"elevation_gain": "Elevation gain",
"elevation_loss": "Elevation loss",
"from": "Start",
"legend": "Legend",
"segment_length": "Segment length",
"to": "Finish",
"type": "Track type"
},
"time": {
"city": "$0 time",
"date_time": "$0 at $1",
"local": "$0 local time",
"user": "$0 your time",
"zone": "Time zone"
},
"track": {
"download": "Download GPX track",
"hitchhiking": "Hitchhiking",
"main": "Main track",
"off-track": "Off-track"
},
"unit": {
"day": "day",
"day_short": "D",
"days": "days",
"hour": "h"
},
"upload": {
"error": "Upload failed",
"media": {
"exists": "Picture $0 already exists",
"title": "Picture and video uploads"
},
"mode_archived": "Project \"$0\" is archived. Uploads are not allowed.",
"position": {
"determining": "Determining position…",
"error": "Unable to determine position",
"new": "New position",
"permission_denied": "Location permission was denied",
"sending": "Sending position…",
"success": "Position uploaded successfully",
"timeout": "Position request timed out",
"title": "Add position",
"unavailable": "Position is unavailable",
"unsupported": "This browser does not support geolocation"
},
"success": "$0 uploaded successfully"
},
"user": {
"subscribed": "Subscribed users",
"clearance": "Clearance",
"email": "Email",
"id": "User ID",
"language": "Language",
"name": "User name"
},
"weather": {
"clear-day": "Cloud cover is less than 20% during the day",
"clear-night": "Cloud cover is less than 20% during the night",
"cloudy": "Cloud cover is greater than 90%",
"fog": "Visibility is low (less than 1km)",
"hail": "Hail showers",
"partly-cloudy-day": "Cloud cover is greater than 20% during the day",
"partly-cloudy-night": "Cloud cover is greater than 20% during the night",
"rain": "Rainfall is greater than zero",
"rain-snow": "Snow and rain showers",
"rain-snow-showers-day": "Possible rain/snow throughout the day",
"rain-snow-showers-night": "Possible rain/snow throughout the night",
"showers-day": "Rain showers during the day",
"showers-night": "Rain showers during the night",
"sleet": "Sleet",
"snow": "Snowfall is greater than zero",
"snow-showers-day": "Periods of snow during the day",
"snow-showers-night": "Periods of snow during the night",
"thunder": "Thunderstorms",
"thunder-rain": "Thunderstorms throughout the day or night",
"thunder-showers-day": "Possible thunderstorms throughout the day",
"thunder-showers-night": "Possible thunderstorms throughout the night",
"wind": "Wind speed is high (greater than 30 km/h)"
}
}
-237
View File
@@ -1,237 +0,0 @@
{
"action": {
"back": "Atrás",
"delete": "Borrar",
"save": "Guardar",
"send": "Enviar"
},
"account": {
"login": "Iniciar sesión",
"logout": "Cerrar sesión",
"confirm_password": "Confirmar contraseña",
"email_placeholder": "nombre@email.com",
"invalid_credentials": "Contraseña incorrecta",
"invalid_email": "Esto no parece una dirección de correo electrónico",
"logged_in": "Sesión iniciada",
"logged_out": "Sesión cerrada",
"newsletter": "Enviarme nuevas actualizaciones por correo",
"password": "Contraseña",
"password_mismatch": "Las contraseñas no coinciden",
"password_required": "Introduce tu contraseña para continuar",
"password_set": "Tu contraseña se ha establecido correctamente.",
"set_password": "Establece una contraseña para esta cuenta administradora",
"subscribed": "¡Gracias! Recibirás un correo electrónico de confirmación.",
"unsubscribed": "Listo. ¡No más spam!",
"title": "Cuenta",
"status": {
"logged_in": "Has iniciado sesión.",
"logged_in_named": "Hola $0, has iniciado sesión.",
"logged_out": "Añade tu dirección de correo electrónico y te enviaré mi posición actualizada tan pronto como la reciba :)",
"subscribed": "Todo está listo. Te enviaré noticias frescas en cuanto las reciba. Prometido..."
}
},
"admin": {
"config": "Configuración",
"create_success": "Creado",
"delete_success": "Eliminado",
"save_success": "Guardado",
"title": "Administración",
"toolbox": "Herramientas",
"upload": "Cargar"
},
"credits": {
"git": "Repositorio de Git",
"license": "bajo licencia GPLv3",
"project": "Proyecto $0"
},
"email": {
"confirmation": {
"body_1": "Te agradezco mucho que sigas mi proyecto y que te intereses por su evolución. Te prometo que te mantendré informado sobre mi progreso.",
"body_2": "Normalmente envío un mensaje una vez al día. Cuando voy a lugares especiales, envío uno extra (cimas, ese tipo de cosas). Uso un dispositivo GPS para enviar la señal, así que no necesito cobertura telefónica para que funcione. Sin embargo, puede haber ocasiones en las que no presione el botón. Por lo tanto, no te preocupes si no recibes mensajes durante uno o dos días.",
"body_3": "Cuando añada fotos a la página, también las encontrarás en este correo electrónico.",
"conclusion": "¡Nos vemos en el camino!",
"preheader": "¡Gracias por mantenerte en contacto!",
"signature": "--François",
"subject": "Confirmación",
"thanks_subject": "¡Hecho!"
},
"failure": "No se pudo enviar el correo de actualización",
"sent": "Correo de actualización enviado",
"unsubscribe": "PD: ¿Demasiados correos electrónicos?",
"unsubscribe_button": "Darse de baja",
"update": {
"latest_news": "Últimas noticias:",
"preheader": "¡Nueva posición!",
"subject": "Nueva posición recibida",
"title": "Mensaje"
}
},
"error": {
"commit_db": "Error SQL",
"file_missing": "Falta el archivo $0 \"$1\"",
"impossible_value": "El valor \"$0\" no es posible para el campo \"$1\"",
"no_auth": "Sin autorización",
"not_found": "Acción desconocida",
"unknown_field": "Campo \"$0\" desconocido",
"unknown_type": "Tipo \"$0\" desconocido"
},
"feed": {
"counter": "N.º $0",
"id": "ID Feed",
"last_update": "Última actualización de SPOT",
"name": "Descripción",
"new": "Nuevo feed",
"plural": "Feeds",
"ref_id": "ID Feed ref.",
"status": "Estado"
},
"map": {
"ign_france": "IGN (Francia)",
"ign_spain": "IGN (España)",
"linz": "LINZ",
"natgeo": "National Geographic",
"otm": "Open Topo Map",
"outdoors": "Mapbox Topo",
"satellite": "Satélite",
"see_on_google": "Ver la posición en Google Maps",
"terrain": "Relieve 3D",
"title": "Mapas de base",
"usgs": "USGS"
},
"media": {
"click_watch": "Haz clic para ver el vídeo",
"click_zoom": "Haz clic para ampliar",
"comment_update": "Comentario \"$0\" actualizado",
"image": "Foto",
"image_taken_on": "Foto tomada el $0",
"images": "Fotos",
"nearby": "Fotos cercanas",
"no_id": "Falta el ID del archivo multimedia",
"posted_on": "Añadido el $0",
"video": "Vídeo",
"video_taken_on": "Vídeo grabado el $0"
},
"meta": {
"locale": "es_ES",
"page_og_desc": "Mantente en contacto conmigo durante mis aventuras en la montaña."
},
"post": {
"copy_to_clipboard": "Copiar el enlace",
"link_copied": "¡Enlace copiado!",
"message": "Mensaje",
"name": "Nombre",
"new_message": "Mensaje nuevo",
"reply": "Responder a este mensaje"
},
"project": {
"wip": "En curso",
"code_name": "Nombre clave",
"end": "Fin",
"hikes": "Senderos",
"id": "ID del proyecto",
"mode": "Modo",
"modes": {
"blog": "Proyecto activo",
"histo": "Proyecto archivado",
"previz": "Proyecto en preparación"
},
"new": "Nuevo proyecto",
"overview": "Vista general",
"plural": "Proyectos",
"single": "Proyecto",
"start": "Inicio",
"update_messages": "Actualizar los mensajes del proyecto"
},
"spot": {
"id": "ID de Spot",
"model": "Modelo",
"name": "Spot",
"no_new_msg": "No hay mensajes nuevos",
"plural": "Spots",
"ref_id": "ID de referencia de Spot"
},
"stats": {
"duration": "Duración",
"distance": "Distancia",
"elevation": "Elevación",
"elevation_gain": "Ascenso acumulado",
"elevation_loss": "Descenso acumulado",
"from": "Inicio",
"legend": "Leyenda",
"segment_length": "Tamaño del segmento",
"to": "Fin",
"type": "Tipo de sendero"
},
"time": {
"city": "Hora en $0",
"date_time": "$0 a las $1",
"local": "$0 hora local",
"user": "$0 en tu zona horaria",
"zone": "Huso horario"
},
"track": {
"download": "Descargar la ruta GPX",
"hitchhiking": "Autostop",
"main": "Camino principal",
"off-track": "Variante"
},
"unit": {
"day": "día",
"day_short": "D",
"days": "días",
"hour": "h"
},
"upload": {
"error": "Error al subir el archivo",
"media": {
"exists": "La imagen $0 ya existe",
"title": "Cargar fotos y vídeos"
},
"mode_archived": "El proyecto \"$0\" está archivado. No se puede cargar.",
"position": {
"determining": "Determinando la posición…",
"error": "No se puede determinar la posición",
"new": "Nueva posición",
"permission_denied": "Se ha denegado el permiso de ubicación",
"sending": "Enviando la posición…",
"success": "Posición subida correctamente",
"timeout": "La solicitud de posición ha agotado el tiempo de espera",
"title": "Subir posición",
"unavailable": "La posición no está disponible",
"unsupported": "Este navegador no admite la geolocalización"
},
"success": "$0 se ha subido correctamente."
},
"user": {
"subscribed": "Usuarios suscritos",
"clearance": "Nivel de autorización",
"email": "Correo electrónico",
"id": "ID del usuario",
"language": "Idioma",
"name": "Nombre"
},
"weather": {
"clear-day": "La nubosidad es inferior al 20 % durante el día",
"clear-night": "La nubosidad es inferior al 20 % durante la noche",
"cloudy": "La nubosidad es superior al 90 %",
"fog": "La visibilidad es baja (menos de 1km)",
"hail": "Chubascos de granizo",
"partly-cloudy-day": "La nubosidad es superior al 20 % durante el día",
"partly-cloudy-night": "La nubosidad es superior al 20 % durante la noche",
"rain": "La cantidad de lluvia es superior a cero",
"rain-snow": "Chubascos de nieve y lluvia",
"rain-snow-showers-day": "Posible lluvia/nieve durante todo el día",
"rain-snow-showers-night": "Posible lluvia/nieve durante toda la noche",
"showers-day": "Chubascos de lluvia durante el día",
"showers-night": "Chubascos de lluvia durante la noche",
"sleet": "Aguanieve",
"snow": "La cantidad de nieve es superior a cero",
"snow-showers-day": "Periodos de nieve durante el día",
"snow-showers-night": "Periodos de nieve durante la noche",
"thunder": "Tormentas",
"thunder-rain": "Tormentas durante el día o la noche",
"thunder-showers-day": "Posibles tormentas durante todo el día",
"thunder-showers-night": "Posibles tormentas durante toda la noche",
"wind": "La velocidad del viento es alta (más de 30 km/h)"
}
}
-237
View File
@@ -1,237 +0,0 @@
{
"action": {
"back": "Retour",
"delete": "Supprimer",
"save": "Sauvegarder",
"send": "Envoyer"
},
"account": {
"login": "Se connecter",
"logout": "Se déconnecter",
"confirm_password": "Confirmer le mot de passe",
"email_placeholder": "mon@email.com",
"invalid_credentials": "Mot de passe incorrect",
"invalid_email": "Ceci ne ressemble pas à une adresse e-mail",
"logged_in": "Connexion réussie",
"logged_out": "Déconnexion réussie",
"newsletter": "M'envoyer les nouvelles mises à jour par e-mail",
"password": "Mot de passe",
"password_mismatch": "Les mots de passe ne correspondent pas",
"password_required": "Saisis ton mot de passe pour continuer",
"password_set": "Ton mot de passe a bien été défini.",
"set_password": "Définis un mot de passe pour ce compte administrateur",
"subscribed": "Merci ! Tu vas recevoir un e-mail de confirmation très bientôt.",
"unsubscribed": "C'est fait. Fini le spam !",
"title": "Compte",
"status": {
"logged_in": "Tu es connecté.",
"logged_in_named": "Bonjour $0, tu es connecté.",
"logged_out": "Ajoute ton adresse e-mail et je t'enverrai ma nouvelle position dès que je la trouve :)",
"subscribed": "C'est tout bon. Je t'enverrai des nouvelles fraîches. Parole de scout."
}
},
"admin": {
"config": "Paramètres",
"create_success": "Créé",
"delete_success": "Supprimé",
"save_success": "Sauvegardé",
"title": "Administration",
"toolbox": "Boîte à outils",
"upload": "Téléverser"
},
"credits": {
"git": "Dépôt Git",
"license": "sous licence GPLv3",
"project": "Projet $0"
},
"email": {
"confirmation": {
"body_1": "C'est gentil de venir voir où j'en suis. Promis, je te tiendrai au courant de mon avancée.",
"body_2": "En général, j'envoie un message une fois par jour. Lorsque je passe par des endroits sympas, j'en envoie un supplémentaire (sommets, ce genre de choses). J'utilise une balise GPS pour envoyer le signal, je n'ai donc pas besoin de réseau téléphonique pour que cela fonctionne. Cependant, il peut m'arriver d'oublier d'appuyer sur le bouton. Donc pas de raison de t'inquiéter si tu ne reçois pas de messages pendant une journée ou deux.",
"body_3": "Si j'ai ajouté des photos sur le site récemment, tu devrais aussi les retrouver dans cet e-mail.",
"conclusion": "À bientôt sur les chemins !",
"preheader": "Merci de rester en contact !",
"signature": "--François",
"subject": "Confirmation",
"thanks_subject": "C'est tout bon !"
},
"failure": "Échec de lenvoi de le-mail de mise à jour",
"sent": "E-mail de mise à jour envoyé",
"unsubscribe": "PS : Trop d'e-mails ?",
"unsubscribe_button": "Se désinscrire",
"update": {
"latest_news": "Dernières nouvelles :",
"preheader": "Nouvelle position !",
"subject": "Nouvelle position reçue",
"title": "Message"
}
},
"error": {
"commit_db": "Erreur lors de la requête SQL",
"file_missing": "Le fichier $0 \"$1\" est manquant",
"impossible_value": "La valeur \"$0\" n'est pas possible pour le champ \"$1\"",
"no_auth": "Pas d'autorisation",
"not_found": "Action inconnue",
"unknown_field": "Champ \"$0\" inconnu",
"unknown_type": "Type \"$0\" inconnu"
},
"feed": {
"counter": "N°$0",
"id": "ID feed",
"last_update": "Dernière vérification de SPOT",
"name": "Description",
"new": "Nouveau feed",
"plural": "Feeds",
"ref_id": "ID Feed ref.",
"status": "Statut"
},
"map": {
"ign_france": "IGN (France)",
"ign_spain": "IGN (Espagne)",
"linz": "LINZ",
"natgeo": "National Geographic",
"otm": "Open Topo Map",
"outdoors": "Mapbox Topo",
"satellite": "Satellite",
"see_on_google": "Voir la position sur Google Maps",
"terrain": "Relief 3D",
"title": "Fonds de carte",
"usgs": "USGS"
},
"media": {
"click_watch": "Cliquer pour voir la vidéo",
"click_zoom": "Cliquer pour zoomer",
"comment_update": "Commentaire du média \"$0\" mis à jour",
"image": "Photo",
"image_taken_on": "Prise le $0",
"images": "Photos",
"nearby": "Photos prises dans le coin",
"no_id": "ID du média manquant",
"posted_on": "Ajouté le $0",
"video": "Vidéo",
"video_taken_on": "Filmé le $0"
},
"meta": {
"locale": "fr_CH",
"page_og_desc": "Garde le contact lorsque je suis sur les chemins."
},
"post": {
"copy_to_clipboard": "Copier le lien dans le presse-papiers",
"link_copied": "Lien copié !",
"message": "Message",
"name": "Nom",
"new_message": "Nouveau message",
"reply": "Répondre à ce message"
},
"project": {
"wip": "En cours",
"code_name": "Nom de code",
"end": "Arrivée",
"hikes": "Randonnées",
"id": "ID projet",
"mode": "Mode",
"modes": {
"blog": "Projet actif",
"histo": "Projet archivé",
"previz": "Projet en cours de préparation"
},
"new": "Nouveau projet",
"overview": "Vue d'ensemble",
"plural": "Projets",
"single": "Projet",
"start": "Départ",
"update_messages": "Mettre à jour les messages du projet"
},
"spot": {
"id": "ID Spot",
"model": "Modèle",
"name": "Spot",
"no_new_msg": "Aucun nouveau message",
"plural": "Spots",
"ref_id": "ID Spot ref."
},
"stats": {
"duration": "Durée",
"distance": "Distance",
"elevation": "Dénivelé",
"elevation_gain": "Dénivelé positif",
"elevation_loss": "Dénivelé négatif",
"from": "Départ",
"legend": "Légende",
"segment_length": "Taille du segment",
"to": "Arrivée",
"type": "Type de rando"
},
"time": {
"city": "Heure à $0",
"date_time": "$0 à $1",
"local": "$0 heure locale",
"user": "$0 dans ton fuseau horaire",
"zone": "Fuseau horaire"
},
"track": {
"download": "Télécharger la trace GPX",
"hitchhiking": "Auto-stop",
"main": "Itinéraire",
"off-track": "Variante"
},
"unit": {
"day": "jour",
"day_short": "J",
"days": "jours",
"hour": "h"
},
"upload": {
"error": "Erreur lors du téléversement",
"media": {
"exists": "L'image $0 existe déjà",
"title": "Téléverser des photos et vidéos"
},
"mode_archived": "Le projet \"$0\" a été archivé. Aucun téléversement possible.",
"position": {
"determining": "Détermination de la position…",
"error": "Impossible de déterminer la position",
"new": "Nouvelle position",
"permission_denied": "Lautorisation daccéder à la position a été refusée",
"sending": "Envoi de la position…",
"success": "Position téléversée avec succès",
"timeout": "La demande de position a expiré",
"title": "Position supplémentaire",
"unavailable": "La position nest pas disponible",
"unsupported": "Ce navigateur ne prend pas en charge la géolocalisation"
},
"success": "$0 a été téléversé"
},
"user": {
"subscribed": "Utilisateurs abonnés",
"clearance": "Niveau d'autorisation",
"email": "E-mail",
"id": "ID utilisateur",
"language": "Langue",
"name": "Nom"
},
"weather": {
"clear-day": "La couverture nuageuse est inférieure à 20 % pendant la journée",
"clear-night": "La couverture nuageuse est inférieure à 20 % pendant la nuit",
"cloudy": "La couverture nuageuse est supérieure à 90 %",
"fog": "La visibilité est faible (moins de 1km)",
"hail": "Averses de grêle",
"partly-cloudy-day": "La couverture nuageuse est supérieure à 20 % pendant la journée",
"partly-cloudy-night": "La couverture nuageuse est supérieure à 20 % pendant la nuit",
"rain": "La quantité de pluie est supérieure à zéro",
"rain-snow": "Averses de neige et de pluie",
"rain-snow-showers-day": "Pluie/neige possible tout au long de la journée",
"rain-snow-showers-night": "Pluie/neige possible tout au long de la nuit",
"showers-day": "Averses de pluie pendant la journée",
"showers-night": "Averses de pluie pendant la nuit",
"sleet": "Grésil",
"snow": "La quantité de neige est supérieure à zéro",
"snow-showers-day": "Périodes de neige pendant la journée",
"snow-showers-night": "Périodes de neige pendant la nuit",
"thunder": "Orages",
"thunder-rain": "Orages tout au long de la journée ou de la nuit",
"thunder-showers-day": "Orages possibles tout au long de la journée",
"thunder-showers-night": "Orages possibles tout au long de la nuit",
"wind": "La vitesse du vent est élevée (plus de 30 km/h)"
}
}
-33
View File
@@ -1,33 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<title>[#]lang:email.confirmation.subject[#]</title>
</head>
<body>
<span style="color: transparent; display: none !important; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">[#]lang:email.confirmation.preheader[#]</span>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="width:100%;max-width:600px;">
<tr>
<td width="20%"><img src="[#]local_server[#]assets/images/icons/favicon-96x96.png" width="90%" border="0" alt="logo" /></td>
<td><h1>[#]lang:email.confirmation.thanks_subject[#]</h1></td>
</tr>
<tr>
<td colspan="2">
<p align="justify">[#]lang:email.confirmation.body_1[#]</p>
<p align="justify">[#]lang:email.confirmation.body_2[#]</p>
<p align="justify">[#]lang:email.confirmation.body_3[#]</p>
</td>
</tr>
<tr>
<td colspan="2">
<p>[#]lang:email.confirmation.conclusion[#]<br />[#]lang:email.confirmation.signature[#]</p>
</td>
</tr>
<tr>
<td colspan="2">
<p>[#]lang:email.unsubscribe[#] <a href="[#]unsubscribe_link[#]" target="_blank" rel="noopener">[#]lang:email.unsubscribe_button[#]</a></p>
</td>
</tr>
</table>
</body>
</html>
-33
View File
@@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="[#]language[#]">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="[#]lang:meta.page_og_desc[#]">
<meta property="og:title" content="[#]title[#]" />
<meta property="og:description" content="[#]lang:meta.page_og_desc[#]" />
<meta property="og:type" content="website" />
<meta property="og:url" content="[#]server[#]" />
<meta property="og:image" content="assets/images/icons/ogp.svg?v=20260528" />
<meta property="og:locale" content="[#]lang:meta.locale[#]" />
<link rel="icon" type="image/png" href="assets/images/icons/favicon-96x96.png?v=20260528" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="assets/images/icons/favicon.svg?v=20260528" />
<link rel="shortcut icon" href="assets/images/icons/favicon.ico?v=20260528" />
<link rel="apple-touch-icon" sizes="180x180" href="assets/images/icons/apple-touch-icon.png?v=20260528" />
<meta name="apple-mobile-web-app-title" content="[#]title[#]" />
<link rel="manifest" href="assets/images/icons/site.webmanifest?v=20260528" />
<meta name="theme-color" content="#081B19">
<script id="app-config" type="application/json">[#]app_config[#]</script>
<title>[#]title[#]</title>
<!-- [PART] css [START] -->
<link rel="stylesheet" href="[#]filename[#]" />
<!-- [PART] css [END] -->
<!-- [PART] module [START] -->
<link rel="modulepreload" href="[#]filename[#]" />
<!-- [PART] module [END] -->
<script type="module" src="[#]app_entry[#]"></script>
</head>
<body>
<div id="container"></div>
</body>
</html>
+2
View File
File diff suppressed because one or more lines are too long
+785
View File
@@ -0,0 +1,785 @@
// ==UserScript==
// @name GaiaGps Uploader
// @version 3.0
// @grant none
// @match https://www.gaiagps.com/map/*
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.5.0/jquery.min.js
// @namespace https://greasyfork.org/users/583371
// ==/UserScript==
//Ctrl+Alt+Shift+I to open network tab on addon
var gpxParser = function () {
this.xmlSource = "";
this.metadata = {};
this.waypoints = [];
this.tracks = [];
this.routes = [];
};
gpxParser.prototype.parse = function (gpxstring) {
var keepThis = this;
var domParser = new window.DOMParser();
this.xmlSource = domParser.parseFromString(gpxstring, 'text/xml');
metadata = this.xmlSource.querySelector('metadata');
if(metadata != null){
this.metadata.name = this.getElementValue(metadata, "name");
this.metadata.desc = this.getElementValue(metadata, "desc");
this.metadata.time = this.getElementValue(metadata, "time");
let author = {};
let authorElem = metadata.querySelector('author');
if(authorElem != null){
author.name = this.getElementValue(authorElem, "name");
author.email = {};
let emailElem = authorElem.querySelector('email');
if(emailElem != null){
author.email.id = emailElem.getAttribute("id");
author.email.domain = emailElem.getAttribute("domain");
}
let link = {};
let linkElem = authorElem.querySelector('link');
if(linkElem != null){
link.href = linkElem.getAttribute('href');
link.text = this.getElementValue(linkElem, "text");
link.type = this.getElementValue(linkElem, "type");
}
author.link = link;
}
this.metadata.author = author;
let link = {};
let linkElem = this.queryDirectSelector(metadata, 'link');
if(linkElem != null){
link.href = linkElem.getAttribute('href');
link.text = this.getElementValue(linkElem, "text");
link.type = this.getElementValue(linkElem, "type");
this.metadata.link = link;
}
}
var wpts = [].slice.call(this.xmlSource.querySelectorAll('wpt'));
for (let idx in wpts){
var wpt = wpts[idx];
let pt = {};
pt.name = keepThis.getElementValue(wpt, "name")
pt.lat = parseFloat(wpt.getAttribute("lat"));
pt.lon = parseFloat(wpt.getAttribute("lon"));
pt.ele = parseFloat(keepThis.getElementValue(wpt, "ele")) || null;
pt.cmt = keepThis.getElementValue(wpt, "cmt");
pt.desc = keepThis.getElementValue(wpt, "desc");
pt.sym = keepThis.getElementValue(wpt, "sym");
keepThis.waypoints.push(pt);
}
var rtes = [].slice.call(this.xmlSource.querySelectorAll('rte'));
for (let idx in rtes){
var rte = rtes[idx];
let route = {};
route.name = keepThis.getElementValue(rte, "name");
route.cmt = keepThis.getElementValue(rte, "cmt");
route.desc = keepThis.getElementValue(rte, "desc");
route.src = keepThis.getElementValue(rte, "src");
route.number = keepThis.getElementValue(rte, "number");
let type = keepThis.queryDirectSelector(rte, "type");
route.type = type != null ? type.innerHTML : null;
let link = {};
let linkElem = rte.querySelector('link');
if(linkElem != null){
link.href = linkElem.getAttribute('href');
link.text = keepThis.getElementValue(linkElem, "text");
link.type = keepThis.getElementValue(linkElem, "type");
}
route.link = link;
let routepoints = [];
var rtepts = [].slice.call(rte.querySelectorAll('rtept'));
for (let idxIn in rtepts){
var rtept = rtepts[idxIn];
let pt = {};
pt.lat = parseFloat(rtept.getAttribute("lat"));
pt.lon = parseFloat(rtept.getAttribute("lon"));
pt.ele = parseFloat(keepThis.getElementValue(rtept, "ele"));
routepoints.push(pt);
}
route.points = routepoints;
keepThis.routes.push(route);
}
var trks = [].slice.call(this.xmlSource.querySelectorAll('trk'));
for (let idx in trks){
var trk = trks[idx];
let track = {};
track.name = keepThis.getElementValue(trk, "name");
track.cmt = keepThis.getElementValue(trk, "cmt");
track.desc = keepThis.getElementValue(trk, "desc");
track.src = keepThis.getElementValue(trk, "src");
track.number = keepThis.getElementValue(trk, "number");
track.color = keepThis.getElementValue(trk, "DisplayColor");
let type = keepThis.queryDirectSelector(trk, "type");
let link = {};
let linkElem = trk.querySelector('link');
if(linkElem != null){
link.href = linkElem.getAttribute('href');
link.text = keepThis.getElementValue(linkElem, "text");
link.type = keepThis.getElementValue(linkElem, "type");
}
track.link = link;
let trackpoints = [];
var trkpts = [].slice.call(trk.querySelectorAll('trkpt'));
for (let idxIn in trkpts){
var trkpt = trkpts[idxIn];
let pt = {};
pt.lat = parseFloat(trkpt.getAttribute("lat"));
pt.lon = parseFloat(trkpt.getAttribute("lon"));
pt.ele = parseFloat(keepThis.getElementValue(trkpt, "ele")) || null;
trackpoints.push(pt);
}
track.points = trackpoints;
keepThis.tracks.push(track);
}
};
gpxParser.prototype.getElementValue = function(parent, needle){
let elem = parent.querySelector(needle);
if(elem != null){
return $('<div>').html(elem.innerHTML != undefined ? elem.innerHTML : elem.childNodes[0].data).text();
}
return elem;
}
gpxParser.prototype.queryDirectSelector = function(parent, needle) {
let elements = parent.querySelectorAll(needle);
let finalElem = elements[0];
if(elements.length > 1) {
let directChilds = parent.childNodes;
for(idx in directChilds) {
elem = directChilds[idx];
if(elem.tagName === needle) {
finalElem = elem;
}
}
}
return finalElem;
}
/*
gpxParser.prototype.toGeoJSON = function () {
var GeoJSON = {
"type": "FeatureCollection",
"features": [],
"properties": {
"name": this.metadata.name,
"desc": this.metadata.desc,
"time": this.metadata.time,
"author": this.metadata.author,
"link": this.metadata.link,
},
};
for(idx in this.tracks) {
let track = this.tracks[idx];
var feature = {
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": []
},
"properties": {
}
};
feature.properties.name = track.name;
feature.properties.cmt = track.cmt;
feature.properties.desc = track.desc;
feature.properties.src = track.src;
feature.properties.number = track.number;
feature.properties.link = track.link;
feature.properties.type = track.type;
for(idx in track.points) {
let pt = track.points[idx];
var geoPt = [];
geoPt.push(pt.lon);
geoPt.push(pt.lat);
geoPt.push(pt.ele);
feature.geometry.coordinates.push(geoPt);
}
GeoJSON.features.push(feature);
}
for(idx in this.routes) {
let track = this.routes[idx];
var feature = {
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": []
},
"properties": {
}
};
feature.properties.name = track.name;
feature.properties.cmt = track.cmt;
feature.properties.desc = track.desc;
feature.properties.src = track.src;
feature.properties.number = track.number;
feature.properties.link = track.link;
feature.properties.type = track.type;
for(idx in track.points) {
let pt = track.points[idx];
var geoPt = [];
geoPt.push(pt.lon);
geoPt.push(pt.lat);
geoPt.push(pt.ele);
feature.geometry.coordinates.push(geoPt);
}
GeoJSON.features.push(feature);
}
for(idx in this.waypoints) {
let pt = this.waypoints[idx];
var feature = {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": []
},
"properties": {
}
};
feature.properties.name = pt.name;
feature.properties.cmt = pt.cmt;
feature.properties.desc = pt.desc;
feature.geometry.coordinates = [pt.lon, pt.lat, pt.ele];
GeoJSON.features.push(feature);
}
return GeoJSON;
}
*/
gpxParser.prototype.getGPX = function(sName) {
var sTrack = '';
for(var i=0 ; i < this.tracks.length ; i++) {
var sTrkSeg = '';
for(var j=0 ; j < this.tracks[i].points.length ; j++) {
sTrkSeg += '<trkpt lat="'+this.tracks[i].points[j].lat+'" lon="'+this.tracks[i].points[j].lon+'">'+
'<ele>'+(this.tracks[i].points[j].ele || '')+'</ele>'+
'</trkpt>';
}
sTrack += '<trk>'+
'<name>'+(this.tracks[i].name || '')+'</name>'+
'<desc>'+(this.tracks[i].desc || '')+'</desc>'+
'<src>'+(this.tracks[i].src || '')+'</src>'+
'<trkseg>'+sTrkSeg+'</trkseg>'+
'</trk>';
}
var sGPX = '<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>'+
'<gpx version="1.1" creator="Franzz" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">'+
'<metadata><name>'+sName+'</name></metadata>'+
sTrack+
'</gpx>';
return sGPX;
};
class Gaia {
static get URL() { return 'https://www.gaiagps.com'; }
static get API() { return Gaia.URL+'/api/objects'; }
constructor($InputFile, $InputButton) {
this.asFiles = [];
this.aoWaypoints = [];
this.aoTracks = [];
this.sFolderId = '';
this.asFolder = {};
this.sFolderName = '';
this.$Feedback = $('<div>', {'style':'position:fixed;width:100%;height:25%;bottom:0;background:white;z-index:10000;border-top:1px solid #CCC;box-sizing:border-box;font-size:1.2em;overflow:auto;padding:0 1em;'});
this.$InputName = $('<input>', {'type':'text', 'name':'folder_name', 'placeholder':'Folder Name', 'style': 'border-width:2px; border-color:rgba(0, 0, 0, 0.1); border-radius:4px; font-family:Inter,Helvetica Neue,Helvetica,Arial; font-size:15px; padding:8px 12px; margin-bottom:12px;'});
this.$InputBox = $InputButton.parent();
//Reboot object to remove events
this.$InputFile = $InputFile.clone().insertAfter($InputFile);
$InputFile.remove();
this.$InputButton = $InputButton.clone().insertAfter($InputButton);
$InputButton.remove();
this.setLayout();
}
feedback(sType, sMsg) {
var sFormattedMsg = sType.charAt(0).toUpperCase()+sType.slice(1)+': '+sMsg+(sMsg.slice(-1)=='.'?'':'.');
console.log(sFormattedMsg);
let sColor = 'black';
switch(sType) {
case 'error': sColor = 'red'; break;
case 'warning': sColor = 'orange'; break;
case 'info': sColor = 'green'; break;
}
this.$Feedback.append($('<p>', {'style': 'color: '+sColor+';'}).text(sFormattedMsg));
this.$Feedback.scrollTop(this.$Feedback.prop("scrollHeight"));
}
//Modify Gaia DOM Interface
setLayout() {
//Add Feedback box
this.$Feedback.appendTo($('body'));
//Add Event on button
this.$InputButton.click(() => {this.$InputFile.click();});
//Set event on file selection
this.$InputFile
.attr('multiple', 'multiple')
.attr('name', 'files[]')
.change(() => {this.readInputFiles();});
//Set default Name
this.$InputName.val('PCT').prependTo(this.$InputBox);
//Clear all upload notifications
this.resetNotif();
}
//Marking all upload notifications as read
resetNotif() {
this.feedback('info', 'Marking all upload notifications as read');
$.get(Gaia.URL+'/social/notifications/popup/').done((asNotifs) => {
for(var i in asNotifs) {
if(!asNotifs[i].isViewed && asNotifs[i].html.indexOf('has completed') != -1) {
$.post(Gaia.URL+'/social/notifications/'+asNotifs[i].id+'/markviewed/');
}
}
});
}
//Parse files from input (multiple)
readInputFiles() {
if (!window.File || !window.FileReader || !window.FileList || !window.Blob) {
this.feedback('error', 'File APIs are not fully supported in this browser');
return;
}
else this.feedback('info', 'Parsing input files');
//Get Folder Name (text input)
this.sFolderName = this.$InputName.val();
let aoReaders = [];
let iCount = 0;
this.asFiles = this.$InputFile.prop('files');
for(var i=0 ; i < this.asFiles.length ; i++) {
aoReaders[i] = new FileReader();
aoReaders[i].onload = ((oFileReader) => {
return (asResult) => {
iCount++;
this.feedback('info', 'Reading file "'+oFileReader.name+'" ('+iCount+'/'+this.asFiles.length+')');
this.parseFile(oFileReader.name, asResult.target.result);
if(iCount == this.asFiles.length) this.createFolder();
};
})(this.asFiles[i]);
aoReaders[i].readAsText(this.asFiles[i]);
}
}
//Parse GPX files to consolidate tracks & waypoints
parseFile(sFileName, oContent) {
this.feedback('info', 'Parsing file "'+sFileName+'"');
var oGPX = new gpxParser();
oGPX.parse(oContent);
//Waypoints
for(var w in oGPX.waypoints) {
oGPX.waypoints[w].filename = sFileName;
var sWaypointName = oGPX.waypoints[w].name;
if(sWaypointName.indexOf('Milestone - ') != -1 && sWaypointName.substr(-2) != '00') { //1 milestone every 100 miles
this.feedback('info', 'Ignoring milestone waypoint "'+sWaypointName+'"');
}
else this.aoWaypoints.push(oGPX.waypoints[w]);
}
//Tracks
for(var t in oGPX.tracks) {
oGPX.tracks[t].filename = sFileName;
this.aoTracks.push(oGPX.tracks[t]);
}
}
//Delete existing folder with same name & recreating it
createFolder() {
this.feedback('info', 'Looking for existing folder "'+this.sFolderName+'"...');
$.get(Gaia.API+'/folder/?search='+this.sFolderName).done((asFolders) => {
if(asFolders != '' && !$.isEmptyObject(asFolders)) {
for(var f in asFolders) {
this.feedback('info', 'Deleting "'+asFolders[f].title+'" folder');
$.ajax({
url: Gaia.API+'/folder/'+asFolders[f].id+'/',
type: 'DELETE'
});
}
}
else this.feedback('info', 'No folder named "'+this.sFolderName+'" found');
this.feedback('info', 'Creating folder "'+this.sFolderName+'"');
$.post({
url: Gaia.API+'/folder/',
contentType: 'application/json',
data: JSON.stringify({imported: true, title: this.sFolderName})
}).done((asFolder) => {
this.feedback('info', 'Folder "'+asFolder.properties.name+'" created');
this.asFolder = asFolder;
this.sFolderId = asFolder.id;
this.sFolderName = asFolder.properties.name;
this.uploadTrack();
});
});
}
//Build & Upload Track File
uploadTrack() {
this.feedback('info', 'Uploading tracks...');
//Convert to geojson
let iPostedTracks = 0;
$.each(this.aoTracks, (iIndex, aoTrack) => {
//Set color
let sColor = '#4ABD32';
switch(aoTrack.color) {
case 'DarkBlue': sColor = '#2D3FC7'; break;
case 'Magenta': sColor = '#B60DC3'; break;
}
//Add track points
let aoCoords = [];
for(var p in aoTrack.points) {
let pt = aoTrack.points[p];
aoCoords.push([pt.lon, pt.lat, pt.ele, 0]);
}
//Upload
let sPostedData = JSON.stringify({
type: 'feature',
properties: {
color: sColor,
title: aoTrack.name,
time_created: '2020-12-27T11:34:03.537Z',
routing_mode: null,
notes: aoTrack.desc,
//distance: 168405.62350073704,
isValid: true,
isLatestImport: true,
filename: aoTrack.filename,
localId: 'track'+(iIndex + 1),
writable: true,
archived: false,
isPublicTrack: false,
isLocallyCreated: true,
type: 'track',
parent_folder_id: this.sFolderId,
hexcolor: sColor
},
geometry: {
type: 'MultiLineString',
coordinates: [aoCoords]
}
});
var self = this;
this.feedback('info', 'Uploading track "'+aoTrack.name+'"');
$.post({
url: Gaia.API+'/track/',
contentType: 'application/json',
data: sPostedData,
postedData: sPostedData,
trackName: aoTrack.name
}).done(function(asTrack) {
self.aoTracks[iIndex] = asTrack;
$.ajax({
url: Gaia.API+'/track/'+asTrack.features[0].id+'/',
type: 'PUT',
contentType: 'application/json',
data: this.postedData,
trackName: this.trackName
}).done(function() {
iPostedTracks++;
self.feedback('info', 'Track "'+this.trackName+'" uploaded');
if(iPostedTracks == self.aoTracks.length) {
self.feedback('info', 'All tracks uploaded');
self.uploadWayPoints();
}
}).fail(function() {
self.feedback('error', 'Track "'+this.trackName+'" failed to upload');
});
});
});
/* Legacy lethod: through form submit
//Build track file
this.feedback('info', 'Building consolidated track');
var oGPX = new gpxParser();
oGPX.tracks = this.aoTracks;
var sTrack = oGPX.getGPX(this.sFolderName);
//Send consolidated Tracks as one file
var oForm = new FormData();
oForm.append('name', this.sFolderName);
oForm.append('files', new Blob([sTrack]), 'Consolidated.gpx');
this.feedback('info', 'Uploading consolidated track');
$.ajax({
url: Gaia.URL+'/upload/',
type: 'POST',
data: oForm,
enctype: 'multipart/form-data',
processData: false,
contentType: false,
cache: false
}).done(() => {this.checkNotif();});
*/
}
/*
//Wait for file to be processed by Gaia
checkNotif() {
this.feedback('info', 'Waiting for Gaia to process consolidated track');
$.get(Gaia.URL+'/social/notifications/popup/').done((asNotifs) => {
for(var i in asNotifs) {
if(!asNotifs[i].isViewed && asNotifs[i].html.indexOf('has completed') != -1) {
this.feedback('info', 'Notification '+asNotifs[i].id+' found. Marking as read');
var $Notif = $('<span>').html(asNotifs[i].html);
this.sFolderId = $Notif.find('a').attr('href').split('/')[3];
$.post(Gaia.URL+'/social/notifications/'+asNotifs[i].id+'/markviewed/');
}
}
if(this.sFolderId != '') {
this.setTracksColor();
this.uploadWayPoints();
}
else setTimeout((() => {this.checkNotif();}), 1000);
});
}
//Convert QMapshack Track Colors into Gaia Colors
setTracksColor() {
this.feedback('info', 'Track Color - Matching Gaia track IDs with GPX tracks');
var iCount = 0;
$.get(Gaia.API+'/folder/'+this.sFolderId+'/').done((asFolder) => {
var self = this;
for(var iGaiaIndex in asFolder.properties.tracks) {
for(var iGpxIndex in this.aoTracks) {
if(asFolder.properties.tracks[iGaiaIndex].title == this.aoTracks[iGpxIndex].name) {
this.aoTracks[iGpxIndex].id = asFolder.properties.tracks[iGaiaIndex].id;
this.feedback('info', 'Track Color - Track "'+this.aoTracks[iGpxIndex].name+'" found a match ('+(++iCount)+'/'+this.aoTracks.length+')');
$.ajax({
url: Gaia.API+'/track/'+this.aoTracks[iGpxIndex].id+'/',
color: this.aoTracks[iGpxIndex].color
}).done(function(asGaiaTrackDisplay) {
var asGaiaTrack = asGaiaTrackDisplay.features[0];
delete asGaiaTrack.id;
delete asGaiaTrack.style;
//Set color
var sColor = '#ff0000';
switch(this.color) {
case 'DarkBlue': sColor = '#2D3FC7'; break;
case 'Magenta': sColor = '#B60DC3'; break;
}
asGaiaTrack.properties.color = sColor;
asGaiaTrack.properties.hexcolor = sColor;
self.feedback('info', 'Track Color - Setting track color "'+sColor+'" ('+this.color+') to "'+asGaiaTrack.properties.title+'"');
$.ajax({
url: Gaia.API+'/track/'+asGaiaTrack.properties.id+'/',
type: 'PUT',
contentType: 'application/json',
data: JSON.stringify(asGaiaTrack)
});
});
}
}
}
});
}
*/
uploadWayPoints(iIndex, bSecondTry) {
iIndex = iIndex || 0;
bSecondTry = bSecondTry || false;
//Upload waypoints
if(iIndex < this.aoWaypoints.length) {
var sWaypointName = this.aoWaypoints[iIndex].name;
this.feedback('info', 'Uploading waypoint '+(iIndex + 1)+'/'+this.aoWaypoints.length+' ('+sWaypointName+')');
var asPost = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [
this.aoWaypoints[iIndex].lon,
this.aoWaypoints[iIndex].lat,
this.aoWaypoints[iIndex].ele
]
},
properties: {
title: sWaypointName,
time_created: "2020-06-07T14:01:03.944Z",
icon: Gaia.getIconName(this.aoWaypoints[iIndex].sym),
isValid: true,
isLatestImport: true,
filename: this.aoWaypoints[iIndex].filename,
writable: true,
localId: iIndex+'',
notes: this.aoWaypoints[iIndex].desc,
archived: false,
isLocallyCreated: true,
type: 'waypoint',
parent_folder_id: this.sFolderId
}
};
let sData = JSON.stringify(asPost);
var self = this;
$.post({
url: Gaia.API+'/waypoint/',
contentType: 'application/json',
data: sData,
postedData: sData
}).done(function(asWaypoint) {
//Update local waypoint with all server info (including ID)
self.aoWaypoints[iIndex] = asWaypoint;
$.ajax({
url: Gaia.API+'/waypoint/'+asWaypoint.properties.id+'/',
type: 'PUT',
contentType: 'application/json',
data: this.postedData
}).always(() => {
self.uploadWayPoints(++iIndex);
});
}).fail(function(){
self.feedback('error', 'Failed to upload waypoint #'+(iIndex + 1)+' ('+sWaypointName+'). Trying again...');
self.uploadWayPoints(iIndex, true);
});
}
//Done uploading, assigning waypoints & tracks to folder
else this.assignElementsToFolder();
}
assignElementsToFolder() {
this.feedback('info', 'Assigning all elements to folder "'+this.asFolder.properties.name+'"');
let asFolder = {
cover_photo_id: this.asFolder.properties.cover_photo_id,
id: this.asFolder.id,
name: this.asFolder.properties.name,
notes: this.asFolder.properties.notes,
time_created: this.asFolder.properties.time_created,
updated_date: this.asFolder.properties.updated_date
}
//Assign waypoints to folder
asFolder.waypoints = [];
for(var w in this.aoWaypoints) asFolder.waypoints.push(this.aoWaypoints[w].properties.id);
//Assign tracks to folder
asFolder.tracks = [];
for(var t in this.aoTracks) asFolder.tracks.push(this.aoTracks[t].features[0].id);
$.ajax({
url: Gaia.API+'/folder/'+asFolder.id+'/',
type: 'PUT',
contentType: 'application/json',
data: JSON.stringify(asFolder)
}).done(() => {
this.feedback('info', 'Done');
}).fail(() => {
this.feedback('error', 'WFailed to assign waypoints & tracks to folder ID "'+asFolder.id+'"');
});
}
static getIconName(sGarminName) {
var asMapping = {
'Trail Head': 'trailhead',
'Water Source': 'water-24.png',
'Truck': 'car-24.png',
'Post Office': 'resupply',
'Flag, Blue': 'blue-pin-down.png',
'Car': 'car-24.png',
'Flag, Red': 'red-pin-down.png',
'Campground': 'campsite-24.png',
'Flag, Green': 'green-pin.png',
'Powerline': 'petroglyph',
'Shopping Center': 'cafe-24.png',
'Lodging': 'lodging-24.png',
'Water Hydrant': 'red-pin-down.png',
'Drinking Water': 'potable-water',
'Toll Booth': 'ranger-station',
'Summit': 'peak',
'Park': 'park-24.png',
'Forest': 'forest',
'Cemetery': 'cemetery-24.png',
'Bridge': 'dam-24.png',
'Restaurant': 'restaurant-24.png',
'Picnic Area': 'picnic',
'Residence': 'city-24.png',
'City (Capitol)': 'city-24.png',
'Ski Resort': 'skiing-24.png',
'Restroom': 'toilets-24.png',
'Ground Transportation': 'car-24.png',
'Church': 'ghost-town'
};
return (sGarminName in asMapping)?asMapping[sGarminName]:'red-pin-down.png';
}
}
console.log('Loading GaiaGps Uploader...');
//To be adjusted regularly
$FileInput = $('input[type=file]')
$FileButton = $('a[href="https://help.gaiagps.com/hc/en-us/articles/360052763513"]').parent().find('button');
let oGaia = new Gaia($FileInput, $FileButton);
+2
View File
File diff suppressed because one or more lines are too long
+20
View File
File diff suppressed because one or more lines are too long
+25
View File
File diff suppressed because one or more lines are too long
+772
View File
@@ -0,0 +1,772 @@
/*!
* Lightbox v2.11.1
* by Lokesh Dhakar
*
* More info:
* http://lokeshdhakar.com/projects/lightbox2/
*
* Copyright Lokesh Dhakar
* Released under the MIT license
* https://github.com/lokesh/lightbox2/blob/master/LICENSE
*
* @preserve
*/
// Uses Node, AMD or browser globals to create a module.
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory(require('jquery'));
} else {
// Browser globals (root is window)
root.lightbox = factory(root.jQuery);
}
}(this, function ($) {
function Lightbox(options) {
this.album = [];
this.currentImageIndex = void 0;
this.init();
// options
this.options = $.extend({}, this.constructor.defaults);
this.option(options);
}
// Descriptions of all options available on the demo site:
// http://lokeshdhakar.com/projects/lightbox2/index.html#options
Lightbox.defaults = {
albumLabel: 'Image %1 of %2',
alwaysShowNavOnTouchDevices: false,
fadeDuration: 600,
fitImagesInViewport: true,
imageFadeDuration: 600,
// maxWidth: 800,
// maxHeight: 600,
positionFromTop: 50,
resizeDuration: 700,
showImageNumberLabel: true,
wrapAround: false,
disableScrolling: false,
/*
Sanitize Title
If the caption data is trusted, for example you are hardcoding it in, then leave this to false.
This will free you to add html tags, such as links, in the caption.
If the caption data is user submitted or from some other untrusted source, then set this to true
to prevent xss and other injection attacks.
*/
sanitizeTitle: false
//ADDED-START
, hasVideo: true
//ADDED-END
};
Lightbox.prototype.option = function(options) {
$.extend(this.options, options);
};
Lightbox.prototype.imageCountLabel = function(currentImageNum, totalImages) {
return this.options.albumLabel.replace(/%1/g, currentImageNum).replace(/%2/g, totalImages);
};
Lightbox.prototype.init = function() {
var self = this;
// Both enable and build methods require the body tag to be in the DOM.
$(document).ready(function() {
self.enable();
self.build();
});
};
// Loop through anchors and areamaps looking for either data-lightbox attributes or rel attributes
// that contain 'lightbox'. When these are clicked, start lightbox.
Lightbox.prototype.enable = function() {
var self = this;
$('body').on('click', 'a[rel^=lightbox], area[rel^=lightbox], a[data-lightbox], area[data-lightbox]', function(event) {
self.start($(event.currentTarget));
return false;
});
};
// Build html for the lightbox and the overlay.
// Attach event handlers to the new DOM elements. click click click
Lightbox.prototype.build = function() {
if ($('#lightbox').length > 0) {
return;
}
var self = this;
// The two root notes generated, #lightboxOverlay and #lightbox are given
// tabindex attrs so they are focusable. We attach our keyboard event
// listeners to these two elements, and not the document. Clicking anywhere
// while Lightbox is opened will keep the focus on or inside one of these
// two elements.
//
// We do this so we can prevent propogation of the Esc keypress when
// Lightbox is open. This prevents it from intefering with other components
// on the page below.
//
// Github issue: https://github.com/lokesh/lightbox2/issues/663
$('<div id="lightboxOverlay" tabindex="-1" class="lightboxOverlay"></div><div id="lightbox" tabindex="-1" class="lightbox"><div class="lb-outerContainer"><div class="lb-container"><img class="lb-image" src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" alt=""/><div class="lb-nav"><a class="lb-prev" aria-label="Previous image" href="" ></a><a class="lb-next" aria-label="Next image" href="" ></a></div><div class="lb-loader"><a class="lb-cancel"></a></div></div></div><div class="lb-dataContainer"><div class="lb-data"><div class="lb-details"><span class="lb-caption"></span><span class="lb-number"></span></div><div class="lb-closeContainer"><a class="lb-close"></a></div></div></div></div>').appendTo($('body'));
// Cache jQuery objects
this.$lightbox = $('#lightbox');
this.$overlay = $('#lightboxOverlay');
this.$outerContainer = this.$lightbox.find('.lb-outerContainer');
this.$container = this.$lightbox.find('.lb-container');
this.$image = this.$lightbox.find('.lb-image');
this.$nav = this.$lightbox.find('.lb-nav');
//ADDED-START
if(self.options.hasVideo) {
this.$video = $('<video class="lb-video" controls autoplay></video>');
this.$image.after(this.$video);
this.videoBorderWidth = {
top: parseInt(this.$video.css('border-top-width'), 10),
right: parseInt(this.$video.css('border-right-width'), 10),
bottom: parseInt(this.$video.css('border-bottom-width'), 10),
left: parseInt(this.$video.css('border-left-width'), 10)
};
}
//ADDED-END
// Store css values for future lookup
this.containerPadding = {
top: parseInt(this.$container.css('padding-top'), 10),
right: parseInt(this.$container.css('padding-right'), 10),
bottom: parseInt(this.$container.css('padding-bottom'), 10),
left: parseInt(this.$container.css('padding-left'), 10)
};
this.imageBorderWidth = {
top: parseInt(this.$image.css('border-top-width'), 10),
right: parseInt(this.$image.css('border-right-width'), 10),
bottom: parseInt(this.$image.css('border-bottom-width'), 10),
left: parseInt(this.$image.css('border-left-width'), 10)
};
// Attach event handlers to the newly minted DOM elements
//ADDED-START
//this.$overlay.hide().on('click', function() {
this.$overlay.hide().add(this.$lightbox.find('.lb-dataContainer')).on('click', function() {
//ADDED-END
self.end();
return false;
});
this.$lightbox.hide().on('click', function(event) {
if ($(event.target).attr('id') === 'lightbox') {
self.end();
}
});
this.$outerContainer.on('click', function(event) {
if ($(event.target).attr('id') === 'lightbox') {
self.end();
}
return false;
});
this.$lightbox.find('.lb-prev').on('click', function() {
if (self.currentImageIndex === 0) {
self.changeImage(self.album.length - 1);
} else {
self.changeImage(self.currentImageIndex - 1);
}
return false;
});
this.$lightbox.find('.lb-next').on('click', function() {
if (self.currentImageIndex === self.album.length - 1) {
self.changeImage(0);
} else {
self.changeImage(self.currentImageIndex + 1);
}
return false;
});
/*
Show context menu for image on right-click
There is a div containing the navigation that spans the entire image and lives above of it. If
you right-click, you are right clicking this div and not the image. This prevents users from
saving the image or using other context menu actions with the image.
To fix this, when we detect the right mouse button is pressed down, but not yet clicked, we
set pointer-events to none on the nav div. This is so that the upcoming right-click event on
the next mouseup will bubble down to the image. Once the right-click/contextmenu event occurs
we set the pointer events back to auto for the nav div so it can capture hover and left-click
events as usual.
*/
this.$nav.on('mousedown', function(event) {
if (event.which === 3) {
self.$nav.css('pointer-events', 'none');
self.$lightbox.one('contextmenu', function() {
setTimeout(function() {
this.$nav.css('pointer-events', 'auto');
}.bind(self), 0);
});
}
});
this.$lightbox.find('.lb-loader, .lb-close').on('click', function() {
self.end();
return false;
});
};
// Show overlay and lightbox. If the image is part of a set, add siblings to album array.
Lightbox.prototype.start = function($link) {
var self = this;
var $window = $(window);
$window.on('resize', $.proxy(this.sizeOverlay, this));
this.sizeOverlay();
//ADDED-START
this.$nav.mousewheel((e) => {
var asImg = self.album[this.currentImageIndex];
if(!asImg.type != 'video') {
asTransform = this.$image.css('transform').replace(/[^0-9\-.,]/g, '').split(',');
var fOldZoom = parseFloat(asTransform[0] || 1);
var fOldTranslateX = parseFloat(asTransform[4] || 0);
var fOldTranslateY = parseFloat(asTransform[5] || 0);
var fOldZoom = parseFloat(asTransform[0] || 1);
var fNewZoom = Math.min(Math.max(fOldZoom + e.deltaY / 10, 1), Math.max(asImg.width/this.$image.width(), asImg.height/this.$image.height()));
var fTransX = fOldTranslateX + (fNewZoom - fOldZoom) * (this.$image.width()/2 - e.offsetX);
var fTransY = fOldTranslateY + (fNewZoom - fOldZoom) * (this.$image.height()/2 - e.offsetY);
var fTransMaxX = (fNewZoom - 1) * this.$image.width() / 2;
var fTransMaxY = (fNewZoom - 1) * this.$image.height() / 2;
fTransX = Math.max(Math.min(fTransX, fTransMaxX), fTransMaxX * -1);
fTransY = Math.max(Math.min(fTransY, fTransMaxY), fTransMaxY * -1);
this.$image.css('--scale', fNewZoom);
this.$image.css('--translate-x', fTransX+'px');
this.$image.css('--translate-y', fTransY+'px');
}
});
//ADDED-END
this.album = [];
var imageNumber = 0;
function addToAlbum($link) {
self.album.push({
alt: $link.attr('data-alt'),
link: $link.attr('href'),
title: $link.attr('data-title') || $link.attr('title')
//ADDED-START
, orientation: $link.attr('data-orientation')
, type: $link.attr('data-type')
, id: $link.attr('data-id')
//ADDED-END
});
}
// Support both data-lightbox attribute and rel attribute implementations
var dataLightboxValue = $link.attr('data-lightbox');
var $links;
if (dataLightboxValue) {
$links = $($link.prop('tagName') + '[data-lightbox="' + dataLightboxValue + '"]');
for (var i = 0; i < $links.length; i = ++i) {
addToAlbum($($links[i]));
if ($links[i] === $link[0]) {
imageNumber = i;
}
}
} else {
if ($link.attr('rel') === 'lightbox') {
// If image is not part of a set
addToAlbum($link);
} else {
// If image is part of a set
$links = $($link.prop('tagName') + '[rel="' + $link.attr('rel') + '"]');
for (var j = 0; j < $links.length; j = ++j) {
addToAlbum($($links[j]));
if ($links[j] === $link[0]) {
imageNumber = j;
}
}
}
}
// Position Lightbox
//ADDED-START
//var top = $window.scrollTop() + this.options.positionFromTop;
//var left = $window.scrollLeft();
this.$lightbox/*.css({
top: top + 'px',
left: left + 'px'
})*/.fadeIn(this.options.fadeDuration);
//ADDED-END
// Disable scrolling of the page while open
if (this.options.disableScrolling) {
$('body').addClass('lb-disable-scrolling');
}
this.changeImage(imageNumber);
};
// Hide most UI elements in preparation for the animated resizing of the lightbox.
Lightbox.prototype.changeImage = function(imageNumber) {
var self = this;
var filename = this.album[imageNumber].link;
var filetype = filename.split('.').slice(-1)[0];
var $image = this.$lightbox.find('.lb-image');
// Disable keyboard nav during transitions
this.disableKeyboardNav();
// Show loading state
this.$overlay.fadeIn(this.options.fadeDuration);
$('.lb-loader').fadeIn('slow');
//ADDED-START
//this.$lightbox.find('.lb-image, .lb-nav, .lb-prev, .lb-next, .lb-dataContainer, .lb-numbers, .lb-caption').hide();
this.$lightbox.find('.lb-image, .lb-video, .lb-nav, .lb-prev, .lb-next, .lb-number, .lb-caption, .lb-close').hide();
this.$image.css({'--scale': '1', '--translate-x': '0', '--translate-y': '0'});
self.$lightbox.find('.lb-dataContainer').css({width:'200px', height:'30px'});
//ADDED-END
this.$outerContainer.addClass('animating');
//ADDED-START
var getMaxSizes = function(iMediaWidth, iMediaHeight, sMediaType) {
var iWindowWidth = $(window).width();
var iWindowHeight = $(window).height();
var oBorder = (sMediaType=='image')?self.imageBorderWidth:self.videoBorderWidth;
var iMaxMediaWidth = iWindowWidth - self.containerPadding.left - self.containerPadding.right - oBorder.left - oBorder.right;
var iMaxMediaHeight = iWindowHeight - self.containerPadding.top - self.containerPadding.bottom - oBorder.top - oBorder.bottom - self.options.positionFromTop;
var iDataMaxWidth = self.$lightbox.find('.lb-dataContainer').width(), iDataMaxHeight = self.$lightbox.find('.lb-dataContainer').height();
var iImageRatio = iMediaWidth / iMediaHeight;
//Case horizontal
var iHeightH = Math.min(iMaxMediaHeight, iMediaHeight);
var iWidthH = Math.min(iHeightH * iImageRatio, iMaxMediaWidth - iDataMaxWidth);
var iSurfaceH = Math.min(iHeightH, iWidthH / iImageRatio) * iWidthH;
//Case vertical
var iWidthV = Math.min(iMaxMediaWidth, iMediaWidth);
var iHeightV = Math.min(iWidthV / iImageRatio, iMaxMediaHeight - iDataMaxHeight);
var iSurfaceV = Math.min(iWidthV, iHeightV * iImageRatio) * iHeightV;
var sDirection = (iSurfaceV > iSurfaceH)?'vertical':'horizontal';
if(sDirection == 'vertical') iMaxMediaHeight -= iDataMaxHeight;
else iMaxMediaWidth -= iDataMaxWidth;
return {maxWidth: iMaxMediaWidth, maxHeight: iMaxMediaHeight, direction: sDirection};
};
updateHash('media', self.album[imageNumber].id);
if(self.options.hasVideo) {
var $lbContainer = this.$lightbox.find('.lb-container');
var $hasVideoNav = $lbContainer.hasClass('lb-video-nav');
if(self.album[imageNumber].type == 'video') {
this.$video.on('loadedmetadata', function(){
var $This = $(this);
var oMaxSizes = getMaxSizes(this.videoWidth, this.videoHeight, 'video');
maxVideoWidth = oMaxSizes.maxWidth;
maxVideoHeight = oMaxSizes.maxHeight;
self.$lightbox.removeClass('vertical horizontal').addClass(oMaxSizes.direction);
//TODO merge with image
if(self.options.fitImagesInViewport) {
//Check if image size is larger than maxWidth|maxHeight in settings
if(self.options.maxWidth && self.options.maxWidth < maxVideoWidth) maxVideoWidth = self.options.maxWidth;
if(self.options.maxHeight && self.options.maxHeight < maxVideoHeight) maxVideoHeight = self.options.maxHeight;
}
else {
maxVideoWidth = self.options.maxWidth || this.videoWidth || maxVideoWidth;
maxVideoHeight = self.options.maxHeight || this.videoHeight || maxVideoHeight;
}
//Is the current image's width or height is greater than the maxImageWidth or maxImageHeight
//option than we need to size down while maintaining the aspect ratio.
if((this.videoWidth > maxVideoWidth) || (this.videoHeight > maxVideoHeight)) {
if ((this.videoWidth / maxVideoWidth) > (this.videoHeight / maxVideoHeight)) {
videoWidth = maxVideoWidth;
videoHeight = Math.round(this.videoHeight / (this.videoWidth / videoWidth));
} else {
videoHeight = maxVideoHeight;
videoWidth = Math.round(this.videoWidth / (this.videoHeight / videoHeight));
}
}
else {
videoWidth = this.videoWidth;
videoHeight = this.videoHeight;
}
$This.width(videoWidth);
$This.height(videoHeight);
self.sizeContainer($This.width(), $This.height(), 'video');
$This.off('loadedmetadata');
});
this.currentImageIndex = imageNumber;
this.$video.attr('src', self.album[imageNumber].link);
if(!$hasVideoNav) $lbContainer.addClass('lb-video-nav');
return;
}
else {
this.$video.attr('src', '');
if($hasVideoNav) $lbContainer.removeClass('lb-video-nav');
}
}
//ADDED-END
// When image to show is preloaded, we send the width and height to sizeContainer()
var preloader = new Image();
preloader.onload = function() {
var $preloader;
var imageHeight;
var imageWidth;
var maxImageHeight;
var maxImageWidth;
var windowHeight;
var windowWidth;
$image.attr({
'alt': self.album[imageNumber].alt,
'src': filename
});
$preloader = $(preloader);
//ADDED-START
if(Math.abs(self.album[imageNumber].orientation) == 90 && preloader.width > preloader.height) {
var sWidth = preloader.width;
preloader.width = preloader.height;
preloader.height = sWidth;
}
self.album[imageNumber].width = preloader.width;
self.album[imageNumber].height = preloader.height;
//ADDED-END
$image.width(preloader.width);
$image.height(preloader.height);
windowWidth = $(window).width();
windowHeight = $(window).height();
// Calculate the max image dimensions for the current viewport.
// Take into account the border around the image and an additional 10px gutter on each side.
//ADDED-START
//maxImageWidth = windowWidth - self.containerPadding.left - self.containerPadding.right - self.imageBorderWidth.left - self.imageBorderWidth.right - 20;
//maxImageHeight = windowHeight - self.containerPadding.top - self.containerPadding.bottom - self.imageBorderWidth.top - self.imageBorderWidth.bottom - self.options.positionFromTop - 70;
var oMaxSizes = getMaxSizes(preloader.width, preloader.height, 'image');
maxImageWidth = oMaxSizes.maxWidth;
maxImageHeight = oMaxSizes.maxHeight;
self.$lightbox.removeClass('vertical horizontal').addClass(oMaxSizes.direction);
//ADDED-END
/*
SVGs that don't have width and height attributes specified are reporting width and height
values of 0 in Firefox 47 and IE11 on Windows. To fix, we set the width and height to the max
dimensions for the viewport rather than 0 x 0.
https://github.com/lokesh/lightbox2/issues/552
*/
if (filetype === 'svg') {
if ((preloader.width === 0) || preloader.height === 0) {
$image.width(maxImageWidth);
$image.height(maxImageHeight);
}
}
// Fit image inside the viewport.
if (self.options.fitImagesInViewport) {
// Check if image size is larger then maxWidth|maxHeight in settings
if (self.options.maxWidth && self.options.maxWidth < maxImageWidth) {
maxImageWidth = self.options.maxWidth;
}
if (self.options.maxHeight && self.options.maxHeight < maxImageHeight) {
maxImageHeight = self.options.maxHeight;
}
} else {
maxImageWidth = self.options.maxWidth || preloader.width || maxImageWidth;
maxImageHeight = self.options.maxHeight || preloader.height || maxImageHeight;
}
// Is the current image's width or height is greater than the maxImageWidth or maxImageHeight
// option than we need to size down while maintaining the aspect ratio.
if ((preloader.width > maxImageWidth) || (preloader.height > maxImageHeight)) {
if ((preloader.width / maxImageWidth) > (preloader.height / maxImageHeight)) {
imageWidth = maxImageWidth;
imageHeight = parseInt(preloader.height / (preloader.width / imageWidth), 10);
$image.width(imageWidth);
$image.height(imageHeight);
} else {
imageHeight = maxImageHeight;
imageWidth = parseInt(preloader.width / (preloader.height / imageHeight), 10);
$image.width(imageWidth);
$image.height(imageHeight);
}
}
//ADDED-START
//self.sizeContainer($image.width(), $image.height());
self.sizeContainer($image.width(), $image.height(), 'image');
//ADDED-END
};
// Preload image before showing
preloader.src = this.album[imageNumber].link;
this.currentImageIndex = imageNumber;
};
// Stretch overlay to fit the viewport
Lightbox.prototype.sizeOverlay = function(e) {
var self = this;
/*
We use a setTimeout 0 to pause JS execution and let the rendering catch-up.
Why do this? If the `disableScrolling` option is set to true, a class is added to the body
tag that disables scrolling and hides the scrollbar. We want to make sure the scrollbar is
hidden before we measure the document width, as the presence of the scrollbar will affect the
number.
*/
//ADDED-START
/*
setTimeout(function() {
self.$overlay
.width($(document).width())
.height($(document).height());
}, 0);
*/
if(e) {
if(typeof oResizeTimer != 'undefined') clearTimeout(oResizeTimer);
oResizeTimer = setTimeout(function(){self.changeImage(self.currentImageIndex);}, 200);
}
//ADDED-END
};
// Animate the size of the lightbox to fit the image we are showing
// This method also shows the the image.
//ADDED-START
//Lightbox.prototype.sizeContainer = function(imageWidth, imageHeight) {
Lightbox.prototype.sizeContainer = function(imageWidth, imageHeight, media) {
media = media || 'image';
//ADDED-END
var self = this;
var oldWidth = this.$outerContainer.outerWidth();
var oldHeight = this.$outerContainer.outerHeight();
//ADDED-START
//var newWidth = imageWidth + this.containerPadding.left + this.containerPadding.right + this.imageBorderWidth.left + this.imageBorderWidth.right;
//var newHeight = imageHeight + this.containerPadding.top + this.containerPadding.bottom + this.imageBorderWidth.top + this.imageBorderWidth.bottom;
var mediaBorderWidth = (media=='image')?this.imageBorderWidth:this.videoBorderWidth;
var newWidth = imageWidth + this.containerPadding.left + this.containerPadding.right + mediaBorderWidth.left + mediaBorderWidth.right;
var newHeight = imageHeight + this.containerPadding.top + this.containerPadding.bottom + mediaBorderWidth.top + mediaBorderWidth.bottom;
//ADDED-END
function postResize() {
//ADDED-START
//self.$lightbox.find('.lb-dataContainer').width(newWidth);
if(self.$lightbox.hasClass('vertical')) self.$lightbox.find('.lb-dataContainer').width(newWidth);
else self.$lightbox.find('.lb-dataContainer').height(newHeight);
//ADDED-END
self.$lightbox.find('.lb-prevLink').height(newHeight);
self.$lightbox.find('.lb-nextLink').height(newHeight);
// Set focus on one of the two root nodes so keyboard events are captured.
self.$overlay.focus();
self.showImage();
}
if (oldWidth !== newWidth || oldHeight !== newHeight) {
this.$outerContainer.animate({
width: newWidth,
height: newHeight
}, this.options.resizeDuration, 'swing', function() {
postResize();
});
} else {
postResize();
}
};
// Display the image and its details and begin preload neighboring images.
Lightbox.prototype.showImage = function() {
this.$lightbox.find('.lb-loader').stop(true).hide();
//ADDED-START
//this.$lightbox.find('.lb-image').fadeIn(this.options.imageFadeDuration);
if(this.options.hasVideo && this.album[this.currentImageIndex].type == 'video') this.$lightbox.find('.lb-video').fadeIn(this.options.imageFadeDuration);
else this.$lightbox.find('.lb-image').fadeIn(this.options.imageFadeDuration);
//ADDED-END
this.updateNav();
this.updateDetails();
this.preloadNeighboringImages();
this.enableKeyboardNav();
};
// Display previous and next navigation if appropriate.
Lightbox.prototype.updateNav = function() {
// Check to see if the browser supports touch events. If so, we take the conservative approach
// and assume that mouse hover events are not supported and always show prev/next navigation
// arrows in image sets.
var alwaysShowNav = false;
try {
document.createEvent('TouchEvent');
alwaysShowNav = (this.options.alwaysShowNavOnTouchDevices) ? true : false;
} catch (e) {}
this.$lightbox.find('.lb-nav').show();
if (this.album.length > 1) {
if (this.options.wrapAround) {
if (alwaysShowNav) {
this.$lightbox.find('.lb-prev, .lb-next').css('opacity', '1');
}
this.$lightbox.find('.lb-prev, .lb-next').show();
} else {
if (this.currentImageIndex > 0) {
this.$lightbox.find('.lb-prev').show();
if (alwaysShowNav) {
this.$lightbox.find('.lb-prev').css('opacity', '1');
}
}
if (this.currentImageIndex < this.album.length - 1) {
this.$lightbox.find('.lb-next').show();
if (alwaysShowNav) {
this.$lightbox.find('.lb-next').css('opacity', '1');
}
}
}
}
};
// Display caption, image number, and closing button.
Lightbox.prototype.updateDetails = function() {
var self = this;
// Enable anchor clicks in the injected caption html.
// Thanks Nate Wright for the fix. @https://github.com/NateWr
if (typeof this.album[this.currentImageIndex].title !== 'undefined' &&
this.album[this.currentImageIndex].title !== '') {
var $caption = this.$lightbox.find('.lb-caption');
if (this.options.sanitizeTitle) {
$caption.text(this.album[this.currentImageIndex].title);
} else {
$caption.html(this.album[this.currentImageIndex].title);
}
//ADDED-START
//$caption.fadeIn('fast');
$caption.add(this.$lightbox.find('.lb-close')).fadeIn('fast');
//ADDED-END
}
if (this.album.length > 1 && this.options.showImageNumberLabel) {
var labelText = this.imageCountLabel(this.currentImageIndex + 1, this.album.length);
//ADDED-START
//this.$lightbox.find('.lb-number').text(labelText).fadeIn('fast');
this.$lightbox.find('.lb-number').empty().append(labelText).fadeIn('fast');
//ADDED-END
} else {
this.$lightbox.find('.lb-number').hide();
}
this.$outerContainer.removeClass('animating');
this.$lightbox.find('.lb-dataContainer').fadeIn(this.options.resizeDuration, function() {
return self.sizeOverlay();
});
};
// Preload previous and next images in set.
Lightbox.prototype.preloadNeighboringImages = function() {
if (this.album.length > this.currentImageIndex + 1) {
var preloadNext = new Image();
preloadNext.src = this.album[this.currentImageIndex + 1].link;
}
if (this.currentImageIndex > 0) {
var preloadPrev = new Image();
preloadPrev.src = this.album[this.currentImageIndex - 1].link;
}
};
Lightbox.prototype.enableKeyboardNav = function() {
this.$lightbox.on('keyup.keyboard', $.proxy(this.keyboardAction, this));
this.$overlay.on('keyup.keyboard', $.proxy(this.keyboardAction, this));
};
Lightbox.prototype.disableKeyboardNav = function() {
this.$lightbox.off('.keyboard');
this.$overlay.off('.keyboard');
};
Lightbox.prototype.keyboardAction = function(event) {
var KEYCODE_ESC = 27;
var KEYCODE_LEFTARROW = 37;
var KEYCODE_RIGHTARROW = 39;
var keycode = event.keyCode;
if (keycode === KEYCODE_ESC) {
// Prevent bubbling so as to not affect other components on the page.
event.stopPropagation();
this.end();
} else if (keycode === KEYCODE_LEFTARROW) {
if (this.currentImageIndex !== 0) {
this.changeImage(this.currentImageIndex - 1);
} else if (this.options.wrapAround && this.album.length > 1) {
this.changeImage(this.album.length - 1);
}
} else if (keycode === KEYCODE_RIGHTARROW) {
if (this.currentImageIndex !== this.album.length - 1) {
this.changeImage(this.currentImageIndex + 1);
} else if (this.options.wrapAround && this.album.length > 1) {
this.changeImage(0);
}
}
};
// Closing time. :-(
Lightbox.prototype.end = function() {
this.disableKeyboardNav();
//ADDED-START
if(this.options.hasVideo) {
var $lbContainer = this.$lightbox.find('.lb-container');
var $hasVideoNav = $lbContainer.hasClass('lb-video-nav');
this.$video.attr('src', '');
if($hasVideoNav) $lbContainer.removeClass('lb-video-nav');
}
flushHash();
//ADDED-END
$(window).off('resize', this.sizeOverlay);
this.$nav.off('mousewheel');
this.$lightbox.fadeOut(this.options.fadeDuration);
this.$overlay.fadeOut(this.options.fadeDuration);
if (this.options.disableScrolling) {
$('body').removeClass('lb-disable-scrolling');
}
};
return new Lightbox();
}));
Executable
+392
View File
@@ -0,0 +1,392 @@
function Spot(asGlobals)
{
self = this;
this.consts = asGlobals.consts;
this.consts.hash_sep = '-';
this.consts.title = 'Spotty';
this.consts.default_page = 'project';
this.consts.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || this.consts.default_timezone;
/* Initialization */
this.init = function()
{
//Variables & constants from php
self.vars('tmp', 'object');
self.vars('page', 'string');
self.updateVars(asGlobals.vars);
//page elem
self.elem = {};
self.elem.container = $('#container');
self.elem.main = $('#main');
self.resetTmpFunctions();
//On Key down
$('html').on('keydown', function(oEvent){self.onKeydown(oEvent);});
//on window resize
$(window).on('resize', function(){self.onResize();});
//Setup menu
//self.initMenu();
//Hash management
$(window)
.bind('hashchange', self.onHashChange)
.trigger('hashchange');
};
this.updateVars = function(asVars)
{
$.each(asVars, function(sKey, oValue){self.vars(sKey, oValue)});
};
/* Variable Management */
this.vars = function(oVarName, oValue)
{
var asVarName = (typeof oVarName == 'object')?oVarName:[oVarName];
//Set, name & type / default value (init)
if(typeof oValue !== 'undefined') setElem(self.vars, copyArray(asVarName), oValue);
//Get, only name parameter
return getElem(self.vars, asVarName);
};
this.tmp = function(sVarName, oValue)
{
var asVarName = (typeof sVarName == 'object')?sVarName:[sVarName];
asVarName.unshift('tmp');
return self.vars(asVarName, oValue);
};
/* Interface with server */
this.get = function(sAction, fOnSuccess, oVars, fOnError, fonProgress)
{
if(!oVars) oVars = {};
fOnError = fOnError || function(sError) {console.log(sError);};
fonProgress = fonProgress || function(sState){};
fonProgress('start');
oVars['a'] = sAction;
oVars['t'] = self.consts.timezone;
return $.ajax(
{
url: self.consts.process_page,
data: oVars,
dataType: 'json'
})
.done(function(oData)
{
fonProgress('done');
if(oData.desc.substr(0, self.consts.lang_prefix.length)==self.consts.lang_prefix) oData.desc = self.lang(oData.desc.substr(5));
if(oData.result==self.consts.error) fOnError(oData.desc);
else fOnSuccess(oData.data, oData.desc);
})
.fail(function(jqXHR, textStatus, errorThrown)
{
fonProgress('fail');
fOnError(textStatus+' '+errorThrown);
});
};
this.lang = function(sKey, asParams) {
var sParamType = $.type(asParams);
if(sParamType == 'undefined') asParams = [];
else if($.type(asParams) != 'array') asParams = [asParams];
var sLang = '';
if(sKey in self.consts.lang) {
sLang = self.consts.lang[sKey];
for(i in asParams) sLang = sLang.replace('$'+i, asParams[i]);
}
else console.log('missing translation: '+sKey);
return sLang;
};
/* Page Switch - Trigger & Event catching */
this.onHashChange = function()
{
var asHash = self.getHash();
if(asHash.hash !='' && asHash.page != '') self.switchPage(asHash); //page switching
else if(self.vars('page')=='') self.setHash(self.consts.default_page); //first page
};
this.getHash = function()
{
var sHash = self.hash();
var asHash = sHash.split(self.consts.hash_sep);
var sPage = asHash.shift() || '';
return {hash:sHash, page:sPage, items:asHash};
};
this.setHash = function(sPage, asItems, bReboot)
{
bReboot = bReboot || false;
sPage = sPage || '';
asItems = asItems || [];
if(typeof asItems == 'string') asItems = [asItems];
if(sPage != '')
{
var sItems = (asItems.length > 0)?self.consts.hash_sep+asItems.join(self.consts.hash_sep):'';
self.hash(sPage+sItems, bReboot);
}
};
this.hash = function(hash, bReboot)
{
bReboot = bReboot || false;
if(!hash) return window.location.hash.slice(1);
else window.location.hash = '#'+hash;
if(bReboot) location.reload();
};
/* Page Switch - DOM Replacement */
this.getActionLink = function(sAction, oVars)
{
if(!oVars) oVars = {};
sVars = '';
for(i in oVars)
{
sVars += '&'+i+'='+oVars[i];
}
return self.consts.process_page+'?a='+sAction+sVars;
};
this.resetTmpFunctions = function()
{
self.pageInit = function(asHash){console.log('no init for the page: '+asHash.page)};
self.onSamePageMove = function(asHash){return false};
self.onQuitPage = function(){return true};
self.onResize = function(){};
self.onFeedback = function(sType, sMsg){};
self.onKeydown = function(oEvent){};
};
this.switchPage = function(asHash)
{
var sPageName = asHash.page;
var bSamePage = self.vars('page')==sPageName;
if(self.onQuitPage(bSamePage) && !bSamePage || self.onSamePageMove(asHash))
{
//Delete tmp variables
self.vars('tmp', {});
//disable tmp functions
self.resetTmpFunctions();
//Officially a new page
var bFirstPage = self.vars('page')=='';
self.vars('page', sPageName);
//Update Page Title
this.setPageTitle(sPageName+' '+(asHash.items[0] || ''));
//Replacing DOM
var $Dom = $(self.consts.pages[sPageName]);
if(bFirstPage)
{
self.splash($Dom, asHash, bFirstPage); //first page
}
else
{
self.elem.main.stop().fadeTo('fast', 0, function(){self.splash($Dom, asHash, bFirstPage);}); //Switching page
}
}
};
this.setPageTitle = function(sTitle) {
document.title = self.consts.title+' - '+sTitle;
};
this.splash = function($Dom, asHash, bFirstPage)
{
//Switch main content
self.elem.main.empty().html($Dom);
//Page Bootstrap
self.pageInit(asHash, bFirstPage);
//Show main
var $FadeInElem = bFirstPage?self.elem.container:self.elem.main;
$FadeInElem.hide().fadeTo('slow', 1);
};
}
/* Common Functions */
function copyArray(asArray)
{
return asArray.slice(0); //trick to copy array
}
function getElem(aoAnchor, asPath)
{
return (typeof asPath == 'object' && asPath.length > 1)?getElem(aoAnchor[asPath.shift()], asPath):aoAnchor[(typeof asPath == 'object')?asPath.shift():asPath];
}
function setElem(aoAnchor, asPath, oValue)
{
var asTypes = {boolean:false, string:'', integer:0, int:0, array:[], object:{}};
if(typeof asPath == 'object' && asPath.length > 1)
{
var nextlevel = asPath.shift();
if(!(nextlevel in aoAnchor)) aoAnchor[nextlevel] = {}; //Creating a new level
if(typeof aoAnchor[nextlevel] !== 'object') debug('Error - setElem() : Already existing path at level "'+nextlevel+'". Cancelling setElem() action');
return setElem(aoAnchor[nextlevel], asPath, oValue);
}
else
{
var sKey = (typeof asPath == 'object')?asPath.shift():asPath;
return aoAnchor[sKey] = (!(sKey in aoAnchor) && (oValue in asTypes))?asTypes[oValue]:oValue;
}
}
function getTimeZoneDesc(sTimeZoneName) {
if(sTimeZoneName.indexOf('/')==-1) return sTimeZoneName;
else {
var sCity = sTimeZoneName.split('/')[1].replace('_', ' ') || '';
return oSpot.lang('city_time', sCity);
}
}
$.prototype.addInput = function(sType, sName, sValue, aoEvents)
{
aoEvents = aoEvents || [];
var $Input = $('<input>', {type: sType, name: sName, value: sValue}).data('old_value', sValue);
$.each(aoEvents, function(iIndex, aoEvent) {
$Input.on(aoEvent.on, aoEvent.callback);
});
return $(this).append($Input);
};
$.prototype.addButton = function(sIcon, sText, sName, fOnClick, sClass)
{
sText = sText || '';
sClass = sClass || '';
var $Btn = $('<button>', {name: sName, 'class':sClass})
.addIcon('fa-'+sIcon, (sText != ''))
.append(sText)
.click(fOnClick);
return $(this).append($Btn);
};
$.prototype.addIcon = function(sIcon, bMargin, sStyle)
{
bMargin = bMargin || false;
sStyle = sStyle || '';
return $(this).prepend($('<i>', {'class':'fa'+sStyle+' '+sIcon+(bMargin?' push':'')}));
};
$.prototype.defaultVal = function(sDefaultValue)
{
$(this)
.data('default_value', sDefaultValue)
.val(sDefaultValue)
.addClass('defaultText')
.focus(function()
{
var $This = $(this);
if($This.val() == $This.data('default_value')) $This.val('').removeClass('defaultText');
})
.blur(function()
{
var $This = $(this);
if($This.val() == '') $This.val($This.data('default_value')).addClass('defaultText');
});
};
$.prototype.checkForm = function(sSelector)
{
sSelector = sSelector || 'input[type="text"], textarea';
var $This = $(this);
var bOk = true;
$This.find(sSelector).each(function()
{
$This = $(this);
bOk = bOk && $This.val()!='' && $This.val()!=$This.data('default_value');
});
return bOk;
};
$.prototype.cascadingDown = function(sDuration)
{
return $(this).slideDown(sDuration, function(){$(this).next().cascadingDown(sDuration);});
};
$.prototype.hoverSwap = function(sDefault, sHover)
{
return $(this)
.data('default', sDefault)
.data('hover', sHover)
.hover(function(){
var $This = $(this),
sHover = $This.data('hover');
sDefault = $This.data('default');
if(sDefault!='' && sHover != '') {
$This.fadeOut('fast', function() {
var $This = $(this);
$This.text((sDefault==$This.text())?sHover:sDefault).fadeIn('fast');
});
}
})
.text(sDefault);
};
$.prototype.onSwipe = function(fCallBack){
return $(this)
.on('mousedown touchstart', function(e) {
var $This = $(this);
$This.data('x-down', e.pageX);
$This.data('y-down', e.pageY);
})
.on('mouseup touchend',function (e) {
var $This = $(this);
var iDeltaX = e.pageX - $This.data('x-down');
var iDeltaY = e.pageY - $This.data('y-down');
fCallBack({x:iDeltaX, y:iDeltaY});
});
};
function copyTextToClipboard(text) {
if(!navigator.clipboard) {
var textArea = document.createElement('textarea');
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
if(!successful) console.error('Fallback: Oops, unable to copy', text);
} catch (err) {
console.error('Fallback: Oops, unable to copy', err);
}
document.body.removeChild(textArea);
return;
}
navigator.clipboard.writeText(text).then(
function() {},
function(err) {
console.error('Async: Could not copy text: ', err);
}
);
}
+19
View File
@@ -0,0 +1,19 @@
<?php
class Settings
{
const GEO_SERVER = 'http(s)://geo.server.tld';
const GEO_SERVER_TOKEN = '';
const DB_SERVER = 'localhost';
const DB_LOGIN = '';
const DB_PASS = '';
const DB_NAME = 'spot';
const DB_ENC = 'utf8mb4';
const TEXT_ENC = 'UTF-8';
const TIMEZONE = 'Europe/Paris';
const MAIL_SERVER = '';
const MAIL_FROM = '';
const MAIL_USER = '';
const MAIL_PASS = '';
const DEBUG = true;
}
-114
View File
@@ -1,114 +0,0 @@
<script>
import { defineAsyncComponent } from 'vue';
import Project from '@components/Project';
const aoRoutes = {
'project': Project, //Merge app.js and project.js calls to avoid extra http request on inital page
'admin': defineAsyncComponent(() => import('@components/Admin')),
'upload': defineAsyncComponent(() => import('@components/Upload'))
};
export default {
data() {
return {
hash: {page: '', items: [], prev: {}},
consts: this.appConfig.consts,
mobile: false
};
},
provide() {
return {
hash: this.hash,
consts: this.consts,
isMobile: () => this.isMobile(),
getAnchor: this.getAnchor,
getPrevAnchor: () => this.getPrevAnchor(),
};
},
computed: {
route() {
return aoRoutes[this.hash.page];
},
hashSnapshot() {
const { prev, ...asHash } = this.hash;
return JSON.stringify(asHash);
}
},
inject: ['appConfig'],
created() {
this.mobileMediaQuery = window.matchMedia('only screen and (max-width: 800px)');
this.mobileMediaQuery.addEventListener('change', this.updateMobile);
this.updateMobile();
//Set initial page
this.setVarHash(this.validateRoute(this.getBrowserHash()));
},
mounted() {
//Catch browser hash change
window.addEventListener('hashchange', this.onBrowserHashChange);
},
watch: {
hashSnapshot(jNewHash, jOldHash) {
const asNewHash = this.validateRoute(JSON.parse(jNewHash));
//Sync variable -> #hash
if(asNewHash != this.getBrowserHash()) {
this.setBrowserHash(asNewHash.page, asNewHash.items);
}
//Store previous value
this.hash.prev = JSON.parse(jOldHash);
this.setPageTitle(asNewHash.page);
}
},
methods: {
isMobile() {
return this.mobile;
},
updateMobile() {
this.mobile = this.mobileMediaQuery.matches;
},
setPageTitle(sTitle) {
document.title = this.consts.title + ' - ' + sTitle.trim();
},
setVarHash(asHash) {
this.hash.page = asHash.page || '';
this.hash.items = Array.isArray(asHash.items) ? [...asHash.items.filter(n => n)] : [];
},
onBrowserHashChange() { //Sync #hash -> variable
let asHash = this.getBrowserHash();
if(asHash != this.hash) this.setVarHash(asHash);
},
getBrowserHash() {
let sHash = window.location.hash.slice(1);
let asHash = sHash.split(this.consts.hash_sep).filter(n => n);
let sPage = asHash.shift() || '';
return {page: sPage, items: asHash};
},
setBrowserHash(sPage = '', asItems = []) {
if(typeof asItems == 'string' && asItems != '') asItems = [asItems];
window.location.hash = this.getAnchor([sPage, ...asItems]);
},
getAnchor(asBreadCrumbs) {
return '#' + asBreadCrumbs.filter(Boolean).join(this.consts.hash_sep);
},
getPrevAnchor() {
return this.getAnchor([this.hash.prev.page, ...this.hash.prev.items]);
},
validateRoute(asHash) {
if(!Object.keys(aoRoutes).includes(asHash.page)) asHash.page = this.consts.default_page;
return asHash;
}
},
beforeUnmount() {
window.removeEventListener('hashchange', this.onBrowserHashChange);
this.mobileMediaQuery.removeEventListener('change', this.updateMobile);
}
};
</script>
<template>
<div id="main">
<component :is="route" />
</div>
</template>
-38
View File
@@ -1,38 +0,0 @@
//Librairies
import 'vite/modulepreload-polyfill';
import Api from '@scripts/api';
import Lang from '@scripts/lang';
import Projects from '@scripts/projects';
import User from '@scripts/user';
import { createApp, reactive } from 'vue';
//Main template
import App from './App.vue';
//Style
import '@styles/livetrail.scss';
//App Configuration from PHP
const appConfig = JSON.parse(document.getElementById('app-config').textContent);
//Instances
const oProjects = new Projects(appConfig.projects);
const oUser = reactive(new User(appConfig.user, appConfig.consts.default_timezone));
const oLang = new Lang({translations: appConfig.consts.lang, prefix: appConfig.consts.lang_prefix});
const oApi = new Api({
server: appConfig.consts.server,
processPage: appConfig.consts.process_page,
timezone: oUser.timezone,
csrfToken: appConfig.consts.csrf_token,
errorCode: appConfig.consts.error,
lang: oLang
});
//Mount app
const oApp = createApp(App);
oApp.provide('appConfig', appConfig);
oApp.provide('api', oApi);
oApp.provide('lang', oLang);
oApp.provide('projects', oProjects);
oApp.provide('user', oUser);
oApp.mount('#container');
-234
View File
@@ -1,234 +0,0 @@
<script>
import AppIcon from '@components/AppIcon';
import AppButton from '@components/AppButton';
import AdminInput from '@components/AdminInput';
export default {
components: {
AppIcon,
AppButton,
AdminInput
},
inject: ['api', 'lang', 'getPrevAnchor'],
data() {
return {
elems: {},
feedbacks: [],
saveTimer: null
};
},
beforeUnmount() {
if(this.saveTimer) clearTimeout(this.saveTimer);
},
mounted() {
this.setProjects();
},
methods: {
l(sLangId, asLangParams=[]) {
return this.lang.get(sLangId, asLangParams);
},
addFeedback(sType, sMsg, asContext = {}) {
delete asContext.a;
delete asContext.t;
sMsg += ' (';
for(const [sKey, sElem] of Object.entries(asContext)) {
sMsg += sKey+'='+sElem+' / ' ;
}
sMsg = sMsg.slice(0, -3)+')';
this.feedbacks.push({type:sType, msg:sMsg});
},
async setProjects() {
let aoElemTypes = await this.api.get('admin_get');
for(const [sType, aoElems] of Object.entries(aoElemTypes)) {
this.elems[sType] = {};
for(const oElem of Object.values(aoElems)) {
oElem.type = sType;
this.elems[sType][oElem.id] = oElem;
}
}
},
createElem(sType) {
this.api.post('admin_create', {type: sType})
.then((aoNewElemTypes) => {
for(const [sType, aoNewElems] of Object.entries(aoNewElemTypes)) {
for(const oNewElem of Object.values(aoNewElems)) {
oNewElem.type = sType;
this.elems[sType][oNewElem.id] = oNewElem;
this.addFeedback('success', this.l('admin.create_success'), {'create':sType});
}
}
})
.catch((oError) => {this.addFeedback('error', oError.desc_lang_text, {'create':sType});});
},
updateElem(oElem, oEvent) {
if(this.saveTimer) clearTimeout(this.saveTimer);
let sOldVal = this.elems[oElem.type][oElem.id][oEvent.target.name];
let sNewVal = oEvent.target.value;
if(sOldVal != sNewVal) {
let asInputs = {
type: oElem.type,
id: oElem.id,
field: oEvent.target.name,
value: sNewVal
};
this.api.request('admin_set', asInputs, 'POST')
.then((oResponse) => {
this.elems[oElem.type][oElem.id][oEvent.target.name] = sNewVal;
const bSuccess = (oResponse.desc_lang_id == '');
const sType = bSuccess?'success':'warning';
const sText = this.l(bSuccess?'admin.save_success':oResponse.desc_lang_id, oResponse.desc_lang_params);
this.addFeedback(sType, sText, asInputs);
})
.catch((oError) => {
oEvent.target.value = sOldVal;
this.addFeedback('error', oError.desc_lang_text, asInputs);
});
}
},
deleteElem(oElem) {
const asInputs = {
type: oElem.type,
id: oElem.id
};
this.api.post('admin_delete', asInputs)
.then(() => {
delete this.elems[asInputs.type][asInputs.id];
this.addFeedback('success', this.l('admin.delete_success'), asInputs);
})
.catch((oError) => {
this.addFeedback('error', oError.desc_lang_text, asInputs);
});
},
queue(oElem, oEvent) {
if(this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => {this.updateElem(oElem, oEvent);}, 2000);
},
updateProject() {
this.api.request('update_project', {}, 'POST')
.then((oResponse) => {this.addFeedback('success', oResponse.desc_lang_text, {'update':'project'});})
.catch((oError) => {this.addFeedback('error', oError.desc_lang_text, {'update':'project'});});
}
}
};
</script>
<template>
<div id="admin">
<a name="back" class="button" :href="getPrevAnchor()"><AppIcon :icon="'back'" :text="l('action.back')" /></a>
<h1>{{ l('project.plural') }}</h1>
<div>
<table>
<thead>
<tr>
<th>{{ l('project.id') }}</th>
<th>{{ l('project.single') }}</th>
<th>{{ l('project.mode') }}</th>
<th>{{ l('project.code_name') }}</th>
<th>{{ l('project.start') }}</th>
<th>{{ l('project.end') }}</th>
<th>{{ l('action.delete') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="project in elems.project" :key="project.id">
<td>{{ project.id }}</td>
<td><AdminInput :type="'text'" :name="'name'" :elem="project" /></td>
<td>{{ project.mode }}</td>
<td><AdminInput :type="'text'" :name="'codename'" :elem="project" /></td>
<td><AdminInput :type="'date'" :name="'active_from'" :elem="project" /></td>
<td><AdminInput :type="'date'" :name="'active_to'" :elem="project" /></td>
<td><AppButton :icon="'close'" iconSize="lg" @click="deleteElem(project)" /></td>
</tr>
</tbody>
</table>
<AppButton :classes="'new'" :text="l('project.new')" :icon="'new'" @click="createElem('project')" />
</div>
<h1>{{ l('feed.plural') }}</h1>
<div>
<table>
<thead>
<tr>
<th>{{ l('feed.id') }}</th>
<th>{{ l('feed.ref_id') }}</th>
<th>{{ l('spot.id') }}</th>
<th>{{ l('project.id') }}</th>
<th>{{ l('feed.name') }}</th>
<th>{{ l('feed.status') }}</th>
<th>{{ l('feed.last_update') }}</th>
<th>{{ l('action.delete') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="feed in elems.feed" :key="feed.id">
<td>{{ feed.id }}</td>
<td><AdminInput :type="'text'" :name="'ref_feed_id'" :elem="feed" /></td>
<td><AdminInput :type="'number'" :name="'id_spot'" :elem="feed" /></td>
<td><AdminInput :type="'number'" :name="'id_project'" :elem="feed" /></td>
<td>{{ feed.name }}</td>
<td>{{ feed.status }}</td>
<td>{{ feed.last_update }}</td>
<td><AppButton :icon="'close'" iconSize="lg" @click="deleteElem(feed)" /></td>
</tr>
</tbody>
</table>
<AppButton :classes="'new'" :text="l('feed.new')" :icon="'new'" @click="createElem('feed')" />
</div>
<h1>{{ l('spot.plural') }}</h1>
<div>
<table>
<thead>
<tr>
<th>{{ l('spot.id') }}</th>
<th>{{ l('spot.ref_id') }}</th>
<th>{{ l('spot.name') }}</th>
<th>{{ l('spot.model') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="spot in elems.spot" :key="spot.id">
<td>{{ spot.id }}</td>
<td>{{ spot.ref_spot_id }}</td>
<td>{{ spot.name }}</td>
<td>{{ spot.model }}</td>
</tr>
</tbody>
</table>
</div>
<h1>{{ l('user.subscribed') }}</h1>
<div>
<table>
<thead>
<tr>
<th>{{ l('user.id') }}</th>
<th>{{ l('user.name') }}</th>
<th>{{ l('user.email') }}</th>
<th>{{ l('user.language') }}</th>
<th>{{ l('time.zone') }}</th>
<th>{{ l('user.clearance') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in elems.user" :key="user.id">
<td>{{ user.id }}</td>
<td class="left">{{ user.name }}</td>
<td class="left">{{ user.email }}</td>
<td>{{ user.language }}</td>
<td>{{ user.timezone }}</td>
<td><AdminInput :type="'number'" :name="'clearance'" :elem="user" /></td>
</tr>
</tbody>
</table>
</div>
<h1>{{ l('admin.toolbox') }}</h1>
<div id="toolbox">
<AppButton :classes="'refresh'" :text="l('project.update_messages')" :icon="'refresh'" @click="updateProject" />
</div>
<div id="feedback" class="feedback">
<p v-for="(feedback, index) in feedbacks" :key="index" :class="feedback.type">{{ feedback.msg }}</p>
</div>
</div>
</template>
-18
View File
@@ -1,18 +0,0 @@
<script>
export default {
props: {
type: String,
name: String,
elem: Object
},
computed: {
value() {
return this.elem[this.name];
}
}
};
</script>
<template>
<input :type="type" :name="name" :value="value" @change="$parent.updateElem(elem, $event)" @keyup="$parent.queue(elem, $event)" />
</template>

Some files were not shown because too many files have changed in this diff Show More