From 67a89bb091af4283a0c5cd02974b0027fd304464 Mon Sep 17 00:00:00 2001 From: Benjamin Renard Date: Sat, 21 Sep 2024 17:07:52 +0200 Subject: [PATCH] Implement server scases data synchronization --- .gitignore | 1 + composer.json | 5 +- config.yml | 30 +- data/.gitignore | 1 + data/sqlite.init-db.sql | 45 +++ includes/core.php | 25 +- includes/views/index.php | 101 ++++++- phpstan.neon | 14 + src/Auth/API.php | 128 ++++++++ src/Db/AuthToken.php | 55 ++++ src/Db/Category.php | 56 ++++ src/Db/DbObject.php | 107 +++++++ src/Db/SCase.php | 81 +++++ src/Db/Thing.php | 44 +++ src/Db/User.php | 39 +++ static/main.js | 463 +++++++++++++++++++++++++---- static/mysc_objects.js | 105 ++++--- templates/index.tpl | 70 ++++- templates/support_info.tpl | 13 + templates/support_info_content.tpl | 8 + 20 files changed, 1265 insertions(+), 126 deletions(-) create mode 100644 data/.gitignore create mode 100644 data/sqlite.init-db.sql create mode 100644 phpstan.neon create mode 100644 src/Auth/API.php create mode 100644 src/Db/AuthToken.php create mode 100644 src/Db/Category.php create mode 100644 src/Db/DbObject.php create mode 100644 src/Db/SCase.php create mode 100644 src/Db/Thing.php create mode 100644 src/Db/User.php create mode 100644 templates/support_info.tpl create mode 100644 templates/support_info_content.tpl diff --git a/.gitignore b/.gitignore index a09d069..7480101 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/config.local.yml # Ignore vim temporary/backup files *~ .*.swp diff --git a/composer.json b/composer.json index 2b594ff..d1e3e0a 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "license": "GPL3", "autoload": { "psr-4": { - "Brenard\\Mysc\\": "src/" + "MySC\\": "src/" } }, "authors": [ @@ -22,5 +22,8 @@ "allow-plugins": { "php-http/discovery": true } + }, + "require-dev": { + "phpstan/phpstan": "2.0.x-dev" } } diff --git a/config.yml b/config.yml index e9f2db8..e87a2b8 100644 --- a/config.yml +++ b/config.yml @@ -112,12 +112,12 @@ session: # db: # Sqlite - #dsn: "sqlite:${data_directory}/db.sqlite3" - #options: null + dsn: "sqlite:${data_directory}/db.sqlite3" + options: null # Date/Datetime format in database (strptime format) - #date_format: '%s' - #datetime_format: '%s' + date_format: '%s' + datetime_format: '%s' # Postgresql #dsn: "pgsql:host=localhost;port=5432;dbname=items" @@ -144,19 +144,7 @@ db: # auth: # Enabled authentication - enabled: false - - # Methods to authenticate users - methods: - - form - - http - #- cas - - # User backends - backends: - #- ldap - #- db - #- casuser + enabled: true # # Login form @@ -257,14 +245,6 @@ auth: # Database user backend # db: - # DSN (required) - dsn: "${db.dsn}" - # Username (optional but could be required with some PDO drivers) - user: "${db.user}" - # Password (optional) - password: "${db.password}" - # PDO options (optional) - options: "${db.options}" # Users table name (optional, default: users) #users_table: "users" # Username field name (optional, default: username) diff --git a/data/.gitignore b/data/.gitignore new file mode 100644 index 0000000..49ef255 --- /dev/null +++ b/data/.gitignore @@ -0,0 +1 @@ +db.sqlite3 diff --git a/data/sqlite.init-db.sql b/data/sqlite.init-db.sql new file mode 100644 index 0000000..bedc1db --- /dev/null +++ b/data/sqlite.init-db.sql @@ -0,0 +1,45 @@ +CREATE TABLE users ( + username text NOT NULL PRIMARY KEY, + name text COLLATE NOCASE NOT NULL, + mail text COLLATE NOCASE, + password text NOT NULL +); + +INSERT INTO users (username, name, mail, password) VALUES ( + "admin", "Administrator", "admin@example.com", + "$argon2id$v=19$m=65536,t=4,p=1$WTQ0di44NW11MUJ1b3RMQw$+LRAQRaIXE2jhfavNFNuxnEtEUT6tEBz/98pTtD0EnM" +); + +CREATE TABLE auth_tokens ( + token text NOT NULL PRIMARY KEY, + username text NOT NULL REFERENCES users(username) ON UPDATE CASCADE ON DELETE CASCADE, + creation_date REAL, + expiration_date REAL +); + +CREATE TABLE scases ( + uuid text NOT NULL PRIMARY KEY, + username text NOT NULL REFERENCES users(username) ON UPDATE CASCADE ON DELETE CASCADE, + name text NOT NULL, + last_change REAL NOT NULL, + removed INTEGER NOT NULL +); + +CREATE TABLE categories ( + uuid text NOT NULL PRIMARY KEY, + scase_uuid text NOT NULL REFERENCES scases(uuid) ON UPDATE CASCADE ON DELETE CASCADE, + name text NOT NULL, + color text NOT NULL, + last_change REAL NOT NULL, + removed INTEGER NOT NULL +); + +CREATE TABLE things ( + uuid text NOT NULL PRIMARY KEY, + category_uuid text NOT NULL REFERENCES categories(uuid) ON UPDATE CASCADE ON DELETE CASCADE, + label text NOT NULL, + nb INTEGER NOT NULL, + checked INTEGER NOT NULL, + last_change REAL NOT NULL, + removed INTEGER NOT NULL +); diff --git a/includes/core.php b/includes/core.php index 7108884..6a53590 100644 --- a/includes/core.php +++ b/includes/core.php @@ -1,9 +1,12 @@ array( + 'enabled' => true, + 'methods' => array( + '\\MySC\\Auth\\API', + ), + 'backends' => array( + 'db', + ), + ), 'default' => array( - // Set here your configuration parameters default value + 'auth' => array( + 'enabled' => true, + 'methods' => array( + '\\MySC\\Auth\\API', + ), + 'auth_token' => array( + 'expiration_delay' => 31536000, + ), + ), ), ), $root_dir_path @@ -48,7 +68,8 @@ App::init( $sentry_span = new SentrySpan('core.init', 'Core initialization'); // Put here your own initialization stuff - +Db :: init(); +API :: init(); require 'views/index.php'; $sentry_span->finish(); diff --git a/includes/views/index.php b/includes/views/index.php index 8c4c821..d0c2360 100644 --- a/includes/views/index.php +++ b/includes/views/index.php @@ -1,8 +1,21 @@ $user -> username]); + foreach($data["scases"] as $scase_uuid => $scase_data) { + Log::debug("sync(): scase %s", $scase_uuid); + $scase = Scase :: from_json($scase_data, $user); + if (array_key_exists($scase_uuid, $db_scases)) { + Log::debug("sync(): scase %s exist in DB", $scase_uuid); + $db_scases[$scase_uuid] -> sync($scase, $updated); + // @phpstan-ignore-next-line + $db_categories = $db_scases[$scase_uuid] -> categories(); + } + else { + Log::debug("sync(): scase %s does not exist in DB, create it", $scase_uuid); + $scase -> save(); + $updated = true; + $db_categories = []; + } + + foreach($scase_data['cats'] as $category_uuid => $category_data) { + Log::debug("sync(): scase %s / category %s", $scase_uuid, $category_uuid); + $category = Category :: from_json($category_data, $scase); + if (array_key_exists($category_uuid, $db_categories)) { + Log::debug("sync(): scase %s / category %s exists in DB, sync it", $scase_uuid, $category_uuid); + $db_categories[$category_uuid] -> sync($category, $updated); + $db_things = $db_categories[$category_uuid] ->things(); + } + else { + Log::debug("sync(): scase %s / category %s does not exists in DB, create it", $scase_uuid, $category_uuid); + $category -> save(); + $updated = true; + $db_things = []; + } + + foreach($category_data['things'] as $thing_uuid => $thing_data) { + $thing = Thing :: from_json($thing_data, $category); + if (array_key_exists($thing_uuid, $db_things)) { + $db_things[$thing_uuid] -> sync($thing, $updated); + } + else { + $thing -> save(); + $updated = true; + } + } + } + } + + $data = [ + "scases" => [], + "updated" => $updated, + ]; + foreach (SCase :: list(['username' => $user -> username]) as $uuid => $scase) { + $data["scases"][$uuid] = $scase -> to_json(); + } + Tpl::display_ajax_return($data); +} +Url :: add_url_handler("#^sync$#", 'handle_sync', null, true, true, true); + +/** + * Support info page + * @param EesyPHP\UrlRequest $request + * @return void + */ +function handle_support_info($request) { + if (isset($_REQUEST['download'])) { + header('Content-Type: text/plain'); + header('Content-disposition: attachment; filename="'.date('Ymd-His-').Auth::user()->username.'-aide-support.txt"'); + } + Tpl :: display( + isset($_REQUEST['download'])? + 'support_info_content.tpl':"support_info.tpl", + "Page d'aide à l'assistance utilisateurs" + ); +} +Url :: add_url_handler('|^support/?$|', 'handle_support_info'); + # vim: tabstop=2 shiftwidth=2 softtabstop=2 expandtab diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..b9a456d --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,14 @@ +parameters: + level: 5 + paths: + - bin + - includes + - src + - public_html + universalObjectCratesClasses: + - EesyPHP\HookEvent + - EesyPHP\UrlRequest + - EesyPHP\Auth\User + - EesyPHP\Db\DbObject + bootstrapFiles: + - includes/core.php diff --git a/src/Auth/API.php b/src/Auth/API.php new file mode 100644 index 0000000..8f501d7 --- /dev/null +++ b/src/Auth/API.php @@ -0,0 +1,128 @@ +username != $_REQUEST['username']) + Tpl :: add_error(_('Invalid authentication token.')); + elseif ($auth_token->expired()) { + Tpl :: add_error(_('Authentication token expired.')); + $auth_token->delete(); + } + else { + Log::debug("API::login(): auth token valid, retrieve corresponding user"); + $user = Auth::get_user($auth_token->username); + if (!$user) { + Tpl :: add_error(_('Your account appears to have been deleted.')); + $auth_token->delete(); + } + Log::debug("login(): authenticated as %s via token", $user); + } + } + return $user; + } + + /** + * The login form view + * @param \EesyPHP\UrlRequest $request + * @return void + */ + public static function handle_login($request) { + $user = Auth :: login(false, '\\mysc\\auth\\api'); + if ($user) + Tpl :: display_ajax_return([ + "success" => true, + "token" => AuthToken :: create($user) -> token, + "username" => $user -> username, + "name" => $user->name, + "mail" => $user->mail, + ]); + + Tpl :: display_ajax_return([ + "success" => false, + ]); + } + + /** + * The logout method + * @return void + */ + public static function logout() { + Log::debug("API::logout()"); + if (isset($_REQUEST['token'])) { + $auth_token = AuthToken :: get($_REQUEST['token']); + Log::debug("API::logout(): auth token = %s", vardump($auth_token)); + if (!$auth_token) { + Log::debug("API::logout(): unknown auth token"); + } + else if (isset($_REQUEST['username']) && $auth_token->username != $_REQUEST['username']) { + Log::warning( + "API::logout(): bad auth token owner ('%s' vs '%s')", + $auth_token->username, + $_REQUEST['username'] + ); + } + else if ($auth_token->delete()) { + Log::debug("API::logout(): auth token deleted"); + } + else { + Log::error("API::logout(): error deleting the auth token %s", $_REQUEST['token']); + } + } + else { + Log::warning("API::logout(): no token provided by the request"); + } + Tpl :: display_ajax_return([ + "success" => true, + ]); + } +} diff --git a/src/Db/AuthToken.php b/src/Db/AuthToken.php new file mode 100644 index 0000000..5d27106 --- /dev/null +++ b/src/Db/AuthToken.php @@ -0,0 +1,55 @@ + new AttrStr(['required' => true, 'default' => '\\EesyPHP\\generate_uuid']), + 'username' => new AttrStr(['required' => true]), + 'creation_date' => new AttrTimestamp(['default' => 'time']), + 'expiration_date' => new AttrTimestamp( + ['default' => ['\\MySC\\Db\\AuthToken', 'generate_expiration_date']] + ), + ]; + } + + public static function generate_expiration_date() { + return time() + App::get('auth.auth_token.expiration_delay', null, 'int'); + } + + public static function create($user=null) { + $user = $user ? $user : Auth::user(); + $token = new AuthToken(); + $token -> username = $user->username; + $token->save(); + return $token; + } + + public function user() { + return User::get($this -> username); + } + + public function expired() { + return $this -> expiration_date -> getTimestamp() <= time(); + } +} diff --git a/src/Db/Category.php b/src/Db/Category.php new file mode 100644 index 0000000..fb6d6d0 --- /dev/null +++ b/src/Db/Category.php @@ -0,0 +1,56 @@ + new AttrStr(["required" => true]), + "name" => new AttrStr(["required" => true]), + "color" => new AttrStr(["required" => true]), + ] + ); + } + + public function to_json() { + $data = parent :: to_json(); + $data["things"] = []; + foreach($this -> things() as $uuid => $thing) + $data["things"][$uuid] = $thing -> to_json(); + return $data; + } + + public static function from_json($data, $scase) { + $obj = parent :: _from_json($data); + $obj -> scase_uuid = $scase -> uuid; + return $obj; + } + + public function things() { + return Thing :: list(["category_uuid" => $this -> uuid]); + } + + public function delete_all_things() { + return Thing :: deleteAll(["category_uuid" => $this -> uuid]); + } + +} diff --git a/src/Db/DbObject.php b/src/Db/DbObject.php new file mode 100644 index 0000000..fc14211 --- /dev/null +++ b/src/Db/DbObject.php @@ -0,0 +1,107 @@ + new AttrStr(['required' => true]), + 'last_change' => new AttrTimestamp(['default' => 'time']), + 'removed' => new AttrBool(['default' => false]), + ]; + } + + public function to_json() { + $data = [ + 'uuid' => $this -> uuid, + 'lastChange' => intval($this -> last_change -> format('Uv')), + 'removed' => boolval($this -> removed), + ]; + foreach(static :: UPDATABLE_FIELDS as $field) + $data[$field] = $this -> $field; + return $data; + } + + public static function _from_json($data) { + Log::debug( + "from_json(): input=%s", + vardump($data), + ); + $class = get_called_class(); + $obj = new $class(); + $obj -> apply([ + 'uuid' => $data['uuid'], + 'last_change' => Date :: from_timestamp($data['lastChange'] / 1000), + 'removed' => $data['removed'] == "true", + ]); + foreach(static :: UPDATABLE_FIELDS as $field) + $obj -> $field = $data[$field]; + Log::debug( + "%s::from_json(): result=%s", + $obj, + vardump($obj->to_json()), + ); + return $obj; + } + + public function sync(&$other, &$updated) { + if ($this -> last_change >= $other -> last_change) { + Log::debug( + "%s::sync(): keep current (%s >= %s)", + $this, + Date::format($this->last_change), + Date::format($other->last_change), + ); + return true; + } + Log::debug( + "%s::sync(): update from other (%s < %s) : %s", + $this, + Date::format($this->last_change), + Date::format($other->last_change), + vardump($other->to_json()), + ); + $this -> apply([ + 'last_change' => $other -> last_change, + 'removed' => $other -> removed, + ]); + foreach(static :: UPDATABLE_FIELDS as $field) + $this -> $field = $other -> $field; + $updated = true; + return $this -> save(); + } + + /** + * List objects + * @param array $where Where clauses as associative array of field name and value + * @return array|false + */ + public static function list($where=null) { + $objs = []; + foreach(parent :: list($where) as $obj) + $objs[$obj -> uuid] = $obj; + return $objs; + } + + public function __toString() { + return get_called_class()."<".$this -> uuid.">"; + } +} diff --git a/src/Db/SCase.php b/src/Db/SCase.php new file mode 100644 index 0000000..c1a47f5 --- /dev/null +++ b/src/Db/SCase.php @@ -0,0 +1,81 @@ + new AttrStr(['required' => true]), + 'name' => new AttrStr(['required' => true]), + ] + ); + } + + public function to_json() { + $data = parent :: to_json(); + $data["cats"] = []; + foreach($this -> categories() as $uuid => $cat) + $data["cats"][$uuid] = $cat -> to_json(); + return $data; + } + + public static function from_json($data, $user) { + $obj = parent :: _from_json($data); + $obj -> username = $user -> username; + return $obj; + } + + /** + * Get scase's categories + * @return array|false + */ + public function categories() { + return Category :: list(['scase_uuid' => $this -> uuid]); + } + + public function delete() { + foreach($this -> categories() as $category) { + if (!$category->delete_all_things()) { + Log::error( + "Db: error occurred deleting things of category '%s' (%s)", + $category->name, $category->uuid + ); + return false; + } + if (!$category->delete()) { + Log::error( + "Db: error occurred deleting category '%s' (%s)", + $category->name, $category->uuid + ); + return false; + } + } + if (!parent::delete()) { + Log::error( + "Db: error occurred deleting scases '%s' (%s)", + $this->name, $this->uuid + ); + return false; + } + return true; + } + +} diff --git a/src/Db/Thing.php b/src/Db/Thing.php new file mode 100644 index 0000000..31b72c0 --- /dev/null +++ b/src/Db/Thing.php @@ -0,0 +1,44 @@ + new AttrStr(['required' => true]), + 'label' => new AttrStr(['required' => true]), + 'nb' => new AttrInt(['required' => true, 'default' => 1]), + 'checked' => new AttrBool(['required' => false, 'default' => false]), + ] + ); + } + + public static function from_json($data, $category) { + $obj = parent :: _from_json($data); + $obj -> category_uuid = $category -> uuid; + return $obj; + } + +} diff --git a/src/Db/User.php b/src/Db/User.php new file mode 100644 index 0000000..0de8248 --- /dev/null +++ b/src/Db/User.php @@ -0,0 +1,39 @@ + new AttrStr(['required' => true]), + 'name' => new AttrStr(['required' => true]), + 'password' => new AttrStr(['required' => true]), + 'mail' => new AttrStr(), + ]; + } + + /** + * Get user's scases + * @return bool|SCase[] + */ + public function scases() { + return SCase :: list(['username' => $this -> username]); + } + +} diff --git a/static/main.js b/static/main.js index 0c3822d..e7c3adb 100644 --- a/static/main.js +++ b/static/main.js @@ -62,9 +62,13 @@ on_title_click=function(event) { $('.panel-collapse').each(function(idx,div) { $(div).collapse('hide'); }); - if (show) { + if (show) panel_collapse.collapse('show'); - } + set_location( + title.data('scase').name, + show ? title.data('cat').name : null, + title.data('trash') ? 'trash' : null + ); } /*********************** @@ -96,6 +100,7 @@ on_valid_add_scase_modal=function (e) { if (scase) { scases.save(); show_scase(scase); + auto_sync_local_data(); } $('#add_scase_modal').modal('hide'); } @@ -140,6 +145,7 @@ on_valid_rename_scase_modal=function (e) { if (scase) { scases.save(); show_scase(scase); + auto_sync_local_data(); } else { alertify.notify('Une erreur est survenue en renomant la valise...', "error", 5); @@ -185,6 +191,7 @@ on_valid_copy_scase_modal=function (e) { if (scase) { scases.save(); show_scase(scase); + auto_sync_local_data(); } else { alertify.notify('Une erreur est survenue en copiant la valise...', "error", 5); @@ -214,6 +221,7 @@ on_reset_scase_btn_click=function(event) { scases.resetSCase(scase.name); scases.save(); show_scase(scase); + auto_sync_local_data(); }, null ); @@ -233,6 +241,7 @@ on_delete_scase_btn_click=function(event) { scases.removeSCase(scase.name); scases.save(); show_scases(); + auto_sync_local_data(); }, null ); @@ -296,6 +305,7 @@ on_valid_add_cat_modal=function (e) { if (cat) { scases.save(); show_scase(scase,cat.name); + auto_sync_local_data(); } } $('#add_cat_modal').modal('hide'); @@ -342,6 +352,7 @@ on_valid_rename_cat_modal=function (e) { if (cat) { scases.save(); show_scase(scase,cat.name); + auto_sync_local_data(); } } $('#rename_cat_modal').modal('hide'); @@ -370,6 +381,7 @@ on_delete_cat_btn_click=function(event) { scase.cats.removeCat(cat); scases.save(); show_scase(scase); + auto_sync_local_data(); }, null ); @@ -388,6 +400,7 @@ on_restore_cat_btn_click=function(event) { scase.cats.restoreCat(cat); scases.save(); show_scase(scase); + auto_sync_local_data(); }, null ); @@ -398,16 +411,8 @@ on_restore_cat_btn_click=function(event) { * Check/Uncheck thing ***********************/ on_li_click=function(event) { - if (event.target.tagName!='LI') { - return; - } + if (event.target.tagName!='LI') return; var li=$(this); - if (li.hasClass('checked')) { - li.removeClass('checked'); - } - else { - li.addClass('checked'); - } var ul=li.parent(); var scase=scases.byName($('#cats').data('scase')); if (scase) { @@ -415,8 +420,10 @@ on_li_click=function(event) { if (cat) { var thing=cat.byLabel(li.data('label')); if (thing) { + li.toggleClass('checked'); thing.setChecked(li.hasClass('checked')); scases.save(); + auto_sync_local_data(); } show_scase(scase,cat.name); } @@ -485,6 +492,7 @@ on_valid_add_thing_modal=function (e) { } scases.save(); show_scase(scase,cat.name); + auto_sync_local_data(); } } modal.modal('hide'); @@ -562,6 +570,7 @@ on_valid_edit_thing_modal=function (e) { thing.setNb(nb); scases.save(); show_scase(scase,cat.name); + auto_sync_local_data(); } } } @@ -593,6 +602,7 @@ on_delete_thing_btn_click=function(event) { cat.removeThing(thing); scases.save(); show_scase(scase,cat.name); + auto_sync_local_data(); }, null ); @@ -614,6 +624,7 @@ on_restore_thing_btn_click=function(event) { cat.restoreThing(thing); scases.save(); show_scase(scase,cat.name); + auto_sync_local_data(); }, null ); @@ -624,10 +635,12 @@ on_restore_thing_btn_click=function(event) { /******************** * Show one scase *******************/ -show_cat=function(cat,displayed) { +show_cat=function(scase, cat, displayed) { var panel=$('
'); var panel_heading=$(''); var panel_title=$('

'+cat.name+'

'); + panel_title.data('scase', scase); + panel_title.data('cat', cat); panel_title.bind('click',on_title_click); var stats=cat.stats(); @@ -649,7 +662,7 @@ show_cat=function(cat,displayed) { panel_title.append(tag); - + panel_heading.append(panel_title); panel.append(panel_heading); var panel_collapse=$('
'); @@ -698,8 +711,9 @@ show_scase=function(scase,display_cat) { if (cat.removed) { return; } - show_cat(cat,(cat.name==display_cat)); + show_cat(scase, cat,(cat.name==display_cat)); }); + set_location(scase.name, display_cat); show_menu('scase'); } @@ -720,18 +734,22 @@ show_scase_trash=function(scase,display_cat) { }); scase.cats.each(function(idx,cat) { - show_cat_trash(cat,(cat.name==display_cat)); + show_cat_trash(scase, cat, (cat.name==display_cat)); }); if ($('#cats .panel').length==0) { $('#content').append('

La corbeille est vide.

'); } + set_location(scase.name, display_cat, 'trash'); show_menu('scase'); } -show_cat_trash=function(cat,displayed) { +show_cat_trash=function(scase, cat,displayed) { var panel=$('
'); var panel_heading=$(''); var panel_title=$('

'+cat.name+'

'); + panel_title.data('scase', scase); + panel_title.data('cat', cat); + panel_title.data('trash', true); var tag=$(''); @@ -816,6 +834,7 @@ show_scases=function() { li.bind('click',on_scase_click); $('#scases').append(li); }); + set_location(); show_menu('scases'); } @@ -918,6 +937,25 @@ on_confirm_clear_local_data=function(data) { location.reload(); } +/******************** + * Clear local data + ********************/ +load_example_data=function() { + navbar_collapse_hide(); + alertify.confirm( + "Chargement des données d'exemple", + "Etes-vous sûre de vouloir charger les données d'exemple à la place de vos propres données (action irréversible) ?", + function() { + delete localStorage.scases; + scases=new SCaseList(); + scases.importExampleData(); + scases.save(); + show_scases(); + }, + null + ); +} + /******************************* * Import/Export local data *******************************/ @@ -936,37 +974,47 @@ import_local_data=function() { var file=input.prop('files')[0]; if (file) { var reader = new FileReader(); - $(reader).bind('load',function(e) { - if ($.type(e.target.result)=='string') { - if (e.target.result.startsWith('data:application/json;base64,')) { - try { - json_data=atob(e.target.result.replace('data:application/json;base64,','')); - data=JSON.parse(json_data); - pleaseWaitHide(); - alertify.confirm( - "Importation depuis un fichier", - "Etes-vous sûre de vouloir écraser vos données locales par celle issues de ce fichier ?", - function() { - scases.save(); - var backData=localStorage.scases; - localStorage.scases=json_data; - scases=new SCaseList(); - scases.loadFromLocalStorage(backData); - show_scases(); - }, - null - ); - } - catch (e) { - alertify.notify('Impossible de décodé le fichier.', "error", 5); - pleaseWaitHide(); - } - } - else { - alertify.notify('Fichier invalide.', "error", 5); - pleaseWaitHide(); - } + $(reader).bind('load', function(e) { + if ( + $.type(e.target.result)!='string' + || ! e.target.result.startsWith('data:application/json;base64,') + ) { + pleaseWaitHide(); + alertify.notify('Fichier.', "error", 5); + return; } + try { + json_data=atob(e.target.result.replace('data:application/json;base64,','')); + data=JSON.parse(json_data); + } + catch (e) { + pleaseWaitHide(); + alertify.notify('Impossible de décodé le fichier.', "error", 5); + return; + } + pleaseWaitHide(); + alertify.confirm( + "Importation depuis un fichier", + "Etes-vous sûre de vouloir écraser vos données locales par celle issues de ce fichier ?", + function() { + var backData=scases.export(); + scases=new SCaseList(); + if (scases.loadFromJsonData(data)) { + alertify.notify("Le fichier a bien été importé.", "success", 3); + } + else { + alertify.notify("Une erreur est survenue en chargeant ce fichier. Restauration des données précédentes...", "error", 5); + if (scases.loadFromJsonData(backData)) { + alertify.notify("Les données précédentes ont bien été restaurées.", "success", 5); + } + else { + alertify.notify("Une erreur est survenue en restaurant les données précédentes.", "error", 5); + } + } + show_scases(); + }, + null + ); }); reader.readAsDataURL(file); } @@ -975,25 +1023,283 @@ import_local_data=function() { input[0].click(); } +/****************************** + * Authentication + ******************************/ + +show_user=function() { + if (user.connected()) { + $('#login').parent().css('display', 'none'); + $('#logout').parent().css('display', 'block'); + $('#username').html(user.name); + } + else { + $('#login').parent().css('display', 'block'); + $('#logout').parent().css('display', 'none'); + $('#username').html(""); + } +} + +on_login_button_click=function() { + if (user.connected()) return; + navbar_collapse_hide(); + $('#login_modal').modal('show'); +} + +on_valid_login_modal=function (e) { + e.preventDefault(); + var username=$('#login_username').val(); + var password=$('#login_password').val(); + if (!username || !password) { + alertify.notify("Vous devez saisir votre nom d'utilisateur et votre mot de passe !", "error", 5); + return; + } + $.post( + 'login', + { + username: username, + password: password, + } + ).done(function(data) { + if (data.username && data.token) { + user.token = data.token; + user.username = data.username; + user.name = data.name?data.name:username; + user.save(); + $('#login_modal').modal('hide'); + show_user(); + alertify.notify("Connecté.", "success", 3); + propose_sync_local_data(); + } + else { + alertify.notify("Nom d'utilisateur ou mot de passe.", "error", 5); + } + }).fail(function() { + alertify.notify('Une erreur est survenue en vous identifiant. Merci de réessayer ultèrieument.', "error", 5); + }); +} + +on_show_login_modal=function () { + $('#login_username').val(user.username?user.username:""); + if (user.username) + $('#login_password').focus(); + else + $('#login_username').focus(); +} + +on_close_login_modal=function () { + $('#login_modal form')[0].reset(); +} + +on_logout_button_click=function() { + if (!user.connected()) return; + navbar_collapse_hide(); + alertify.confirm( + "Déconnexion", + "Voulez-vous vraiment vous déconnecter ?", + function(data) { + $.post( + 'logout', + { + username: user.username, + token: user.token, + } + ).done(function(data) { + if (data.success) { + user.reset(); + user.save(); + show_user(); + alertify.notify("Déconnecté.", "success", 3); + } + else { + alertify.notify("Une erreur est survenue en vous déconnectant. Merci de réessayer ultèrieument.", "error", 5); + } + }).fail(function() { + alertify.notify('Une erreur est survenue en vous déconnectant. Merci de réessayer ultèrieument.', "error", 5); + }); + }, + null + ); +} + +is_connected=function() { + return user && user.token; +} + + +/******************************* + * Sync local data with server + *******************************/ +sync_local_data = function(callback) { + if (!is_connected()) { + if (callback) callback(false); + return; + } + $.post( + 'sync', + { + username: user.username, + token: user.token, + data: JSON.stringify(scases.export()), + } + ).done(function(data) { + if (data.scases) { + var backData=scases.export(); + scases=new SCaseList(); + if (scases.loadFromJsonData(data)) { + scases.save(); + alertify.notify("Données synchronisées.", "success", 3); + if (callback) callback(true); + } + else { + alertify.notify("Une erreur est survenue en chargeant les données issues du serveur. Restauration des données précédentes...", "error", 5); + if (scases.loadFromJsonData(backData)) { + alertify.notify("Les données précédentes ont bien été restaurées.", "success", 5); + } + else { + alertify.notify("Une erreur est survenue en restaurant les données précédentes.", "error", 5); + } + if (callback) callback(false); + } + } + else { + alertify.notify("Une erreur est survenue en synchronisant vos données. Merci de réessayer ultèrieument.", "error", 5); + if (callback) callback(false); + } + }).fail(function(xhr, status, error) { + if (xhr.status == 401) { + user.token = null; + user.save(); + show_user(); + alertify.notify("Votre session semble avoir expirée, merci de vous réauthentifier.", "error", 8); + $('#login_modal').modal('show'); + } + else { + alertify.notify("Une erreur est survenue en synchronisant vos données. Merci de réessayer ultèrieument.", "error", 5); + } + if (callback) callback(false); + }); +} +_auto_sync_timeout = false; +auto_sync_local_data = function() { + if (_auto_sync_timeout) clearTimeout(_auto_sync_timeout); + if (!is_connected() || !navigator.onLine) return; + _auto_sync_timeout = setTimeout(sync_local_data, 3000); +} +on_sync_local_data_btn_click=function() { + navbar_collapse_hide(); + if (!is_connected()) { + alertify.notify("Vous devez vous connecter avant de pouvoir synchroniser vos données.", "error", 5); + $('#login_modal').modal('show'); + return; + } + pleaseWaitShow(); + sync_local_data(function(success) { + pleaseWaitHide(); + }); +} +propose_sync_local_data = function() { + alertify.confirm( + "Synchronisation de vos valises depuis le serveur", + "Voulez-vous synchroniser vos valises depuis le serveur ?", + function() { + pleaseWaitShow(); + sync_local_data( + function(success) { + pleaseWaitHide(); + if (!success) return; + if (scases.count() == 0) + propose_example_data(); + else + refresh_location(); + } + ); + }, + refresh_location + ); +} + +/*********************** + * Manage location hash + ***********************/ + +set_location = function(scase, cat, detail) { + console.log(`set_location(${scase}, ${cat}, ${detail})`); + var parts = []; + if (scase) parts[0] = scase; + if (cat) parts[1] = cat; + if (detail) parts[2] = detail; + location.hash = parts.join("|"); +} + +parse_location = function(value) { + value = typeof value == "undefined" ? location.hash : value; + console.log(`parse_location(${value})`); + parts = ( + typeof value == "undefined" ? location.hash : value + ).split("|"); + return { + scase: parts[0]?decodeURI(parts[0].substring(1)):null, + cat: parts[0] && parts[1]?decodeURI(parts[1]):null, + detail: parts[2] ? decodeURI(parts[2]) : null, + }; +} + +refresh_location = function() { + var info = parse_location(); + console.log(`refresh_location(${info.scase}, ${info.cat}, ${info.detail})`); + var scase = info.scase ? scases.byName(info.scase) : null; + if (!scase) + show_scases(); + else if (info.detail == 'trash') + show_scase_trash(scase, info.cat); + else + show_scase(scase, info.cat); +} + +/*********************** + * Welcome modal + ***********************/ + +on_welcome_connect_click = function() { + $('#welcome_modal').modal('hide'); + $('#login_modal').modal('show'); +} + +on_welcome_annonymous_click = function() { + $('#welcome_modal').modal('hide'); + if (scases.count() == 0) propose_example_data(); +} + +propose_example_data = function() { + alertify.confirm( + "Un exemple de valise ?", + "Souhaitez-vous charger un exemple de valise pour commencer ?", + function() { + scases.importExampleData(); + scases.save(); + refresh_location(); + }, + null, + ); +} + /********************* * Activate *********************/ $( document ).ready( function() { - pleaseWaitShow(); - if(typeof(localStorage)!=="undefined"){ - scases=new SCaseList(); - scases.loadFromLocalStorage(); - show_scases(); - } - else { - alertify.notify('Local storage not supported !', "error", 5); - pleaseWaitHide(); + if(typeof(localStorage)==="undefined"){ + alertify.notify( + 'Votre navigateur internet ne support pas le stockage de données locale. Vous ne pouvez donc malheureusment pas utiliser cette application.', "error", 5); return; } + pleaseWaitShow(); $('#clear_local_data').bind('click',clear_local_data); + $('#load_example_data').bind('click',load_example_data); $('#import_local_data').bind('click',import_local_data); $('#export_local_data').bind('click',export_local_data); + $('#sync_local_data').bind('click',on_sync_local_data_btn_click); $('#add_scase_btn').bind('click',on_add_scase_btn_click); $('#add_scase_submit').bind('click',on_valid_add_scase_modal); @@ -1031,7 +1337,7 @@ $( document ).ready( function() { $("#rename_cat_modal form").bind('submit',on_valid_rename_cat_modal); $('#back_to_scases').bind('click',on_back_to_scases_btn_click); - + $('input.add_thing_label').bind('focus',on_add_thing_label_focus); $('#add_thing_submit').bind('click',on_valid_add_thing_modal); $("#add_thing_modal").on('shown.bs.modal',on_show_add_thing_modal); @@ -1043,7 +1349,50 @@ $( document ).ready( function() { $("#edit_thing_modal").on('hidden.bs.modal',on_close_edit_thing_modal); $("#edit_thing_modal form").bind('submit',on_valid_edit_thing_modal); + $('#login').bind('click',on_login_button_click); + $('#logout').bind('click',on_logout_button_click); + $('#login_submit').bind('click',on_valid_login_modal); + $("#login_modal").on('shown.bs.modal',on_show_login_modal); + $("#login_modal").on('hidden.bs.modal',on_close_login_modal); + $("#login_modal form").bind('submit',on_valid_login_modal); + + $('#welcome_connect').bind('click',on_welcome_connect_click); + $('#welcome_annonymous').bind('click',on_welcome_annonymous_click); + $('#app-name').bind('click', show_scases); - pleaseWaitHide(); + user=new User(); + user.loadFromLocalStorage(); + show_user(); + + scases=new SCaseList(); + switch(scases.loadFromLocalStorage()) { + case null: + pleaseWaitHide(); + if (is_connected()) + propose_sync_local_data(); + else + $('#welcome_modal').modal('show'); + break; + + case false: + alertify.confirm( + "Erreur en chargeant les données locales", + 'Une erreur est survenue en chargeant les données locales. On les purges ?', + function(data) { + delete localStorage.scases; + location.reload(); + }, + function(data) { + pleaseWaitHide(); + location.reload(); + }, + ); + break; + + case true: + refresh_location(); + pleaseWaitHide(); + break; + } }); diff --git a/static/mysc_objects.js b/static/mysc_objects.js index 0c5b7d9..8b10662 100644 --- a/static/mysc_objects.js +++ b/static/mysc_objects.js @@ -35,51 +35,34 @@ function SCaseList() { } } - this.loadFromLocalStorage=function(backData) { + this.loadFromLocalStorage=function(data) { if (jQuery.type(localStorage.scases)!='undefined') { try { - var data=JSON.parse(localStorage.scases); - this.lastChange=data.lastChange; - for (el in data.scases) { - this[el]=new SCase(false,false,data.scases[el]); - } + return this.loadFromJsonData(JSON.parse(localStorage.scases)); } catch(e) { - for (el in this) { - if (this.isSCase(this[el])) { - delete this[el]; - } - } - if (jQuery.type(backData)!='undefined') { - alert('Erreur en chargeant les données. Restauration des données précédentes'); - localStorage.scases=backData; - return this.loadFromLocalStorage(); - } - else { - alertify.confirm( - "Erreur en chargeant les données locales", - 'Une erreur est survenue en chargeant les données locales. On les purges ?', - function(data) { - delete localStorage.scases; - location.reload(); - }, - null - ); + return false; + } + } + return null; + } + + this.loadFromJsonData=function(data) { + try { + this.lastChange=data.lastChange; + for (el in data.scases) { + this[el]=new SCase(false,false,data.scases[el]); + } + return true; + } + catch(e) { + for (el in this) { + if (this.isSCase(this[el])) { + delete this[el]; } } } - else { - alertify.confirm( - "Bienvenu", - "

Bienvenu !

Souhaitez-vous charger les données d'exemple ?

", - function() { - this.importExampleData(); - this.save(); - show_scases(); - }.bind(this), - null, - ); - } + return false; } this.export=function() { @@ -143,6 +126,10 @@ function SCaseList() { return false; } + this.byUUID=function(uuid) { + return this.isCase(this[uuid]) ? this[uuid] : null; + } + this.removeSCase=function(name) { for (el in this) { if (this.isSCase(this[el]) && this[el].name==name) { @@ -361,6 +348,10 @@ function CatList(data) { return false; } + this.byUUID=function(uuid) { + return this.isCas(this[uuid]) ? this[uuid] : null; + } + this.newCat=function(name) { if (this.byName(name)) { var cat=this.byName(name); @@ -606,6 +597,7 @@ function Thing(uuid,label,nb,checked) { this.setChecked=function(value) { this.checked=value; this.lastChange=new Date().getTime(); + console.log(`Thing<${this.uuid}>.setChecked(${this.checked}): ${this.lastChange}`); } this.remove=function() { @@ -618,3 +610,40 @@ function Thing(uuid,label,nb,checked) { this.lastChange=new Date().getTime(); } } + +function User() { + this.username = null; + this.name = null; + this.token = null; + this.loadFromLocalStorage=function() { + if (jQuery.type(localStorage.user) == 'undefined') + return; + try { + var data=JSON.parse(localStorage.user); + this.username = data.username; + this.name = data.name; + this.token = data.token; + } + catch(e) { + alert('Erreur en chargeant vos informations de connexion. Merci de vous reconnecter.'); + } + }; + + this.connected=function() { + return this.username && this.token; + } + + this.reset=function() { + this.username = null; + this.name = null; + this.token = null; + } + + this.save=function() { + localStorage.user = JSON.stringify({ + username: this.username, + name: this.name, + token: this.token, + }); + } +} diff --git a/templates/index.tpl b/templates/index.tpl index ce5e6bb..227eb9f 100644 --- a/templates/index.tpl +++ b/templates/index.tpl @@ -34,7 +34,7 @@ MySC -