commit bcc26040806ae752d3ba8d7363a476452e5f45f8 Author: ilhooq Date: Mon Sep 9 10:22:45 2024 +0200 Premier commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01c27ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor +/web/assets +/web/manifest.json +deploy.sh +env.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..5609f4a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# AI-UI Interface pour LLMS + + diff --git a/cli/init_assistant.php b/cli/init_assistant.php new file mode 100644 index 0000000..dc8578a --- /dev/null +++ b/cli/init_assistant.php @@ -0,0 +1,34 @@ + $val) { + putenv("{$key}={$val}"); +} + +$db = new PDO('sqlite:' . getenv('SQLITE_DB')); + + +$query =<<exec($query) === false) { + $error = $db->errorInfo(); + throw new RuntimeException("Query failed with error : {$error[2]}"); +} + +echo "Assistant table created.\n"; diff --git a/cli/init_user.php b/cli/init_user.php new file mode 100644 index 0000000..ad2fc12 --- /dev/null +++ b/cli/init_user.php @@ -0,0 +1,52 @@ + $val) { + putenv("{$key}={$val}"); +} + +$db = new PDO('sqlite:' . getenv('SQLITE_DB')); + +Rbac::$db = $db; + +$query = file_get_contents(__DIR__ . '/../modules/user/sql/install-sqlite.sql'); + +if ($db->exec($query) === false) { + $error = $db->errorInfo(); + throw new RuntimeException("Query failed with error : {$error[2]}"); +} + +echo "Users table created.\n"; + +echo "Create admin user\n"; + +$name = readline("Nom : "); +$email = readline("Email : "); +$username = readline("Nom d'utilisateur : "); +$password = readline("Mot de passe : "); + +$user = new User($db); +$user->bind([ + 'name' => $name, + 'username' => $username, + 'email' => $email, + 'password' => $password, +]); + +if ($user->save()) { + echo "Utilisateur $username créé.\n"; +} + +if (!Rbac::roleExists('admin')) { + Rbac::createRole('admin'); +} + +Rbac::assignRole($user->id, 'admin'); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0fd2a96 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "piko/openai", + "description": "Application to interact with Openai GPT-3.", + "type": "project", + "license": "MIT", + "require": { + "piko/framework": "^3.0", + "piko/user": "^2.0", + "tectalic/openai": "^1.2", + "guzzlehttp/guzzle": "^7.5", + "tracy/tracy": "^2.9", + "monolog/monolog": "^2.8", + "rahul900day/gpt-3-encoder": "^1.1", + "piko/db-record": "^2.0", + "nette/mail": "^4.0", + "piko/i18n": "^2.1" + }, + "autoload": { + "psr-4": { + "app\\": "" + } + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..515cac6 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2231 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "b5b9ae6af8ec6174a76792624485b541", + "packages": [ + { + "name": "clue/stream-filter", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/clue/stream-filter.git", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7", + "reference": "049509fef80032cb3f051595029ab75b49a3c2f7", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "Clue\\StreamFilter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "A simple and modern approach to stream filtering in PHP", + "homepage": "https://github.com/clue/stream-filter", + "keywords": [ + "bucket brigade", + "callback", + "filter", + "php_user_filter", + "stream", + "stream_filter_append", + "stream_filter_register" + ], + "support": { + "issues": "https://github.com/clue/stream-filter/issues", + "source": "https://github.com/clue/stream-filter/tree/v1.7.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2023-12-20T15:40:13+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.9.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "d281ed313b989f213357e3be1a179f02196ac99b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", + "reference": "d281ed313b989f213357e3be1a179f02196ac99b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.9.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2024-07-24T11:22:20+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", + "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2024-07-18T10:29:17+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2024-07-18T11:15:46+00:00" + }, + { + "name": "httpsoft/http-message", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/httpsoft/http-message.git", + "reference": "f6c88e2189b9f75f10dfaeb0a85c56ea04a53c19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/httpsoft/http-message/zipball/f6c88e2189b9f75f10dfaeb0a85c56ea04a53c19", + "reference": "f6c88e2189b9f75f10dfaeb0a85c56ea04a53c19", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "php-http/psr7-integration-tests": "^1.3", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "^4.9|^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "HttpSoft\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Evgeniy Zyubin", + "email": "mail@devanych.ru", + "homepage": "https://devanych.ru/", + "role": "Founder and lead developer" + } + ], + "description": "Strict and fast implementation of PSR-7 and PSR-17", + "homepage": "https://httpsoft.org/", + "keywords": [ + "http", + "http-message", + "php", + "psr-17", + "psr-7" + ], + "support": { + "docs": "https://httpsoft.org/docs/message", + "issues": "https://github.com/httpsoft/http-message/issues", + "source": "https://github.com/httpsoft/http-message" + }, + "time": "2024-08-09T07:13:21+00:00" + }, + { + "name": "httpsoft/http-server-request", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/httpsoft/http-server-request.git", + "reference": "3d773c8bcaa1c44793d35842fcd82a9d5fd5f193" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/httpsoft/http-server-request/zipball/3d773c8bcaa1c44793d35842fcd82a9d5fd5f193", + "reference": "3d773c8bcaa1c44793d35842fcd82a9d5fd5f193", + "shasum": "" + }, + "require": { + "httpsoft/http-message": "^1.1", + "php": "^7.4|^8.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "^4.9|^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "HttpSoft\\ServerRequest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Evgeniy Zyubin", + "email": "mail@devanych.ru", + "homepage": "https://devanych.ru/", + "role": "Founder and lead developer" + } + ], + "description": "Infrastructure for creating PSR-7 ServerRequest and UploadedFile", + "homepage": "https://httpsoft.org/", + "keywords": [ + "http", + "http-message", + "http-server-request", + "php", + "psr-7" + ], + "support": { + "docs": "https://httpsoft.org/docs/server-request", + "issues": "https://github.com/httpsoft/http-server-request/issues", + "source": "https://github.com/httpsoft/http-server-request" + }, + "time": "2023-05-05T19:55:05+00:00" + }, + { + "name": "monolog/monolog", + "version": "2.9.3", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "a30bfe2e142720dfa990d0a7e573997f5d884215" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/a30bfe2e142720dfa990d0a7e573997f5d884215", + "reference": "a30bfe2e142720dfa990d0a7e573997f5d884215", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2@dev", + "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "phpspec/prophecy": "^1.15", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.38 || ^9.6.19", + "predis/predis": "^1.1 || ^2.0", + "rollbar/rollbar": "^1.3 || ^2 || ^3", + "ruflin/elastica": "^7", + "swiftmailer/swiftmailer": "^5.3|^6.0", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/2.9.3" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2024-04-12T20:52:51+00:00" + }, + { + "name": "nette/mail", + "version": "v4.0.2", + "source": { + "type": "git", + "url": "https://github.com/nette/mail.git", + "reference": "c0b81124284bee573ee968de98fe3dcf2c2a9b5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/mail/zipball/c0b81124284bee573ee968de98fe3dcf2c2a9b5e", + "reference": "c0b81124284bee573ee968de98fe3dcf2c2a9b5e", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "nette/utils": "^4.0", + "php": "8.0 - 8.3" + }, + "require-dev": { + "nette/di": "^3.1 || ^4.0", + "nette/tester": "^2.4", + "phpstan/phpstan-nette": "^1.0", + "tracy/tracy": "^2.8" + }, + "suggest": { + "ext-fileinfo": "to detect type of attached files", + "ext-openssl": "to use Nette\\Mail\\DkimSigner" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📧 Nette Mail: handy email creation and transfer library for PHP with both text and MIME-compliant support.", + "homepage": "https://nette.org", + "keywords": [ + "mail", + "mailer", + "mime", + "nette", + "smtp" + ], + "support": { + "issues": "https://github.com/nette/mail/issues", + "source": "https://github.com/nette/mail/tree/v4.0.2" + }, + "time": "2023-10-02T20:59:33+00:00" + }, + { + "name": "nette/utils", + "version": "v4.0.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/736c567e257dbe0fcf6ce81b4d6dbe05c6899f96", + "reference": "736c567e257dbe0fcf6ce81b4d6dbe05c6899f96", + "shasum": "" + }, + "require": { + "php": "8.0 - 8.4" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "dev-master", + "nette/tester": "^2.5", + "phpstan/phpstan": "^1.0", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.0.5" + }, + "time": "2024-08-07T15:39:19+00:00" + }, + { + "name": "nyholm/psr7", + "version": "1.8.1", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "aa5fc277a4f5508013d571341ade0c3886d4d00e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/aa5fc277a4f5508013d571341ade0c3886d4d00e", + "reference": "aa5fc277a4f5508013d571341ade0c3886d4d00e", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.1" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2023-11-13T09:31:12+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.19.4", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "0700efda8d7526335132360167315fdab3aeb599" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/0700efda8d7526335132360167315fdab3aeb599", + "reference": "0700efda8d7526335132360167315fdab3aeb599", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.19.4" + }, + "time": "2024-03-29T13:00:05+00:00" + }, + { + "name": "php-http/message", + "version": "1.16.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/message.git", + "reference": "5997f3289332c699fa2545c427826272498a2088" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message/zipball/5997f3289332c699fa2545c427826272498a2088", + "reference": "5997f3289332c699fa2545c427826272498a2088", + "shasum": "" + }, + "require": { + "clue/stream-filter": "^1.5", + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.6", + "ext-zlib": "*", + "guzzlehttp/psr7": "^1.0 || ^2.0", + "laminas/laminas-diactoros": "^2.0 || ^3.0", + "php-http/message-factory": "^1.0.2", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "slim/slim": "^3.0" + }, + "suggest": { + "ext-zlib": "Used with compressor/decompressor streams", + "guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories", + "laminas/laminas-diactoros": "Used with Diactoros Factories", + "slim/slim": "Used with Slim Framework PSR-7 implementation" + }, + "type": "library", + "autoload": { + "files": [ + "src/filters.php" + ], + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "HTTP Message related tools", + "homepage": "http://php-http.org", + "keywords": [ + "http", + "message", + "psr-7" + ], + "support": { + "issues": "https://github.com/php-http/message/issues", + "source": "https://github.com/php-http/message/tree/1.16.1" + }, + "time": "2024-03-07T13:22:09+00:00" + }, + { + "name": "php-http/multipart-stream-builder", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/multipart-stream-builder.git", + "reference": "ed56da23b95949ae4747378bed8a5b61a2fdae24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/multipart-stream-builder/zipball/ed56da23b95949ae4747378bed8a5b61a2fdae24", + "reference": "ed56da23b95949ae4747378bed8a5b61a2fdae24", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/discovery": "^1.15", + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "nyholm/psr7": "^1.0", + "php-http/message": "^1.5", + "php-http/message-factory": "^1.0.2", + "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Message\\MultipartStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + } + ], + "description": "A builder class that help you create a multipart stream", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "multipart stream", + "stream" + ], + "support": { + "issues": "https://github.com/php-http/multipart-stream-builder/issues", + "source": "https://github.com/php-http/multipart-stream-builder/tree/1.3.1" + }, + "time": "2024-06-10T14:51:55+00:00" + }, + { + "name": "piko/core", + "version": "v2.4", + "source": { + "type": "git", + "url": "https://github.com/piko-framework/core.git", + "reference": "6d9703700c99523c60dfaca88e27c458b2406bfb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/piko-framework/core/zipball/6d9703700c99523c60dfaca88e27c458b2406bfb", + "reference": "6d9703700c99523c60dfaca88e27c458b2406bfb", + "shasum": "" + }, + "require": { + "php": ">=7.1.0", + "piko/event-dispatcher": "^1.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/Piko.php" + ], + "psr-4": { + "Piko\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Sylvain Philip", + "homepage": "https://www.sphilip.com", + "role": "Developer" + } + ], + "description": "Piko framework base", + "homepage": "https://github.com/piko-framework/core", + "keywords": [ + "framework", + "micro", + "micro-framework", + "mvc" + ], + "support": { + "issues": "https://github.com/piko-framework/core/issues", + "source": "https://github.com/piko-framework/core/tree/v2.4" + }, + "time": "2022-11-09T10:05:05+00:00" + }, + { + "name": "piko/db-record", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/piko-framework/db-record.git", + "reference": "1d08d7247291e946ce8dc05653bcd345d95ba790" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/piko-framework/db-record/zipball/1d08d7247291e946ce8dc05653bcd345d95ba790", + "reference": "1d08d7247291e946ce8dc05653bcd345d95ba790", + "shasum": "" + }, + "require": { + "php": ">=7.1.0", + "piko/core": "^2.2" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Piko\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Sylvain Philip", + "homepage": "https://www.sphilip.com", + "role": "Developer" + } + ], + "description": "A lightweight Active Record helper built on top of PDO.", + "homepage": "https://github.com/piko-framework/db-record", + "keywords": [ + "Active Record", + "database", + "record", + "sql" + ], + "support": { + "issues": "https://github.com/piko-framework/db-record/issues", + "source": "https://github.com/piko-framework/db-record/tree/v2.0.2" + }, + "time": "2023-10-18T16:20:38+00:00" + }, + { + "name": "piko/event-dispatcher", + "version": "v1.1", + "source": { + "type": "git", + "url": "https://github.com/piko-framework/event-dispatcher.git", + "reference": "f9ef6d608d6d49ab3d959ba5ad2dc97ed35e7c30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/piko-framework/event-dispatcher/zipball/f9ef6d608d6d49ab3d959ba5ad2dc97ed35e7c30", + "reference": "f9ef6d608d6d49ab3d959ba5ad2dc97ed35e7c30", + "shasum": "" + }, + "require": { + "php": ">=7.1.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Piko\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sylvain Philip", + "homepage": "https://www.sphilip.com", + "role": "Developer" + } + ], + "description": "A basic PSR-14 implementation using a priority queue", + "homepage": "https://github.com/piko-framework/event-dispatcher", + "keywords": [ + "event", + "event-dispatcher", + "psr-14" + ], + "support": { + "issues": "https://github.com/piko-framework/event-dispatcher/issues", + "source": "https://github.com/piko-framework/event-dispatcher/tree/v1.1" + }, + "time": "2022-11-13T19:35:38+00:00" + }, + { + "name": "piko/framework", + "version": "v3.4.5", + "source": { + "type": "git", + "url": "https://github.com/piko-framework/piko.git", + "reference": "e62cea654a38605e7973aaa4239ca96aa958fad1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/piko-framework/piko/zipball/e62cea654a38605e7973aaa4239ca96aa958fad1", + "reference": "e62cea654a38605e7973aaa4239ca96aa958fad1", + "shasum": "" + }, + "require": { + "httpsoft/http-server-request": "^1.0", + "php": ">=7.1.0", + "piko/core": "^2.2", + "piko/router": "^3.1", + "psr/http-server-middleware": "^1.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Piko\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Sylvain Philip", + "homepage": "https://www.sphilip.com", + "role": "Developer" + } + ], + "description": "Ultra lighweight MVC web framework", + "homepage": "https://github.com/piko-framework/piko", + "keywords": [ + "framework", + "micro", + "micro-framework", + "mvc" + ], + "support": { + "issues": "https://github.com/piko-framework/piko/issues", + "source": "https://github.com/piko-framework/piko/tree/v3.4.5" + }, + "time": "2023-11-08T12:41:23+00:00" + }, + { + "name": "piko/i18n", + "version": "v2.1", + "source": { + "type": "git", + "url": "https://github.com/piko-framework/i18n.git", + "reference": "df264e11829514c9cf8348990d9728435feb5650" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/piko-framework/i18n/zipball/df264e11829514c9cf8348990d9728435feb5650", + "reference": "df264e11829514c9cf8348990d9728435feb5650", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "piko/core": "^2.2" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/I18n/functions.php" + ], + "psr-4": { + "Piko\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Sylvain Philip", + "homepage": "https://www.sphilip.com", + "role": "Developer" + } + ], + "description": "A minimal internationalization component that can be used in a piko application or standalone.", + "homepage": "https://github.com/piko-framework/i18n", + "keywords": [ + "i18n", + "internationalization", + "language", + "translation" + ], + "support": { + "issues": "https://github.com/piko-framework/i18n/issues", + "source": "https://github.com/piko-framework/i18n/tree/v2.1" + }, + "time": "2022-11-13T21:40:28+00:00" + }, + { + "name": "piko/router", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/piko-framework/router.git", + "reference": "5569784f34f6dcfc2db1ae403c253342a2b5d6ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/piko-framework/router/zipball/5569784f34f6dcfc2db1ae403c253342a2b5d6ef", + "reference": "5569784f34f6dcfc2db1ae403c253342a2b5d6ef", + "shasum": "" + }, + "require": { + "php": ">=7.1.0", + "piko/core": "^2.1" + }, + "require-dev": { + "nikic/fast-route": "^1.3", + "phpbench/phpbench": "^1.1", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5", + "symfony/config": "^5.3", + "symfony/routing": "^5.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Piko\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Sylvain Philip", + "homepage": "https://www.sphilip.com", + "role": "Developer" + } + ], + "description": "One of the fastest PHP router, using a radix trie to retrieve routes", + "homepage": "https://github.com/piko-framework/router", + "keywords": [ + "micro", + "micro-router", + "radix", + "router" + ], + "support": { + "issues": "https://github.com/piko-framework/router/issues", + "source": "https://github.com/piko-framework/router/tree/v3.1.1" + }, + "time": "2023-03-04T15:14:30+00:00" + }, + { + "name": "piko/user", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/piko-framework/user.git", + "reference": "66ab638e02a3c8982a9284f979ff9872d13a3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/piko-framework/user/zipball/66ab638e02a3c8982a9284f979ff9872d13a3c3c", + "reference": "66ab638e02a3c8982a9284f979ff9872d13a3c3c", + "shasum": "" + }, + "require": { + "php": ">=7.1.0", + "piko/core": "^2.2" + }, + "require-dev": { + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Piko\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Sylvain Philip", + "homepage": "https://www.sphilip.com", + "role": "Developer" + } + ], + "description": "A lightweight user session manager to login/logout and retrieve user identity.", + "homepage": "https://github.com/piko-framework/user", + "keywords": [ + "login", + "logout", + "piko", + "session", + "user" + ], + "support": { + "issues": "https://github.com/piko-framework/user/issues", + "source": "https://github.com/piko-framework/user/tree/v2.0.1" + }, + "time": "2022-11-04T14:33:39+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, + { + "name": "psr/log", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001", + "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.0" + }, + "time": "2021-07-14T16:46:02+00:00" + }, + { + "name": "rahul900day/gpt-3-encoder", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/RahulDey12/gpt-3-encoder.git", + "reference": "4c3c706a0ddcad137d0f9d592d141d0a23fa2502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/RahulDey12/gpt-3-encoder/zipball/4c3c706a0ddcad137d0f9d592d141d0a23fa2502", + "reference": "4c3c706a0ddcad137d0f9d592d141d0a23fa2502", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "pestphp/pest": "^1.22", + "phpstan/phpstan": "^1.10", + "rector/rector": "^0.15.21", + "symfony/var-dumper": "^5.4 || ^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Rahul900day\\Gpt3Encoder\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rahul Dey", + "email": "rahul900day@gmail.com" + } + ], + "description": "GPT-3-Encoder is a PHP implementation of OpenAI's original python encoder/decoder.", + "keywords": [ + "GPT-2-Encoder", + "GPT-2-Tokenizer", + "GPT-3", + "GPT-3-Encoder", + "GPT-3-Tokenizer", + "decoder", + "encoder", + "gpt-2", + "php", + "tokenizer" + ], + "support": { + "issues": "https://github.com/RahulDey12/gpt-3-encoder/issues", + "source": "https://github.com/RahulDey12/gpt-3-encoder/tree/1.1.0" + }, + "funding": [ + { + "url": "https://ko-fi.com/rahul900day", + "type": "ko_fi" + } + ], + "abandoned": "rahul900day/tiktoken-php", + "time": "2023-03-22T20:12:00+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "spatie/data-transfer-object", + "version": "1.14.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/data-transfer-object.git", + "reference": "12c25e15f08684f1c57c88ccfb3a38a677a11314" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/data-transfer-object/zipball/12c25e15f08684f1c57c88ccfb3a38a677a11314", + "reference": "12c25e15f08684f1c57c88ccfb3a38a677a11314", + "shasum": "" + }, + "require": { + "php": "^7.1|^8.0" + }, + "require-dev": { + "larapack/dd": "^1.1", + "phpunit/phpunit": "^7.0|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\DataTransferObject\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brent Roose", + "email": "brent@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Data transfer objects with batteries included", + "homepage": "https://github.com/spatie/data-transfer-object", + "keywords": [ + "data-transfer-object", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/data-transfer-object/issues", + "source": "https://github.com/spatie/data-transfer-object/tree/1.14.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "abandoned": "spatie/laravel-data", + "time": "2021-12-15T07:25:06+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.5.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-04-18T09:32:20+00:00" + }, + { + "name": "tectalic/openai", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/tectalichq/public-openai-client-php.git", + "reference": "aa321d55bdd01511515e7b3436c092325fa651f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tectalichq/public-openai-client-php/zipball/aa321d55bdd01511515e7b3436c092325fa651f4", + "reference": "aa321d55bdd01511515e7b3436c092325fa651f4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nyholm/psr7": "^1.4", + "php": "^7.2.5 || ^8.0", + "php-http/message": "^1.11", + "php-http/multipart-stream-builder": "^1.2", + "psr/http-client": "^1.0.1", + "spatie/data-transfer-object": "^1.14" + }, + "conflict": { + "cebe/php-openapi": "<1.7", + "riverline/multipart-parser": "<2.0.9", + "symfony/yaml": "<3.4.31 || >4.0 <4.3.4" + }, + "require-dev": { + "league/openapi-psr7-validator": "^0.17.0", + "mikey179/vfsstream": "^1.6.10", + "php-http/mock-client": "^1.5", + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^8.5.14 || ^9.5 || ^10.0", + "squizlabs/php_codesniffer": "^3.6", + "symfony/http-client": "^5.3" + }, + "suggest": { + "php-http/mock-client": "Simplify testing by using a mock HTTP client" + }, + "type": "library", + "autoload": { + "psr-4": { + "Tectalic\\OpenAi\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "An OpenAI REST API Client with support for ChatGPT, GPT-4, GPT-3.5, GPT-3, Codex, DALL·E, Whisper, Embeddings, Fine-Tuning and Moderation models. Includes fully typed Data Transfer Objects (DTOs) for all requests and responses and IDE autocomplete support.", + "homepage": "https://tectalic.com/apis/openai", + "keywords": [ + "ChatGpt", + "GPT-3", + "ai", + "api", + "dall-e", + "dalle", + "fine-tuning", + "gpt-3.5", + "gpt-4", + "gpt3", + "gpt3.5", + "gpt4", + "openai", + "rest", + "tectalic", + "whisper" + ], + "support": { + "source": "https://github.com/tectalichq/public-openai-client-php/tree/v1.6.0" + }, + "abandoned": true, + "time": "2023-09-06T02:15:29+00:00" + }, + { + "name": "tracy/tracy", + "version": "v2.10.8", + "source": { + "type": "git", + "url": "https://github.com/nette/tracy.git", + "reference": "0e0f3312708fb9c179a92072ebacc24aeee7e2e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/tracy/zipball/0e0f3312708fb9c179a92072ebacc24aeee7e2e8", + "reference": "0e0f3312708fb9c179a92072ebacc24aeee7e2e8", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-session": "*", + "php": "8.0 - 8.4" + }, + "conflict": { + "nette/di": "<3.0" + }, + "require-dev": { + "latte/latte": "^2.5 || ^3.0", + "nette/di": "^3.0", + "nette/http": "^3.0", + "nette/mail": "^3.0 || ^4.0", + "nette/tester": "^2.2", + "nette/utils": "^3.0 || ^4.0", + "phpstan/phpstan": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.10-dev" + } + }, + "autoload": { + "files": [ + "src/Tracy/functions.php" + ], + "classmap": [ + "src" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "😎 Tracy: the addictive tool to ease debugging PHP code for cool developers. Friendly design, logging, profiler, advanced features like debugging AJAX calls or CLI support. You will love it.", + "homepage": "https://tracy.nette.org", + "keywords": [ + "Xdebug", + "debug", + "debugger", + "nette", + "profiler" + ], + "support": { + "issues": "https://github.com/nette/tracy/issues", + "source": "https://github.com/nette/tracy/tree/v2.10.8" + }, + "time": "2024-08-07T02:04:53+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..07090f5 --- /dev/null +++ b/config/app.php @@ -0,0 +1,58 @@ + realpath(__DIR__ . '/../'), + 'defaultLayoutPath' => '@app/modules/site/layouts', + 'defaultLayout' => 'main', + 'errorRoute' => 'site/default/error', + 'language' => getenv('APP_LANGUAGE'), + 'components' => [ + 'Piko\View' => [ + 'themeMap' => [ + '@app/modules/user/views' => '@app/overrides/user/views', + ], + ], + 'Piko\Router' => [ + 'construct' => [ + [ + 'routes' => require __DIR__ . '/routes.php', + ] + ] + ], + 'Piko\User' => [ + 'identityClass' => 'app\overrides\user\models\User', + 'checkAccess' => 'app\modules\user\AccessChecker::checkAccess' + ], + 'Monolog\Logger' => function() { + // create a log channel + $logger = new Logger('app'); + $level = getenv('APP_DEBUG') ? Logger::DEBUG : Logger::ERROR; + $logger->pushHandler(new StreamHandler( __DIR__ . '/../var/log/app.log', $level)); + + return $logger; + }, + 'PDO' => [ + 'construct' => [ + 'sqlite:' . getenv('SQLITE_DB') + ] + ], + 'Piko\I18n' => [ + 'language' => getenv('APP_LANGUAGE'), + 'translations' => [ + 'user' => '@app/modules/user/messages' + ] + ], + ], + 'modules' => [ + 'site' => 'app\modules\site\Module', + 'user' => [ + 'class' => 'app\modules\user\Module', + 'controllerMap' => [ + 'admin' => 'app\overrides\user\controllers\AdminController' + ] + ], + ], + 'bootstrap' => ['site', 'user'] +]; diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 0000000..8c3fa85 --- /dev/null +++ b/config/routes.php @@ -0,0 +1,17 @@ + 'site/assistant/index', + '/about' => 'site/default/about', + '/login' => 'user/default/login', + '/logout' => 'user/default/logout', + '/account' => 'user/default/edit', + '/contact' => 'site/default/contact', +]; diff --git a/lib/AuthMiddleware.php b/lib/AuthMiddleware.php new file mode 100644 index 0000000..953d319 --- /dev/null +++ b/lib/AuthMiddleware.php @@ -0,0 +1,51 @@ +application = $app; + + $pdo = $this->application->getComponent('PDO'); + assert($pdo instanceof PDO); + + User::$pdo = $pdo; + } + + /** + * {@inheritDoc} + * @see \Psr\Http\Server\MiddlewareInterface::process() + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $user = $this->application->getComponent('Piko\User'); + assert($user instanceof \Piko\User); + + $router = $this->application->getComponent('Piko\Router'); + assert($router instanceof \Piko\Router); + $loginUrl = $router->getUrl('user/default/login'); + + $params = $request->getServerParams(); + + if ($user->isGuest() && $params['REQUEST_URI'] != $loginUrl) { + + $response= new Response(); + + return $response->withHeader('Location', $loginUrl); + } + + return $handler->handle($request); + } +} diff --git a/lib/CorsMiddleware.php b/lib/CorsMiddleware.php new file mode 100644 index 0000000..d0f8de9 --- /dev/null +++ b/lib/CorsMiddleware.php @@ -0,0 +1,22 @@ +handle($request); + + return $response->withHeader('Access-Control-Allow-Origin', '*'); + } +} diff --git a/lib/Vite.php b/lib/Vite.php new file mode 100644 index 0000000..a5916d7 --- /dev/null +++ b/lib/Vite.php @@ -0,0 +1,107 @@ +'; + } + + private static function jsPreloadImports(string $entry): string + { + if (getenv('VITE_ENV') === 'dev') { + return ''; + } + + $res = ''; + + foreach (static::importsUrls($entry) as $url) { + $res .= ''; + } + + return $res; + } + + private static function cssTag(string $entry): string + { + // Not needed on dev, it's inject by Vite + if (getenv('VITE_ENV') === 'dev' || strpos($entry, '.css') === false) { + return ''; + } + + $url = static::assetUrl($entry); + + if (!$url) { + return ''; + } + + return ''; + } + + // Helpers to locate files + private static function getManifest(): array + { + $content = file_get_contents(Piko::getAlias('@webroot/manifest.json')); + + return json_decode($content, true); + } + + private static function assetUrl(string $entry): string + { + $manifest = static::getManifest(); + + return isset($manifest[$entry]) + ? Piko::getAlias('@web/' . $manifest[$entry]['file']) + : ''; + } + + private static function importsUrls(string $entry): array + { + $urls = []; + $manifest = static::getManifest(); + + if (!empty($manifest[$entry]['imports'])) { + foreach ($manifest[$entry]['imports'] as $imports) { + $urls[] = Piko::getAlias('@web/' . $manifest[$imports]['file']); + } + } + + return $urls; + } +} diff --git a/modules/site/Module.php b/modules/site/Module.php new file mode 100644 index 0000000..a63d186 --- /dev/null +++ b/modules/site/Module.php @@ -0,0 +1,43 @@ +application->getComponent('Piko\I18n'); + + $user = $this->application->getComponent('Piko\User'); + assert($user instanceof \Piko\User); + + // Pass some parameters to the View component + $view = $this->application->getComponent('Piko\View'); + $view->params['user'] = $user; + $view->params['language'] = $this->application->language; + $view->attachBehavior('vite', 'app\lib\Vite::vite'); + + $userModule = $this->application->getModule('user'); + assert ($userModule instanceof \app\modules\user\Module); + + $userModule->on(CreateControllerEvent::class, function(CreateControllerEvent $event) { + $event->controller->on(BeforeActionEvent::class, function (BeforeActionEvent $event) { + + $action = $event->actionId; + + switch($action) { + case 'login': + $event->controller->layout = 'minimal'; + break; + } + + }); + }); + } +} diff --git a/modules/site/controllers/AssistantController.php b/modules/site/controllers/AssistantController.php new file mode 100644 index 0000000..d62fb65 --- /dev/null +++ b/modules/site/controllers/AssistantController.php @@ -0,0 +1,338 @@ +module->getApplication(); + + $this->log = $app->getComponent(Logger::class); + assert($this->log instanceof Logger); + + $this->user = $app->getComponent('Piko\User'); + assert($this->user instanceof User); + + $this->db = $app->getComponent('PDO'); + assert($this->db instanceof PDO); + } + + protected function getUserApiKey() + { + $identity = $this->user->getIdentity(); + $apiKey = $identity->profil['api_key'] ?? null; + + if ($apiKey === null) { + $this->log->error('API key not defined for user ID :' . $this->user->getId()); + throw new HttpException(500, 'Internal error'); + } + + return $apiKey; + } + + private function proxyRequest(string $method, string $endPoint, array $params = []): array + { + $client = new Client([ + 'base_uri' => getenv('PROXY_BASE_URL'), + ]); + + /* + $identity = $this->user->getIdentity(); + $apiKey = $identity->profil['api_key'] ?? null; + + if ($apiKey === null) { + $this->log->error('API key not defined for user ID :' . $this->user->getId()); + throw new HttpException(500, 'Internal error'); + } + */ + + $apiKey = getenv('PROXY_MASTER_KEY'); + + $headers = [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Content-Type' => 'application/json', + ]; + + try { + $response = $client->request($method, $endPoint, [ + 'headers' => $headers, + 'json' => $params + ]); + } catch (RequestException $e) { + $response = $e->getResponse(); + $responseBody = (string) $response->getBody(); + $contentType = $response->getHeader('Content-Type'); + $errorCode = 500; + + if ($contentType[0] == 'application/json') { + $data = json_decode($responseBody); + + if (isset($data->error)) { + $responseBody = $data->error->message; + $errorCode = $data->error->code; + } + } + + $this->log->error('Chat request error', ['request_body' => (string) $e->getRequest()->getBody()]); + $this->log->error('Chat response error', ['response_body' => $responseBody]); + throw new HttpException($errorCode, $responseBody); + } + + $body = $response->getBody(); + + return [ + 'headers' => $response->getHeaders(), + 'body' => (string) $body + ]; + } + + private function getModels() + { + if (!isset($_SESSION['models'])) { + + $models = []; + $response = $this->proxyRequest('GET', '/models'); + + if (isset($response['body'])) { + $body = json_decode($response['body'], true); + $data = $body['data'] ?? []; + + foreach ($data as $obj) { + if (isset($obj['id'])) { + $models[] = $obj['id']; + } + } + } + + $_SESSION['models'] = $models; + } + + return $_SESSION['models']; + } + + /** + * Stream de la réponse + * + * Pour désactiver l'output buffering: + * php -d output_buffering=0 -S localhost:8080 -t web + * + * @return void + */ + public function responseAction() + { + $identity = $this->user->getIdentity(); + + $client = new Client([ + 'base_uri' => getenv('PROXY_BASE_URL'), + 'stream' => true, + ]); + + $headers = [ + 'Authorization' => 'Bearer ' . getenv('PROXY_KEY'), + 'Content-Type' => 'application/json', + ]; + + $data = json_decode((string) $this->request->getBody()); + + // bdump($data); + + try { + $response = $client->request('POST', '/chat/completions', [ + 'headers' => $headers, + 'stream' => true, + 'json' => [ + 'model' => $data->model, + 'messages' => $data->messages, + 'stream' => true, + 'temperature' => $data->temperature ?? 0, + 'top_p' => $config->top_p ?? 0, + 'user' => $identity->email + ] + ]); + } catch (RequestException $e) { + + header("Content-Type: text/event-stream;charset=UTF-8"); + $responseBody = (string) $e->getResponse()->getBody(); + echo 'data: ' . str_replace("\n", '', $responseBody) . "\n\n"; + $this->log->error('Chat request error', ['request_body' => (string) $e->getRequest()->getBody()]); + $this->log->error('Chat response error', ['response_body' => $responseBody]); + exit; + } + + $body = $response->getBody(); + $contentType = $response->getHeader('Content-Type'); + + if (count($contentType)) { + header("Content-Type:{$contentType[0]}"); + } + + $content = ''; + + while (!$body->eof()) { + $line = Utils::readLine($body); + + if ($line != '[DONE]') { + $data = preg_replace('/^data:/', '', $line); + $data = json_decode($data, true); + + if (isset($data['choices'][0]['delta']['content'])) { + $content .= $data['choices'][0]['delta']['content']; + } + } + + echo $line; + flush(); + } + + exit; + } + + public function indexAction() + { + // $assistants = Assistant::find($this->db, $this->user->getId(), '`title` ASC'); + // $assistant = array_pop($assistants); + // $apiKey = $this->getUserApiKey(); + // $assistant = Assistant::getDefaultUserAssistant($this->db, $this->user->getId()); + + return $this->render('index', [ + // 'assistant' => $assistant, + 'models' => $this->getModels(), + // 'apiKey' => $apiKey, + ]); + } + + public function listAction() + { + $assistants = Assistant::find($this->db, $this->user->getId(), '`title` ASC'); + + return $this->jsonResponse($assistants); + } + + public function assistantsAction() + { + return $this->render('assistants', [ + 'assistants' => Assistant::find($this->db, $this->user->getId(), '`title` ASC') + ]); + } + + public function editAction($id = 0) + { + $model = new Assistant($this->db); + + if ($id) { + $model->load($id); + } + + $response = []; + + $post = $this->request->getParsedBody(); + + if (!empty($post)) { + + $model->bind($post); + $model->user_id = $this->user->getId(); + + try { + + + + if ($model->isValid() && $model->save()) { + $response['status'] = 'success'; + } else { + $response['status'] = 'error'; + $response['error'] = array_pop($model->getErrors()); + } + + } catch (\Exception $e) { + exit ($e->getMessage()); + } + } + + $response['model'] = $model->toArray(); + + return $this->jsonResponse($response); + } + + public function saveAction() + { + $model = new Assistant($this->db); + + $data = json_decode((string) $this->request->getBody(), true); + + if (isset($data['id'])) { + $model->load($data['id']); + unset($data['id']); + } + + $response = []; + + $model->bind($data); + $model->user_id = $this->user->getId(); + + try { + if ($model->isValid() && $model->save()) { + $response['status'] = 'success'; + } else { + $response['status'] = 'error'; + $response['errors'] = $model->getErrors(); + } + + } catch (\Exception $e) { + exit ($e->getMessage()); + } + + $response['assistant'] = $model->toArray(); + + return $this->jsonResponse($response); + } + + public function setAsDefaultAction($id = 0) + { + $model = new Assistant($this->db); + + if ($id) { + $model->load($id); + $model->default = 1; + $model->save(); + + return $this->jsonResponse(true); + } + + return $this->jsonResponse(false); + } + + public function deleteAction($id = 0) + { + $model = new Assistant($this->db); + + if ($id) { + $model->load($id); + + if ($model->delete()) { + return $this->jsonResponse(true); + } + } + + return $this->jsonResponse(false); + } + +} diff --git a/modules/site/controllers/DefaultController.php b/modules/site/controllers/DefaultController.php new file mode 100644 index 0000000..dd00ac8 --- /dev/null +++ b/modules/site/controllers/DefaultController.php @@ -0,0 +1,14 @@ +render('error', [ + 'exception' => $exception + ]); + } +} diff --git a/modules/site/layouts/main.php b/modules/site/layouts/main.php new file mode 100644 index 0000000..79bb83a --- /dev/null +++ b/modules/site/layouts/main.php @@ -0,0 +1,78 @@ +params['user']; +assert($user instanceof Piko\User); + +if (!$this->title) $this->title = 'Openai'; +?> + + + + + + <?= $this->escape($this->title) ?> + head() ?> + vite('main.css') ?> + + + + + + + + params['message']) && is_array($this->params['message'])): ?> + + + + + endBody() ?> + vite('main.js') ?> + + diff --git a/modules/site/layouts/minimal.php b/modules/site/layouts/minimal.php new file mode 100644 index 0000000..a629d60 --- /dev/null +++ b/modules/site/layouts/minimal.php @@ -0,0 +1,23 @@ +title) $this->title = 'Openai'; +?> + + + + + + <?= $this->escape($this->title) ?> + head() ?> + vite('main.css') ?> + + + + + + endBody() ?> + vite('main.js') ?> + + diff --git a/modules/site/models/Assistant.php b/modules/site/models/Assistant.php new file mode 100644 index 0000000..ebf7299 --- /dev/null +++ b/modules/site/models/Assistant.php @@ -0,0 +1,145 @@ + + */ +class Assistant extends \Piko\DbRecord +{ + /** + * The table name + * + * @var string + */ + protected $tableName = 'chat_assistant'; + + /** + * The role permissions + * + * @var array + */ + public $permissions = []; + + /** + * The table schema + * + * @var array + */ + protected $schema = [ + 'id' => self::TYPE_INT, + 'user_id' => self::TYPE_INT, + 'title' => self::TYPE_STRING, + 'model' => self::TYPE_STRING, + 'system_prompt' => self::TYPE_STRING, + 'temperature' => self::TYPE_STRING, + 'top_p' => self::TYPE_STRING, + 'default' => self::TYPE_INT, + ]; + + + /** + * {@inheritDoc} + * @see \Piko\ModelTrait::validate() + */ + protected function validate(): void + { + if (empty($this->user_id)) { + $this->errors['user_id'] = 'L\'id de l\'utilisateur est obligatoire.'; + } + + if (empty($this->title)) { + $this->errors['title'] = 'Le titre doit être renseigné.'; + } + + if (empty($this->model)) { + $this->errors['model'] = 'Le modèle doit être renseigné.'; + } + + if (empty($this->system_prompt)) { + $this->errors['system_prompt'] = 'Le prompt système doit être renseigné.'; + } + + if (!empty($this->temperature) && $this->temperature < 0 || $this->temperature > 2) { + $this->errors['temperature'] = 'Le température doit être comprise entre 0 et 2.'; + } + } + + /** + * @inheritDoc + */ + protected function beforeSave(bool $insert): bool + { + if ($this->default == 1) { + + $this->db->beginTransaction(); + + try { + // Reset default for all other assistants of the same user + $sth = $this->db->prepare('UPDATE chat_assistant SET `default` = 0 WHERE user_id = :user_id'); + $sth->execute(['user_id' => $this->user_id]); + $this->db->commit(); + } catch (\Exception $e) { + $this->db->rollBack(); + throw $e; + } + } + + return parent::beforeSave($insert); + } + + /** + * Get assistants + * + * @param \PDO $db A pdo connexion + * @param string $order The order condition + * @param number $start The offset start + * @param number $limit The offset limit + * + * @return array An array of role rows + */ + public static function find(\PDO $db, $userId = 0, $order = '', $start = 0, $limit = 0) + { + $query = 'SELECT * FROM chat_assistant'; + + if ($userId) { + $query .= ' WHERE user_id = :user_id'; + } + + $query .= ' ORDER BY ' . (empty($order) ? '`id` DESC' : $order); + + if (!empty($start)) { + $query .= ' OFFSET ' . (int) $start; + } + + if (!empty($limit)) { + $query .= ' LIMIT ' . (int) $limit; + } + + $sth = $db->prepare($query); + + $sth->execute(['user_id' => $userId]); + + return $sth->fetchAll(\PDO::FETCH_ASSOC); + } + + public static function getDefaultUserAssistant(\PDO $db, int $userId): ?self + { + $query = 'SELECT * FROM chat_assistant WHERE user_id = :user_id AND `default` = 1 LIMIT 1'; + $sth = $db->prepare($query); + $sth->execute(['user_id' => $userId]); + + return $sth->fetchObject(self::class) ?: null; + } +} diff --git a/modules/site/views/assistant/index.php b/modules/site/views/assistant/index.php new file mode 100644 index 0000000..179b246 --- /dev/null +++ b/modules/site/views/assistant/index.php @@ -0,0 +1,28 @@ +title = 'Assistant chat'; + +$modelsJs = json_encode($models); +$responseUrl = $this->getUrl('site/assistant/response'); + +$script = << { + new ChatApp({ + target: document.getElementById('chat-app'), + props : { + proxyBaseUrl: '$responseUrl', + model_list: $modelsJs + } + }); + }) +JS; + +$this->registerJs($script); + +?> + +
+ + diff --git a/modules/site/views/default/error.php b/modules/site/views/default/error.php new file mode 100644 index 0000000..caaa3f6 --- /dev/null +++ b/modules/site/views/default/error.php @@ -0,0 +1,34 @@ +getMessage() . ' (#' . $exception->getCode() . ')' : 'Not found'; + +$this->title = $message; + +?> +
+ +

escape($this->title) ?>

+ +
+ escape($message)) ?> +
+ +

+ The above error occurred while the Web server was processing your request. +

+

+ Please contact us if you think this is a server error. Thank you. +

+ + +
+
Trace:
+
+ getTraceAsString()) ?> +
+
+ +
diff --git a/modules/user/AccessChecker.php b/modules/user/AccessChecker.php new file mode 100644 index 0000000..f23d636 --- /dev/null +++ b/modules/user/AccessChecker.php @@ -0,0 +1,74 @@ + + */ +class AccessChecker +{ + public static $adminRole; + + /** + * User roles + * + * @var null|array + */ + private static $roles = null; + + /** + * User permissions + * + * @var null|array + */ + private static $permissions = null; + + /** + * Check Permission or role access + * + * @param int $userId The user Id + * @param string $permission The permission or role name + * @return bool + * + * @see \piko\User + */ + public static function checkAccess($userId, string $permission) : bool + { + $identity = User::findIdentity($userId); + + if ($identity !== null) { + + if (static::$roles === null) { + static::$roles = Rbac::getUserRoles($identity->id); + } + + if (in_array(static::$adminRole, static::$roles)) { + return true; + } + + if (in_array($permission, static::$roles)) { + return true; + } + + if (static::$permissions === null) { + static::$permissions = Rbac::getUserPermissions($identity->id); + } + + if (in_array($permission, static::$permissions)) { + return true; + } + } + + return false; + } +} diff --git a/modules/user/Module.php b/modules/user/Module.php new file mode 100644 index 0000000..7984912 --- /dev/null +++ b/modules/user/Module.php @@ -0,0 +1,73 @@ + + */ +class Module extends \Piko\Module +{ + /** + * The admin role + * @var string + */ + public $adminRole = 'admin'; + + /** + * Allow user registration + * + * @var boolean + */ + public $allowUserRegistration = false; + + /** + * Min length of the user password + * + * @var integer + */ + public $passwordMinLength = 8; + + public function bootstrap() + { + $pdo = $this->application->getComponent('PDO'); + assert($pdo instanceof PDO); + + User::$pdo = $pdo; + Rbac::$db = $pdo; + AccessChecker::$adminRole = $this->adminRole; + } + + /** + * {@inheritDoc} + * @see \piko\Module::init() + */ + protected function init() + { + /* @var $i18n \piko\i18n */ + // $i18n = Piko::get('i18n'); + // $i18n->addTranslation('user', __DIR__ . '/messages'); + + // parent::init(); + } + +} diff --git a/modules/user/Rbac.php b/modules/user/Rbac.php new file mode 100644 index 0000000..6ed132b --- /dev/null +++ b/modules/user/Rbac.php @@ -0,0 +1,263 @@ + + */ +class Rbac +{ + public static PDO $db; + + /** + * Create a role + * + * @param string $name The role name + * @param string $description The role description + * @return int The role Id + */ + public static function createRole($name, $description = '') + { + $query = 'INSERT INTO `auth_role` (`name`, `description`) VALUES (?, ?)'; + + static::$db->beginTransaction(); + $st = static::$db->prepare($query); + $st->execute([$name, $description]); + static::$db->commit(); + + return static::$db->lastInsertId(); + } + + /** + * Check if the role exists + * + * @param string $name The role name + * @return boolean + */ + public static function roleExists($name) + { + $st = static::$db->prepare('SELECT COUNT(`id`) FROM `auth_role` WHERE `name` = :name'); + $st->execute(['name' => $name]); + + return ((int) $st->fetchColumn() > 0) ? true : false; + } + + /** + * Get the role Id + * + * @param string $name The role name + * @return int The role Id (0 if the role is not found) + */ + public static function getRoleId($name) + { + $st = static::$db->prepare('SELECT `id` FROM `auth_role` WHERE `name` = :name'); + $st->execute(['name' => $name]); + + return (int) $st->fetchColumn(); + } + + /** + * Assign a role to an user + * + * @param int $userId The user Id + * @param string $roleName The role name + * @throws \RuntimeException If the role doesn't exists + */ + public static function assignRole($userId, $roleName) + { + $roleId = static::getRoleId($roleName); + + if (!$roleId) { + throw new \RuntimeException("Role $roleName doesn't exists"); + } + + $query = 'INSERT INTO `auth_assignment` (`role_id`, `user_id`) VALUES (?, ?)'; + + static::$db->beginTransaction(); + $st = static::$db->prepare($query); + $st->execute([$roleId, $userId]); + static::$db->commit(); + } + + /** + * Get user roles + * + * @param int $userId The user Id + * @return array An array containing user roles + */ + public static function getUserRoles($userId) + { + $query = 'SELECT `auth_role`.`name` FROM `auth_role` ' + . 'INNER JOIN `auth_assignment` ON `auth_assignment`.`role_id` = `auth_role`.`id` ' + . 'WHERE `auth_assignment`.`user_id` = :user_id ' + . 'GROUP BY role_id'; + $st = static::$db->prepare($query); + $st->execute(['user_id' => $userId]); + + return $st->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get user roles ids + * + * @param int $userId The user Id + * @return array An array containing user role ids + */ + public static function getUserRoleIds($userId) + { + $query = 'SELECT role_id FROM `auth_assignment` WHERE user_id = :user_id'; + $sth = static::$db->prepare($query); + $sth->execute(['user_id' => $userId]); + + return $sth->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get user permissions + * + * @param int $userId The user Id + * @return array An array containing user permissions + */ + public static function getUserPermissions($userId) + { + $query = 'SELECT p.`name` FROM `auth_permission` AS p ' + . 'INNER JOIN `auth_role_has_permission` AS ap ON ap.`permission_id` = p.`id` ' + . 'INNER JOIN `auth_assignment` AS aa ON aa.`role_id` = ap.`role_id` ' + . 'WHERE aa.`user_id` = :user_id ' + . 'GROUP BY permission_id'; + + $st = static::$db->prepare($query); + $st->execute(['user_id' => $userId]); + + return $st->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get role permissions + * + * @param string $roleName The role name + * @return array An array of permissions as string + */ + public static function getRolePermissions($roleName): array + { + $roleId = static::getRoleId($roleName); + + if (!$roleId) { + throw new \RuntimeException("Role $roleName doesn't exists"); + } + + $query = 'SELECT p.`name` FROM `auth_permission` AS p ' + . 'INNER JOIN `auth_role_has_permission` AS ap ON ap.`permission_id` = p.`id` ' + . 'WHERE ap.`role_id` = :role_id ' + . 'GROUP BY permission_id'; + $st = static::$db->prepare($query); + $st->execute(['role_id' => $roleId]); + + return $st->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Get role permission ids + * + * @param string $roleName The role name + * @return array An array of permission ids + */ + public static function getRolePermissionIds($roleName) + { + $roleId = static::getRoleId($roleName); + + if (!$roleId) { + throw new \RuntimeException("Role $roleName doesn't exists"); + } + + $query = 'SELECT permission_id FROM `auth_role_has_permission` WHERE role_id = :role_id'; + $sth = static::$db->prepare($query); + $sth->execute(['role_id' => $roleId]); + + return $sth->fetchAll(\PDO::FETCH_COLUMN); + } + + /** + * Create a permission + * + * @param string $name The permission name + * @return int The permission id + */ + public static function createPermission($name): int + { + $query = 'INSERT INTO `auth_permission` (`name`) VALUES (?)'; + + static::$db->beginTransaction(); + $st = static::$db->prepare($query); + $st->execute([$name]); + static::$db->commit(); + + return (int) static::$db->lastInsertId(); + } + + /** + * Check if the permission exists + * + * @param string $name The permission name + * @return boolean + */ + public static function permissionExists($name): bool + { + $st = static::$db->prepare('SELECT COUNT(`id`) FROM `auth_permission` WHERE `name` = :name'); + $st->execute(['name' => $name]); + + return ((int) $st->fetchColumn() > 0) ? true : false; + } + + /** + * Get the permission Id + * + * @param string $name The permission name + * @return int The permission id (0 if the permission is not found) + */ + public static function getPermissionId($name): int + { + $st = static::$db->prepare('SELECT `id` FROM `auth_permission` WHERE `name` = :name'); + $st->execute(['name' => $name]); + + return (int) $st->fetchColumn(); + } + + /** + * Assign a permission to a role + * + * @param string $roleName The role name + * @param string $permissionName The permission name + * @throws \RuntimeException If the role or the permission doesn't exists + */ + public static function assignPermission($roleName, $permissionName): void + { + $roleId = static::getRoleId($roleName); + $permissionId = static::getPermissionId($permissionName); + + if (!$roleId) { + throw new \RuntimeException("Role $roleName doesn't exists"); + } + + if (!$permissionId) { + throw new \RuntimeException("Permission $permissionName doesn't exists"); + } + + $query = 'INSERT INTO `auth_role_has_permission` (`role_id`, `permission_id`) VALUES (?, ?)'; + + static::$db->beginTransaction(); + $st = static::$db->prepare($query); + $st->execute([$roleId, $permissionId]); + static::$db->commit(); + } +} diff --git a/modules/user/controllers/AdminController.php b/modules/user/controllers/AdminController.php new file mode 100644 index 0000000..a88e4c2 --- /dev/null +++ b/modules/user/controllers/AdminController.php @@ -0,0 +1,248 @@ + + */ +class AdminController extends \Piko\Controller +{ + protected PikoUser $user; + protected \PDO $db; + + public function init(): void + { + $app = $this->module->getApplication(); + + $user = $app->getComponent('Piko\User'); + assert($user instanceof PikoUser); + $this->user = $user; + + $db = $app->getComponent('PDO'); + assert($db instanceof \PDO); + $this->db = $db; + } + + /** + * {@inheritDoc} + * @see \piko\Controller::runAction() + */ + public function runAction($id) + { + assert($this->module instanceof \app\modules\user\Module); + + if (!$this->user->can($this->module->adminRole)) { + throw new HttpException('Not authorized.', 403); + } + + return parent::runAction($id); + } + + /** + * Render users view + * + * @return string + */ + public function usersAction() + { + return $this->render('users', [ + 'users' => User::find() + ]); + } + + /** + * Render User form and create or update user + * + * @return string + */ + public function editAction(int $id = 0) + { + $user = new User($this->db); + + if ($id) { + $user->load($id); + } + + $user->scenario = User::SCENARIO_ADMIN; + $message = false; + + $post = $this->request->getParsedBody(); + + if (!empty($post)) { + + $user->bind($post); + + if ($user->isValid() && $user->save()) { + $message['type'] = 'success'; + $message['content'] = __('user', 'User successfully saved'); + } else { + $message['type'] = 'danger'; + $message['content'] = __('user', 'Save error!') . implode(' ', $user->errors); + } + } + + return $this->render('edit', [ + 'user' => $user, + 'message' => $message, + 'roles' => Role::find('`name` ASC'), + ]); + } + + /** + * Delete users + */ + public function deleteAction() + { + $post = $this->request->getParsedBody(); + $ids = isset($post['items'])? $post['items'] : []; + + foreach ($ids as $id) { + $user = new User($id); + $user->delete(); + } + + $this->redirect($this->getUrl('user/admin/users')); + } + + /** + * Render roles view + * + * @return string + */ + public function rolesAction() + { + return $this->render('roles', [ + 'roles' => Role::find(), + 'permissions' => Permission::find('`name` ASC'), + ]); + } + + /** + * Create/update role (AJAX) + * + * @return string + */ + public function editRoleAction(int $id = 0) + { + $role = new Role($this->db); + + if ($id) { + $role->load($id); + } + + $role->scenario = Role::SCENARIO_ADMIN; + + $post = $this->request->getParsedBody(); + + $response = [ + 'role' => $role + ]; + + if (!empty($post)) { + + $role->bind($post); + + if ($role->isValid() && $role->save()) { + $response['status'] = 'success'; + } else { + $response['status'] = 'error'; + } + } + + return $this->jsonResponse($response); + } + + + /** + * Delete roles + */ + public function deleteRolesAction() + { + $post = $this->request->getParsedBody(); + $ids = isset($post['items'])? $post['items'] : []; + + foreach ($ids as $id) { + $item = new Role($id); + $item->delete(); + } + + $this->redirect($this->getUrl('user/admin/roles')); + } + + /** + * Render permissions view + * + * @return string + */ + public function permissionsAction() + { + return $this->render('permissions', [ + 'permissions' => Permission::find() + ]); + } + + /** + * Create/update permission (AJAX) + * + * @return string + */ + public function editPermissionAction(int $id = 0) + { + $permission = new Permission($this->db); + + if ($id) { + $permission->load($id); + } + + $response = [ + 'permission' => $permission + ]; + + $post = $this->request->getParsedBody(); + + if (!empty($post)) { + + $permission->bind($post); + + if ($permission->isValid() && $permission->save()) { + $response['status'] = 'success'; + } else { + $response['status'] = 'error'; + $response['error'] = array_pop($permission->getErrors()); + } + } + + return $this->jsonResponse($response); + } + + /** + * Delete permissions + */ + public function deletePermissionsAction() + { + $post = $this->request->getParsedBody(); + $ids = isset($post['items'])? $post['items'] : []; + + foreach ($ids as $id) { + $item = new Permission($id); + $item->delete(); + } + + $this->redirect($this->getUrl('user/admin/permissions')); + } +} diff --git a/modules/user/controllers/DefaultController.php b/modules/user/controllers/DefaultController.php new file mode 100644 index 0000000..61f5393 --- /dev/null +++ b/modules/user/controllers/DefaultController.php @@ -0,0 +1,295 @@ + + */ +class DefaultController extends \Piko\Controller +{ + protected PikoUser $user; + protected \PDO $db; + + public function init(): void + { + $app = $this->module->getApplication(); + + $user = $app->getComponent('Piko\User'); + assert($user instanceof PikoUser); + $this->user = $user; + + $db = $app->getComponent('PDO'); + assert($db instanceof \PDO); + $this->db = $db; + } + + /** + * Render and process user registration + * + * @return string + */ + public function registerAction() + { + if (!$this->user->isGuest()) { + return $this->redirect('/'); + } + + $message = false; + $post = $this->request->getParsedBody(); + + if (!empty($post)) { + + $user = new User($this->db); + + $user->scenario = User::SCENARIO_REGISTER; + + $user->bind($post); + + if ($user->isValid() && $user->save()) { + // $user->sendRegistrationConfirmation(); + $message['type'] = 'success'; + $message['content'] = __( + 'user', + 'Your account was created. Please activate it through the confirmation email that was sent to you.' + ); + } else { + $message['type'] = 'danger'; + $message['content'] = implode(', ', $user->errors); + } + } + + return $this->render('register', [ + 'message' => $message, + ]); + } + + /** + * Validate registration (AJAX) + * + * @return string + */ + public function checkRegistrationAction() + { + $errors = []; + $this->layout = false; + $post = $this->request->getParsedBody(); + + if (!empty($post)) { + + $user = new User($this->db); + $user->scenario = 'register'; + $user->bind($post); + $user->isValid(); + $errors = $user->getErrors(); + } + + return $this->jsonResponse($errors); + } + + /** + * Render user activation confirmation + * + * @throws HttpException + * @return string + */ + public function confirmationAction($token) + { + $user = User::findByAuthKey($token); + + if (!$user) { + throw new HttpException('Not found.', 404); + } + + $message = false; + + if (!$user->isActivated()) { + + if ($user->activate()) { + $message['type'] = 'success'; + $message['content'] = __('user', 'Your account has been activated. You can now log in.'); + } else { + $message['type'] = 'danger'; + $message['content'] = __( + 'user', + 'Unable to activate your account. Please contact the site manager.' + ); + } + } else { + $message['type'] = 'warning'; + $message['content'] = __('user', 'Your account has already been activated.'); + } + + return $this->render('login', ['message' => $message]); + } + + /** + * Render reminder password form and send email to change password + * + * @return string + */ + public function reminderAction() + { + $message = false; + $post = $this->request->getParsedBody(); + + $reminder = $post['reminder']?? ''; + + if (!empty($reminder)) { + + $user = User::findByUsername($reminder); + + if (!$user) { + $user = User::findByEmail($reminder); + } + + if ($user) { + // $user->sendResetPassword(); + $message['type'] = 'success'; + $message['content'] = __( + 'user', + 'A link has been sent to you by email ({email}). It will allow you to recreate your password.', + ['email' => $user->email] + ); + } else { + $message['type'] = 'danger'; + $message['content'] = __('user', 'Account not found.'); + } + } + + return $this->render('reminder', [ + 'message' => $message, + 'reminder' => $reminder, + ]); + } + + /** + * Render and process reset password + * + * @throws HttpException + * @return string + */ + public function resetPasswordAction($token) + { + $user = User::findByAuthKey($token); + + if (!$user) { + throw new HttpException('Not found', 404); + } + + $message = false; + $post = $this->request->getParsedBody(); + + if (!empty($post)) { + $user->scenario = 'reset'; + + $user->bind($post); + + if ($user->isValid() && $user->save()) { + $message['type'] = 'success'; + $message['content'] = __('user', 'Your password has been successfully updated.'); + } else { + $message['type'] = 'danger'; + $message['content'] = implode(', ', $user->errors); + } + } + + return $this->render('reset', [ + 'message' => $message, + 'user' => $user, + ]); + } + + /** + * Render user form and update changes + * + * @throws HttpException + * @return string + */ + public function editAction() + { + if ($this->user->isGuest()) { + throw new HttpException(__('user', 'You must be logged to access this page.'), 401); + } + + $identity = $this->user->getIdentity(); + + assert($identity instanceof User); + + $message = false; + $post = $this->request->getParsedBody(); + + if (!empty($post)) { + $identity->bind($post); + + if ($identity->isValid() && $identity->save()) { + $message['type'] = 'success'; + $message['content'] = __('user', 'Changes saved!'); + } else { + $message['type'] = 'danger'; + $message['content'] = implode(', ', $identity->getErrors()); + } + } + + return $this->render('edit', [ + 'user' => $identity, + 'message' => $message, + ]); + } + + /** + * Render login form and process login + * + * @return string + */ + public function loginAction() + { + $message = false; + $post = $this->request->getParsedBody(); + + if (!empty($post)) { + $identity = User::findByUsername($post['username']); + + if ($identity instanceof User && $identity->validatePassword($post['password'])) { + + $this->user->login($identity); + $identity->last_login_at = time(); + $identity->save(); + + return $this->redirect('/'); + + } else { + $message['type'] = 'danger'; + $message['content'] = __('user', 'Authentication failure'); + } + } + + assert($this->module instanceof \app\modules\user\Module); + + return $this->render('login', [ + 'message' => $message, + 'canRegister' => $this->module->allowUserRegistration + ]); + } + + /** + * User logout + */ + public function logoutAction() + { + $this->user->logout(); + $this->redirect('/'); + } +} diff --git a/modules/user/messages/fr.php b/modules/user/messages/fr.php new file mode 100644 index 0000000..d4eeabe --- /dev/null +++ b/modules/user/messages/fr.php @@ -0,0 +1,111 @@ + 'Utilisateurs', + 'Name' => 'Nom', + 'Username' => 'Identifiant', + 'Email' => 'Email', + 'Password' => 'Mot de passe', + 'Last login at' => 'Dernière connexion', + 'Created at' => 'Créé le', + 'Id' => 'Id', + 'Roles' => 'Rôles', + 'Role name' => 'Nom du rôle', + 'Role name must be filled in.' => 'Le nom du role doit être renseigné.', + 'Role already exists.' => 'Le rôle existe déjà.', + 'Description' => 'Description', + 'Role permissions' => 'Permissions du rôle', + 'Users management' => 'Gestion des utilisateurs', + 'Are you sure you want to perform this action?' => 'Êtes-vous certain de vouloir effectuer cette action ?', + 'Create user' => 'Nouvel utilisateur', + 'Edit user' => 'Modifier l\'utilisateur', + 'New role' => 'Nouveau rôle', + 'Delete' => 'Supprimer', + 'Close' => 'Fermer', + 'Cancel' => 'Annuler', + 'Permissions' => 'Permissions', + 'New permission' => 'Nouvelle permission', + 'Permission name' => 'Nom de la permission', + 'Permission name must be filled in.' => 'Le nom de la permission doit être renseigné.', + 'Permission already exists.' => 'La permission existe déjà', + 'User successfully saved' => 'Utilisateur correctement enregistré', + 'Save' => 'Enregistrer', + 'Save error!' => 'Erreur lors de l\'enregistrement', + 'Email must be filled in.' => 'L\'email doit être renseigné.', + '{email} is not a valid email address.' => '{email} n\'est pas une adresse email valide.', + 'Username must be filled in.' => 'Le nom d\'utilisateur doit être renseigné.', + 'The username should only contain alphanumeric characters.' => 'Le nom d\'utilisateur ne doit contenir que des caractères alphanumériques.', + 'This email is already used.' => 'Cet email est déjà utilisé.', + 'This username is already used.' => 'Cet identifant est déjà utilisé.', + 'Password must be filled in.' => 'Le mot de passe doit être renseigné.', + 'Password is to short. Minimum {num}: characters.' => 'Mot de passe trop court. Minimum {num} caractères.', + 'Passwords are not the same.' => 'Les mots de passe ne sont pas identiques.', + + // Register Account + 'Your account was created. Please activate it through the confirmation email that was sent to you.' => 'Votre compte a été créé. Merci de l\'activer via le mail de confirmation qui vous a été envoyé.', + 'confirmation_mail_body' => $confirmationMailBody, + 'Registration confirmation on {site_name}' => 'Confirmation de l\'inscription sur {site_name}', + // Account activation + 'Your account has been activated. You can now log in.' => 'Votre compte a bien été activé. Vous pouvez désormais vous connecter.', + 'Unable to activate your account. Please contact the site manager.' => 'Impossible d\'activer votre compte. Merci de contacter le responsable du site.', + 'Your account has already been activated.' => 'Votre compte a déjà été activé.', + // Password reset / reminder + 'A link has been sent to you by email ({email}). It will allow you to recreate your password.' => 'Un lien vous a été envoyé par email ({email}). Il vous permettra de recréer votre mot de passe.', + 'reset_password_mail_body' => $resetPasswordMailBody, + 'Password change request on {site_name}' => 'Demande de changement de mot de passe sur {site_name}', + 'Account not found.' => 'Compte innexistant', + 'Your password has been successfully updated.' => 'Votre mot de passe a bien été modifié.', + 'Forget password' => 'Mot de passe oublié', + 'Your email or your username' => 'Votre email ou votre identifiant', + 'Send' => 'Envoyer', + 'Change your account ({account}) password' => 'Réinitialisation du mot de passe pour le compte : {account}', + // Edit account + 'You must be logged to access this page.' => 'Vous devez vous connecter pour accéder à cette page.', + 'Changes saved!' => 'Modifications enregistrées !', + 'Edit your account' => 'Modification de votre compte', + 'Password (leave blank to keep the same)' => 'Mot de passe (laisser vide pour garder le même)', + 'Last name' => 'Nom', + 'First name' => 'Prénom', + 'Company' => 'Entreprise', + 'Phone number' => 'Téléphone', + 'Address' => 'Adresse', + 'Zip code' => 'Code postal', + 'City' => 'Ville', + 'Country' => 'Pays', + + // Login + 'Authentication failure' => 'Échec de l\'authentification.', + 'Login' => 'Connexion', + 'No account yet?' => 'Pas encore de compte ?', + 'Register' => 'Créer un compte', + 'Forget password?' => 'Mot de passe oublié ?', + // register + 'Confirm your password' => 'Confirmez votre mot de passe' + +]; diff --git a/modules/user/models/Permission.php b/modules/user/models/Permission.php new file mode 100644 index 0000000..b07afcd --- /dev/null +++ b/modules/user/models/Permission.php @@ -0,0 +1,96 @@ + + */ +class Permission extends \piko\DbRecord +{ + /** + * The table name + * + * @var string + */ + protected $tableName = 'auth_permission'; + + /** + * The model errors + * + * @var array + */ + public $errors = []; + + /** + * The table schema + * + * @var array + */ + protected $schema = [ + 'id' => self::TYPE_INT, + 'name' => self::TYPE_STRING, + ]; + + /** + * {@inheritDoc} + * @see \Piko\ModelTrait::validate() + */ + protected function validate(): void + { + if (empty($this->name)) { + $this->errors['name'] = __('user', 'Permission name must be filled in.'); + } else { + $st = $this->db->prepare('SELECT COUNT(`id`) FROM `auth_permission` WHERE name = :name'); + $st->execute(['name' => $this->name]); + + $count = (int) $st->fetchColumn(); + + if ($count) { + $this->errors['name'] = __('user', 'Permission already exists.'); + } + } + } + + /** + * Find permissions + * + * @param string $order The order condition + * @param number $start The offset start + * @param number $limit The offset limit + * + * @return array An array of permission rows + */ + public static function find($order = '', $start = 0, $limit = 0) + { + $db = User::$pdo; + $query = 'SELECT `id`, `name` FROM `auth_permission`'; + $query .= ' ORDER BY ' . (empty($order) ? '`id` DESC' : $order); + + if (!empty($start)) { + $query .= ' OFFSET ' . (int) $start; + } + + if (!empty($limit)) { + $query .= ' LIMIT ' . (int) $limit; + } + + $sth = $db->prepare($query); + + $sth->execute(); + + return $sth->fetchAll(); + } +} diff --git a/modules/user/models/Role.php b/modules/user/models/Role.php new file mode 100644 index 0000000..3afd7e2 --- /dev/null +++ b/modules/user/models/Role.php @@ -0,0 +1,180 @@ + + */ +class Role extends \Piko\DbRecord +{ + const SCENARIO_ADMIN = 'admin'; + + /** + * The table name + * + * @var string + */ + protected $tableName = 'auth_role'; + + /** + * The model scenario + * + * @var string + */ + public $scenario = ''; + + /** + * The model errors + * + * @var array + */ + public $errors = []; + + /** + * The role permissions + * + * @var array + */ + public $permissions = []; + + /** + * The table schema + * + * @var array + */ + protected $schema = [ + 'id' => self::TYPE_INT, + 'name' => self::TYPE_STRING, + 'description' => self::TYPE_STRING, + ]; + + /** + * {@inheritDoc} + * @see \piko\Component::init() + */ + protected function init() + { + if (!empty($this->name)) { + $this->permissions = Rbac::getRolePermissionIds($this->name); + } + } + + /** + * {@inheritDoc} + * @see \Piko\DbRecord::bind() + */ + public function bind($data): void + { + if (isset($data['permissions'])) { + $this->permissions = $data['permissions']; + unset($data['permissions']); + } + + parent::bind($data); + } + + /** + * {@inheritDoc} + * @see \Piko\DbRecord::afterSave() + */ + protected function afterSave(): void + { + if ($this->scenario === self::SCENARIO_ADMIN) { + + $st = $this->db->prepare('DELETE FROM `auth_role_has_permission` WHERE role_id = :role_id'); + + if (!$st->execute(['role_id' => $this->id])) { + throw new \RuntimeException( + "Error while trying to delete role id {$this->id} in auth_role_has_permission table" + ); + } + + if (!empty($this->permissions)) { + + $values = []; + + foreach ($this->permissions as $id) { + $values[] = '(' . (int) $this->id . ',' . (int) $id . ')'; + } + + $query = 'INSERT INTO `auth_role_has_permission` (role_id, permission_id) VALUES ' + . implode(', ', $values); + + $this->db->beginTransaction(); + + $st = $this->db->prepare($query); + $st->execute(); + $this->db->commit(); + } + } + + parent::afterSave(); + } + + /** + * {@inheritDoc} + * @see \Piko\ModelTrait::validate() + */ + protected function validate(): void + { + if (empty($this->name)) { + $this->errors['name'] = __('user', 'Role name must be filled in.'); + } else { + $st = $this->db->prepare('SELECT COUNT(`id`) FROM `auth_role` WHERE name = :name'); + $st->execute(['name' => $this->name]); + + $count = (int) $st->fetchColumn(); + + if ($count) { + $this->errors['name'] = __('user', 'Role already exists.'); + } + } + } + + /** + * Get roles + * + * @param string $order The order condition + * @param number $start The offset start + * @param number $limit The offset limit + * + * @return array An array of role rows + */ + public static function find($order = '', $start = 0, $limit = 0) + { + $db = User::$pdo; + $query = 'SELECT * FROM `auth_role`'; + + $query .= ' ORDER BY ' . (empty($order) ? '`id` DESC' : $order); + + if (!empty($start)) { + $query .= ' OFFSET ' . (int) $start; + } + + if (!empty($limit)) { + $query .= ' LIMIT ' . (int) $limit; + } + + $sth = $db->prepare($query); + + $sth->execute(); + + return $sth->fetchAll(); + } +} diff --git a/modules/user/models/User.php b/modules/user/models/User.php new file mode 100644 index 0000000..33c3a04 --- /dev/null +++ b/modules/user/models/User.php @@ -0,0 +1,551 @@ + + */ +class User extends \Piko\DbRecord implements \Piko\User\IdentityInterface +{ + const SCENARIO_ADMIN = 'admin'; + const SCENARIO_REGISTER = 'register'; + const SCENARIO_RESET = 'reset'; + + public static \PDO $pdo; + public static Module $module; + + /** + * The table name + * @var string + */ + protected $tableName = 'user'; + + /** + * The model errors + * + * @var array + */ + public $errors = []; + + /** + * The model scenario + * + * @var string + */ + public $scenario = ''; + + /** + * The user role ids + * + * @var array + */ + protected $roleIds = []; + + /** + * The confirmation password + * + * @var string + */ + protected $password2 = ''; + + /** + * Reset password state + * + * @var boolean + */ + protected $resetPassword = false; + + /** + * The table schema + * + * @var array + */ + protected $schema = [ + 'id' => self::TYPE_INT, + 'name' => self::TYPE_STRING, + 'username' => self::TYPE_STRING, + 'email' => self::TYPE_STRING, + 'password' => self::TYPE_STRING, + 'auth_key' => self::TYPE_STRING, + 'confirmed_at' => self::TYPE_INT, + 'blocked_at' => self::TYPE_INT, + 'registration_ip' => self::TYPE_STRING, + 'created_at' => self::TYPE_INT, + 'updated_at' => self::TYPE_INT, + 'last_login_at' => self::TYPE_INT, + 'is_admin' => self::TYPE_INT, + 'timezone' => self::TYPE_STRING, + 'profil' => self::TYPE_STRING, + ]; + + /** + * {@inheritDoc} + * @see \Piko\DbRecord::beforeSave() + */ + protected function beforeSave($isNew): bool + { + if ($isNew) { + $this->name = $this->username; + $this->password = sha1($this->password); + $this->created_at = time(); + $this->auth_key = sha1(Random::generate(10)); + } else { + $this->updated_at = time(); + + if ($this->resetPassword) { + $this->password = sha1($this->password); + } + } + + return parent::beforeSave($isNew); + } + + /** + * {@inheritDoc} + * @see \Piko\DbRecord::afterSave() + */ + protected function afterSave(): void + { + if ($this->scenario === self::SCENARIO_ADMIN) { + + // Don't allow admin user to remove its admin role + /* + if ($this->id == Piko::get('user')->getId()) { + + $adminRole = Piko::get('userModule')->adminRole; + $adminRoleId = Rbac::getRoleId($adminRole); + + if (!in_array($adminRoleId, $this->roleIds)) { + $this->roleIds[] = $adminRoleId; + } + } + */ + + if (!empty($this->roleIds)) { + + $roleIds = Rbac::getUserRoleIds($this->id); + + $idsToRemove = array_diff($roleIds, $this->roleIds); + $idsToAdd = array_diff($this->roleIds, $roleIds); + + if (!empty($idsToRemove)) { + $query = 'DELETE FROM `auth_assignment` WHERE user_id = :user_id AND role_id IN(' + . implode(',', $idsToRemove) . ')'; + $st = $this->db->prepare($query); + $st->execute(['user_id' => $this->id]); + } + + if (!empty($idsToAdd)) { + $values = []; + foreach ($idsToAdd as $id) { + $values[] = '(' . (int) $this->id . ',' . (int) $id . ')'; + } + + $query = 'INSERT INTO `auth_assignment` (user_id, role_id) VALUES ' . implode(', ', $values); + + $this->db->beginTransaction(); + $st = $this->db->prepare($query); + $st->execute(); + $this->db->commit(); + } + } else { + + $st = $this->db->prepare('DELETE FROM `auth_assignment` WHERE user_id = :user_id'); + $st->execute(['user_id' => $this->id]); + } + } + + parent::afterSave(); + } + + /** + * {@inheritDoc} + * @see \Piko\DbRecord::bind() + */ + public function bind($data): void + { + if (isset($data['password']) && empty($data['password'])) { + unset($data['password']); + } + + if (isset($data['password2'])) { + $this->password2 = $data['password2']; + unset($data['password2']); + } + + if (!empty($data['password']) && !$this->validatePassword($data['password'])) { + $this->resetPassword = true; + } + + if (!empty($data['profil']) && is_array($data['profil'])) { + $data['profil'] = json_encode($data['profil']); + } + + if (isset($data['roles']) && $this->scenario == self::SCENARIO_ADMIN) { + $this->roleIds = $data['roles']; + unset($data['roles']); + } + + parent::bind($data); + } + + /** + * {@inheritDoc} + * @see \Piko\ModeTrait::validate() + */ + protected function validate(): void + { + if (empty($this->email)) { + $this->errors['email'] = __('user', 'Email must be filled in.'); + } elseif (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) { + $this->errors['email'] = __( + 'user', + '{email} is not a valid email address.', + ['email' => $this->data['email']] + ); + } + + if (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_ADMIN) + && empty($this->username)) { + $this->errors['username'] = __('user', 'Username must be filled in.') ; + } + + // New user + if (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_ADMIN) + && empty($this->id)) { + + $st = $this->db->prepare('SELECT id FROM user WHERE email = ?'); + $st->execute([$this->email]); + $id = $st->fetchColumn(); + + if ($id) { + $this->errors['email'] = __('user', 'This email is already used.'); + } + + $st = $this->db->prepare('SELECT id FROM user WHERE username = ?'); + $st->execute([$this->username]); + $id = $st->fetchColumn(); + + if ($id) { + $this->errors['username'] = __('user', 'This username is already used.'); + } + } + + if (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_RESET) + && empty($this->password)) { + $this->errors['password'] = __('user', 'Password must be filled in.'); + + } elseif (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_RESET) && + strlen($this->password) < static::$module->passwordMinLength) { + $this->errors['password'] = __( + 'user', + 'Password is to short. Minimum {num}: characters.', + ['num' => static::$module->passwordMinLength] + ); + } + + if (($this->scenario == self::SCENARIO_REGISTER || $this->scenario == self::SCENARIO_RESET) && + $this->password != $this->password2) { + $this->errors['password2'] = __('user', 'Passwords are not the same.'); + } + } + + /** + * Get user role ids + * + * @return array An array containg role ids + */ + public function getRoleIds() + { + return Rbac::getUserRoleIds($this->id); + } + + /** + * Activate an user + * + * @return boolean + */ + public function activate() + { + $this->confirmed_at = time(); + return $this->save(); + } + + /** + * Check if the user is activated + * @return boolean + */ + public function isActivated() + { + return empty($this->confirmed_at) ? false : true; + } + + /** + * Send Registration confirmation email + * + * @return boolean Return false if fail to send email + */ + public function sendRegistrationConfirmation(Router $router, SmtpMailer $mailer) + { + $siteName = getenv('SITE_NAME'); + $baseUrl = $this->getAbsoluteBaseUrl(); + + $message = __('user', 'confirmation_mail_body', [ + 'site_name' => $siteName, + 'link' => $baseUrl . $router->getUrl('user/default/confirmation', ['token' => $this->auth_key]), + 'base_url' => $baseUrl, + 'username' => $this->username, + ]); + + $subject = __('user', 'Registration confirmation on {site_name}', ['site_name' => $siteName]); + + $mail = new Message(); + $mail->setFrom($siteName . ' <' . getenv('NO_REPLY_EMAIL') . '>') + ->addTo($this->email) + ->setSubject($subject) + ->setBody($message); + + try { + $mailer->send($mail); + return true; + + } catch (\Exception $e) { + $this->errors['sendmail'] = $e->getMessage(); + } + + return false; + } + + /** + * Send reset password email + * + * @return boolean Return false if fail to send email + */ + public function sendResetPassword(Router $router, SmtpMailer $mailer) + { + $siteName = getenv('SITE_NAME'); + + $baseUrl = $this->getAbsoluteBaseUrl(); + + $message = __('user', 'reset_password_mail_body', [ + 'site_name' => $siteName, + 'link' => $baseUrl . $router->getUrl('user/default/reset-password', ['token' => $this->auth_key]), + 'username' => $this->username, + ]); + + $subject = __('user', 'Password change request on {site_name}', ['site_name' => $siteName]); + + $mail = new Message(); + $mail->setFrom($siteName . ' <' . getenv('NO_REPLY_EMAIL') . '>') + ->addTo($this->email) + ->setSubject($subject) + ->setBody($message); + + try { + $mailer->send($mail); + return true; + + } catch (\Exception $e) { + $this->errors['sendmail'] = $e->getMessage(); + } + + return false; + } + + /** + * Get users + * + * @param array $filters Array of filter conditions (['name' => '']) + * @param string $order The order condition + * @param number $start The offset start + * @param number $limit The offset limit + * + * @return array An array of user rows + */ + public static function find($filters = [], $order = '', $start = 0, $limit = 0) + { + $query = 'SELECT * FROM `user`'; + $where = []; + + if (!empty($filters['name'])) { + $where[] = '`name` LIKE :search'; + } + + if (!empty($where)) { + $query .= ' WHERE ' . implode(' AND ', $where); + } + + $query .= ' ORDER BY ' . (empty($order) ? '`id` DESC' : $order); + + if (!empty($start)) { + $query .= ' OFFSET ' . (int) $start; + } + + if (!empty($limit)) { + $query .= ' LIMIT ' . (int) $limit; + } + + $sth = static::$pdo->prepare($query); + + $sth->execute($filters); + + return $sth->fetchAll(); + } + + + /** + * Find user by username + * + * @param string $username + * @return User|NULL + */ + public static function findByUsername($username) + { + $st = static::$pdo->prepare('SELECT id FROM user WHERE username = ?'); + $st->bindParam(1, $username, \PDO::PARAM_STR); + + if ($st->execute()) { + $id = $st->fetchColumn(); + + if ($id) { + $user = new static(static::$pdo); + $user->load($id); + + return $user; + } + } + + return null; + } + + /** + * Find user by email + * + * @param string $email + * @return User|NULL + */ + public static function findByEmail($email) + { + + $st = static::$pdo->prepare('SELECT id FROM user WHERE email = ?'); + $st->bindParam(1, $email, \PDO::PARAM_STR); + + if ($st->execute()) { + $id = $st->fetchColumn(); + + if ($id) { + $user = new static(static::$pdo); + + return $user->load($id); + } + } + + return null; + } + + /** + * Find user by auth key + * + * @param string $token + * @return User|NULL + */ + public static function findByAuthKey($token) + { + $st = static::$pdo->prepare('SELECT id FROM `user` WHERE `auth_key` = ?'); + + if ($st->execute([$token])) { + $id = $st->fetchColumn(); + + if ($id) { + $user = new static(static::$pdo); + $user->load($id); + + return $user; + } + } + + return null; + } + + /** + * Validate password + * + * @param string $password + * @return boolean + */ + public function validatePassword($password) + { + return $this->password == sha1($password); + } + + /** + * Find user by Id + * + * @param int $id + * @return User|NULL + */ + public static function findIdentity($id) + { + try { + $user = new static(static::$pdo); + + return $user->load($id); + } catch (\RuntimeException $e) { + + } + + return null; + } + + /** + * {@inheritDoc} + * @see \piko\IdentityInterface::getId() + */ + public function getId() + { + return $this->id; + } + + /** + * Get absolute base Url + * + * @return string + */ + protected function getAbsoluteBaseUrl() + { + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + + return "$protocol://{$_SERVER['HTTP_HOST']}"; + } +} diff --git a/modules/user/sql/install-mysql.sql b/modules/user/sql/install-mysql.sql new file mode 100644 index 0000000..ba0fad4 --- /dev/null +++ b/modules/user/sql/install-mysql.sql @@ -0,0 +1,21 @@ + +CREATE TABLE IF NOT EXISTS `user` ( + `id` int(11) NOT NULL auto_increment, + `name` varchar(50) NOT NULL, + `username` varchar(50) NOT NULL, + `email` varchar(100) NOT NULL, + `password` varchar(50) NOT NULL, + `auth_key` varchar(100) NOT NULL DEFAULT '', + `confirmed_at` datetime DEFAULT NULL, + `blocked_at` datetime DEFAULT NULL, + `registration_ip` varchar(40) DEFAULT '' COMMENT 'Stores ip v4 or ip v6', + `created_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + `last_login_at` datetime DEFAULT NULL, + `is_admin` tinyint(1) NOT NULL DEFAULT 0, + `timezone` varchar(40) DEFAULT '', + `profil` text NOT NULL DEFAULT '{}' COMMENT 'Json encoded profil', + PRIMARY KEY (`id`), + INDEX `username` (`username`), + UNIQUE INDEX `email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE=utf8mb4_unicode_ci; diff --git a/modules/user/sql/install-sqlite.sql b/modules/user/sql/install-sqlite.sql new file mode 100644 index 0000000..a628945 --- /dev/null +++ b/modules/user/sql/install-sqlite.sql @@ -0,0 +1,52 @@ + +CREATE TABLE IF NOT EXISTS "user" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + username TEXT NOT NULL, + email TEXT NOT NULL, + password TEXT NOT NULL, + auth_key TEXT, + confirmed_at INTEGER, + blocked_at INTEGER, + registration_ip TEXT, + created_at INTEGER, + updated_at INTEGER, + last_login_at INTEGER, + timezone TEXT, + profil TEXT, + UNIQUE(email) +); + +CREATE TABLE IF NOT EXISTS auth_role +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(64) NOT NULL, + description TEXT, + UNIQUE(name) +); + +CREATE TABLE IF NOT EXISTS auth_permission +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(64) NOT NULL, + UNIQUE(name) +); + +CREATE TABLE IF NOT EXISTS auth_role_has_permission +( + role_id INTEGER NOT NULL, + permission_id INTEGER NOT NULL, + primary key (role_id, permission_id), + foreign key (role_id) references auth_role(id) on delete cascade on update cascade, + foreign key (permission_id) references auth_permission(id) on delete cascade on update cascade +); + +CREATE TABLE IF NOT EXISTS auth_assignment +( + role_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + primary key (role_id, user_id), + foreign key (role_id) references auth_role(id) on delete cascade on update cascade, + foreign key (user_id) references "user" (id) on delete cascade on update cascade +); + diff --git a/modules/user/views/admin/edit.php b/modules/user/views/admin/edit.php new file mode 100644 index 0000000..0759c24 --- /dev/null +++ b/modules/user/views/admin/edit.php @@ -0,0 +1,58 @@ +title = empty($user->id) ? __('user', 'Create user') : __('user', 'Edit user'); +$roleIds = $user->getRoleIds(); +?> +
+ + + + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
diff --git a/modules/user/views/admin/nav.php b/modules/user/views/admin/nav.php new file mode 100644 index 0000000..16ec44a --- /dev/null +++ b/modules/user/views/admin/nav.php @@ -0,0 +1,17 @@ + + diff --git a/modules/user/views/admin/permissions.php b/modules/user/views/admin/permissions.php new file mode 100644 index 0000000..a04cec6 --- /dev/null +++ b/modules/user/views/admin/permissions.php @@ -0,0 +1,111 @@ +title = __('user', 'Permissions'); + +$this->registerCSSFile(Piko::getAlias('@web/js/DataTables/datatables.min.css')); +$this->registerJsFile(Piko::getAlias('@web/js/jquery-3.7.1.min.js')); +$this->registerJsFile(Piko::getAlias('@web/js/DataTables/datatables.min.js')); + +$script = <<registerJs($script); + +?> + +render('nav', ['page' => 'permissions']) ?> + +
+ +
+ + +
+ + + + + + + + + + + + + + + + +
+   + +
+
+ + + diff --git a/modules/user/views/admin/roles.php b/modules/user/views/admin/roles.php new file mode 100644 index 0000000..b71bdfb --- /dev/null +++ b/modules/user/views/admin/roles.php @@ -0,0 +1,152 @@ +title = __('user', 'Roles'); + +$this->registerCSSFile(Piko::getAlias('@web/js/DataTables/datatables.min.css')); +$this->registerJsFile(Piko::getAlias('@web/js/jquery-3.7.1.min.js')); +$this->registerJsFile(Piko::getAlias('@web/js/DataTables/datatables.min.js')); + +$confirmDeleteMsg = __('user', 'Are you sure you want to perform this action?'); + +$script = <<registerJs($script); + +?> + +render('nav', ['page' => 'roles']) ?> + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + +
+   + +
+
+ + + diff --git a/modules/user/views/admin/users.php b/modules/user/views/admin/users.php new file mode 100644 index 0000000..59e4154 --- /dev/null +++ b/modules/user/views/admin/users.php @@ -0,0 +1,71 @@ +title = __('user', 'Users management'); + +$this->registerCSSFile(Piko::getAlias('@web/js/DataTables/datatables.min.css')); +$this->registerJsFile(Piko::getAlias('@web/js/jquery-3.7.1.min.js')); +$this->registerJsFile(Piko::getAlias('@web/js/DataTables/datatables.min.js')); + + +$confirmDeleteMsg = __('user', 'Are you sure you want to perform this action?'); +$script = <<registerJs($script); + +?> + +render('nav', ['page' => 'users']) ?> + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+   + +
+
diff --git a/modules/user/views/default/edit.php b/modules/user/views/default/edit.php new file mode 100644 index 0000000..9ff9531 --- /dev/null +++ b/modules/user/views/default/edit.php @@ -0,0 +1,93 @@ +title = __('user', 'Edit your account'); + +if (is_array($message)) { + $this->params['message'] = $message; +} + +if (!empty($user->profil)) { + $user->profil = json_decode($user->profil); +} + +?> + +
+

title ?>

+ +
+ +
+ +
+ +
+ + + errors['email'])): ?> + + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + + +
+
+ + diff --git a/modules/user/views/default/login.php b/modules/user/views/default/login.php new file mode 100644 index 0000000..4b9ba9e --- /dev/null +++ b/modules/user/views/default/login.php @@ -0,0 +1,77 @@ +title = __('user', 'Login'); +$this->params['breadcrumbs'][] = $this->title; + +if (is_array($message)) { + $this->params['message'] = $message; +} + +?> + +
+ +
+

title ?>

+ +
+ + +
+
+ + +
+ + +

© 2017–2023

+ + + +
+ + + +
+

+

+
+

+
+ + +
+ + + diff --git a/modules/user/views/default/register.php b/modules/user/views/default/register.php new file mode 100644 index 0000000..d0f6ec5 --- /dev/null +++ b/modules/user/views/default/register.php @@ -0,0 +1,82 @@ +title = Piko::t('user', 'Register'); + +if (is_array($message)) { + $this->params['message'] = $message; + return; +} + +$js = << + + + + + diff --git a/vite/src/ChatApp.svelte b/vite/src/ChatApp.svelte new file mode 100644 index 0000000..f0e5461 --- /dev/null +++ b/vite/src/ChatApp.svelte @@ -0,0 +1,529 @@ + + +
+

{assistant_title}

+ +
+ + + {#if assistants.length} + + {#if assistant} + + + {/if} + {/if} + + + + {#if messages.length} +
+ + +
+ {/if} + + + + + + +
+ +
+ +
+
+ {#each messages as message, index (index)} + + {/each} +
+
+ +
+ +
+ + + + + + + diff --git a/vite/src/ChatMessage.svelte b/vite/src/ChatMessage.svelte new file mode 100644 index 0000000..b8a7c59 --- /dev/null +++ b/vite/src/ChatMessage.svelte @@ -0,0 +1,51 @@ + + + + +{#if message.role != 'system'} +
+ {@html renderedContent} +
+ {/if} \ No newline at end of file diff --git a/vite/src/Modal.svelte b/vite/src/Modal.svelte new file mode 100644 index 0000000..32981f9 --- /dev/null +++ b/vite/src/Modal.svelte @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/vite/src/main.js b/vite/src/main.js new file mode 100644 index 0000000..2647fee --- /dev/null +++ b/vite/src/main.js @@ -0,0 +1,33 @@ +// Styles +import './styles/site.scss' + +import ChatApp from './ChatApp.svelte' + +// import * as bootstrap from 'bootstrap/dist/js/bootstrap'; +// window.bootstrap = bootstrap; + +import {Modal, Alert} from 'bootstrap'; + +window.ChatApp = ChatApp; + +window.bootstrap = { + Modal: Modal, + Alert: Alert, +}; + +window.addEventListener('DOMContentLoaded', function() { + + /* + const hamburgerBtn = document.querySelector('.hamburger') + hamburgerBtn.addEventListener('click', function () { + this.classList.toggle('is-open') + this.classList.toggle('is-closed') + }) + */ + + const activeLink = document.querySelector('#mainmenu a[href="' + location.pathname + '"]'); + + if (activeLink) { + activeLink.parentNode.classList.add('active'); + } +}); diff --git a/vite/src/styles/_chat.scss b/vite/src/styles/_chat.scss new file mode 100644 index 0000000..e43024d --- /dev/null +++ b/vite/src/styles/_chat.scss @@ -0,0 +1,155 @@ +/* + +* { + box-sizing: border-box; + } + html, + body, + #app { + height: 100%; + } + body { + margin: 0; + font-family: -apple-system, "system-ui", "Segoe UI Adjusted", "Segoe UI", + "Liberation Sans", sans-serif; + } + pre { + overflow-x: auto; + border-radius: 6px; + } + button { + border: none; + background-color: transparent; + color: inherit; + padding: 0; + cursor: pointer; + } + ul { + list-style: none; + padding: 0; + margin: 0; + } +*/ +#chat-app { + height: 100%; + + display: flex; + flex-direction: column; + + header { + position: relative; + width: 100%; + height: 48px; + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + background-color: #333; + color: white; + + & > button { + position: absolute; + padding: 12px; + } + } + + main { + flex-grow: 1; + overflow-y: auto; + } + + button.menu { + left: 0; + } + button.clear { + right: 0; + } + .hidden { + display: none; + } + + footer { + width: 100%; + padding: 8px; + display: flex; + justify-content: center; + border-top: 1px solid rgba(0, 0, 0, 0.1); + } + + #input-box { + width: 100%; + max-width: 768px; + padding: 8px; + border-width: 1px; + border-color: rgba(0, 0, 0, 0.1); + border-radius: 6px; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); + min-height: 40px; + } + + .hljs-code-header { + display: flex; + justify-content: space-between; + padding: 3px 6px; + background-color: #9b9b9b; + color: #fff; + } +} + +/* + aside { + position: fixed; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + display: flex; + } + .sidebar-container { + background-color: #333; + color: white; + width: 300px; + overflow-y: auto; + } + .sidebar-modal { + flex: 1 0; + min-width: 64px; + background-color: rgba(0, 0, 0, 0.3); + } + li > button { + width: 100%; + height: 48px; + text-align: left; + padding: 0 16px; + transition: background-color 0.2s; + } + li > button:hover { + background-color: rgba(255, 255, 255, 0.1); + } + main { + flex-grow: 1; + overflow-y: auto; + } +*/ +.chat-container { + display: flex; + flex-direction: column; + width: 100%; + max-width: 768px; + margin: 0 auto; + overflow-wrap: break-word; +} + +.chat-container .message { + padding: 2px 16px; +} + +.chat-container code:not(.hljs) { + color: #4d4d4d; + padding: 0 5px; + display: inline-block; +} + +.chat-container .assistant { + background-color: #f0f0f0; +} \ No newline at end of file diff --git a/vite/src/styles/_fonts.scss b/vite/src/styles/_fonts.scss new file mode 100644 index 0000000..33f7730 --- /dev/null +++ b/vite/src/styles/_fonts.scss @@ -0,0 +1,63 @@ +@font-face { + font-family: 'icomoon'; + src: url('/fonts/icomoon.eot?ws5e0y'); + src: url('/fonts/icomoon.eot?ws5e0y#iefix') format('embedded-opentype'), + url('/fonts/icomoon.ttf?ws5e0y') format('truetype'), + url('/fonts/icomoon.woff?ws5e0y') format('woff'), + url('/fonts/icomoon.svg?ws5e0y#icomoon') format('svg'); + font-weight: normal; + font-style: normal; + font-display: block; + } + + [class^="icon-"], [class*=" icon-"] { + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: 'icomoon' !important; + speak: never; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .icon-error:before { + content: "\e900"; + } + .icon-warning:before { + content: "\e901"; + } + .icon-loop:before { + content: "\e902"; + } + .icon-mic:before { + content: "\e903"; + } + .icon-library_add:before { + content: "\e90a"; + } + .icon-add:before { + content: "\e904"; + } + .icon-edit:before { + content: "\e905"; + } + .icon-file_download:before { + content: "\e908"; + } + .icon-file_upload:before { + content: "\e909"; + } + .icon-delete:before { + content: "\e906"; + } + .icon-settings:before { + content: "\e90b"; + } + .icon-clean:before { + content: "\e907"; + } diff --git a/vite/src/styles/_hamburger.scss b/vite/src/styles/_hamburger.scss new file mode 100644 index 0000000..53fb7f2 --- /dev/null +++ b/vite/src/styles/_hamburger.scss @@ -0,0 +1,113 @@ +// ------------------------------- +// Hamburger-Cross +// ------------------------------- + +// https://codepen.io/djdabe/pen/qXgJNV + +.hamburger { + + display: block; + width: 32px; + height: 32px; + background: transparent; + border: none; + outline: none; + + .hamb-top, .hamb-middle, .hamb-bottom { + position: absolute; + left: 0; + height: 4px; + width: 100%; + background-color: #1a1a1a; + } + + &.is-closed { + &:before { + content: ''; + display: block; + width: 100px; + font-size: 14px; + color: #fff; + line-height: 32px; + text-align: center; + opacity: 0; + -webkit-transform: translate3d(0,0,0); + -webkit-transition: all .35s ease-in-out; + } + + &:hover{ + &:before { + opacity: 1; + display: block; + transform: translate3d(-100px, 0, 0); + transition: all .35s ease-in-out; + } + + .hamb-top { + top: 0; + transition: all .35s ease-in-out; + } + .hamb-bottom { + bottom: 0; + transition: all .35s ease-in-out; + } + } + + .hamb-top { + top: 5px; + -webkit-transition: all .35s ease-in-out; + } + .hamb-middle { + top: 50%; + margin-top: -2px; + } + .hamb-bottom { + bottom: 5px; + -webkit-transition: all .35s ease-in-out; + } + } + + &.is-open { + .hamb-top, + .hamb-bottom { + top: 50%; + margin-top: -2px; + } + .hamb-top { + -webkit-transform: rotate(45deg); + -webkit-transition: -webkit-transform .2s cubic-bezier(.73,1,.28,.08); + } + .hamb-middle { + display: none; + } + .hamb-bottom { + -webkit-transform: rotate(-45deg); + -webkit-transition: -webkit-transform .2s cubic-bezier(.73,1,.28,.08); + } + &:before { + content: ''; + display: block; + width: 100px; + font-size: 14px; + color: #fff; + line-height: 32px; + text-align: center; + opacity: 0; + -webkit-transform: translate3d(0,0,0); + -webkit-transition: all .35s ease-in-out; + } + &:hover:before { + opacity: 1; + display: block; + -webkit-transform: translate3d(-100px,0,0); + -webkit-transition: all .35s ease-in-out; + } + } +} + + + + + + + diff --git a/vite/src/styles/site.scss b/vite/src/styles/site.scss new file mode 100644 index 0000000..517edc3 --- /dev/null +++ b/vite/src/styles/site.scss @@ -0,0 +1,66 @@ +@import "~bootstrap/scss/bootstrap"; +@import "~highlightjs/scss/github"; + +@import "fonts"; +@import "hamburger"; + +// @import "chat"; + +html, +body { + height: 100%; +} + +body { + background-color: #cccccc; +} + +#navBtn { + position: fixed; + top: 10px; + left: 10px; + z-index: 999; +} + +#chat-app { + height: 100%; + display: flex; + flex-direction: column; +} + +#chat { + white-space: pre-wrap; + + .user { + color: #ffeaa4; + } + + .assistant { + color: #ffffff; + } +} + + +.form-signin { + max-width: 330px; + padding: 1rem; + + .form-floating:focus-within { + z-index: 2; + } + + input[type="text"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + input[type="password"] { + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; + } +} + + + diff --git a/vite/svelte.config.js b/vite/svelte.config.js new file mode 100644 index 0000000..b0683fd --- /dev/null +++ b/vite/svelte.config.js @@ -0,0 +1,7 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/vite/vite.config.js b/vite/vite.config.js new file mode 100644 index 0000000..7f556b1 --- /dev/null +++ b/vite/vite.config.js @@ -0,0 +1,65 @@ +// View your website at your own local server +// for example http://vite-php-setup.test + +// http://localhost:5133 is serving Vite on development +// but accessing it directly will be empty +// TIP: consider changing the port for each project, see below + +// IMPORTANT image urls in CSS works fine +// BUT you need to create a symlink on dev server to map this folder during dev: +// ln -s {path_to_project_source}/src/assets {path_to_public_html}/assets +// on production everything will work just fine +// (this happens because our Vite code is outside the server public access, +// if it where, we could use https://vitejs.dev/config/server-options.html#server-origin) + +import { defineConfig, splitVendorChunkPlugin } from 'vite' +import liveReload from 'vite-plugin-live-reload' +import path from 'path' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import svelteConfig from './svelte.config.js' // Configuration Svelte + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + liveReload([ + // edit live reload paths according to your source code + __dirname + '/../modules/**/*.php', + __dirname + '/../config/*.php', + __dirname + '/../web/*.php', + ]), + splitVendorChunkPlugin(), + svelte(svelteConfig), + ], + root: 'src', + base: process.env.APP_ENV === 'development' + ? '/dev/' + : '/', + + build: { + // Output dir for production build + outDir: '../../web', + emptyOutDir: false, + + // Emit manifest so PHP can find the hashed files + manifest: true, + + // Our entry + rollupOptions: { + input: path.resolve(__dirname, 'src/main.js'), + } + }, + + resolve: { + alias: { + '~bootstrap': path.resolve(__dirname, 'node_modules/bootstrap'), + '~highlightjs': path.resolve(__dirname, 'node_modules/highlight.js'), + } + }, + + server: { + // we need a strict port to match on PHP side + // change freely, but update on PHP to match the same port + strictPort: true, + port: 5133 + }, +}) diff --git a/web/.user.ini b/web/.user.ini new file mode 100644 index 0000000..942c9e5 --- /dev/null +++ b/web/.user.ini @@ -0,0 +1 @@ +output_buffering = Off diff --git a/web/favicon.ico b/web/favicon.ico new file mode 100644 index 0000000..db6be0a Binary files /dev/null and b/web/favicon.ico differ diff --git a/web/fonts/icomoon.eot b/web/fonts/icomoon.eot new file mode 100644 index 0000000..57e5bd8 Binary files /dev/null and b/web/fonts/icomoon.eot differ diff --git a/web/fonts/icomoon.svg b/web/fonts/icomoon.svg new file mode 100644 index 0000000..c555534 --- /dev/null +++ b/web/fonts/icomoon.svg @@ -0,0 +1,22 @@ + + + +Generated by IcoMoon + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/fonts/icomoon.ttf b/web/fonts/icomoon.ttf new file mode 100644 index 0000000..93e7d22 Binary files /dev/null and b/web/fonts/icomoon.ttf differ diff --git a/web/fonts/icomoon.woff b/web/fonts/icomoon.woff new file mode 100644 index 0000000..faadfa3 Binary files /dev/null and b/web/fonts/icomoon.woff differ diff --git a/web/index.php b/web/index.php new file mode 100644 index 0000000..facb31c --- /dev/null +++ b/web/index.php @@ -0,0 +1,22 @@ + $val) { + putenv("{$key}={$val}"); +} + +if (getenv('APP_DEBUG')) { + Debugger::enable(Debugger::DEVELOPMENT); +} + +$config = require __DIR__ . '/../config/app.php'; + +$app = new ModularApplication($config); + +$app->pipe(new \app\lib\AuthMiddleware($app)); +$app->pipe(new \app\lib\CorsMiddleware()); + +$app->run(); diff --git a/web/js/DataTables/datatables.css b/web/js/DataTables/datatables.css new file mode 100644 index 0000000..e320547 --- /dev/null +++ b/web/js/DataTables/datatables.css @@ -0,0 +1,451 @@ +/* + * This combined file was created by the DataTables downloader builder: + * https://datatables.net/download + * + * To rebuild or modify this file with the latest versions of the included + * software please visit: + * https://datatables.net/download/#bs5/dt-1.13.6 + * + * Included libraries: + * DataTables 1.13.6 + */ + +@charset "UTF-8"; +:root { + --dt-row-selected: 13, 110, 253; + --dt-row-selected-text: 255, 255, 255; + --dt-row-selected-link: 9, 10, 11; + --dt-row-stripe: 0, 0, 0; + --dt-row-hover: 0, 0, 0; + --dt-column-ordering: 0, 0, 0; + --dt-html-background: white; +} +:root.dark { + --dt-html-background: rgb(33, 37, 41); +} + +table.dataTable td.dt-control { + text-align: center; + cursor: pointer; +} +table.dataTable td.dt-control:before { + display: inline-block; + color: rgba(0, 0, 0, 0.5); + content: "►"; +} +table.dataTable tr.dt-hasChild td.dt-control:before { + content: "▼"; +} + +html.dark table.dataTable td.dt-control:before { + color: rgba(255, 255, 255, 0.5); +} +html.dark table.dataTable tr.dt-hasChild td.dt-control:before { + color: rgba(255, 255, 255, 0.5); +} + +table.dataTable thead > tr > th.sorting, table.dataTable thead > tr > th.sorting_asc, table.dataTable thead > tr > th.sorting_desc, table.dataTable thead > tr > th.sorting_asc_disabled, table.dataTable thead > tr > th.sorting_desc_disabled, +table.dataTable thead > tr > td.sorting, +table.dataTable thead > tr > td.sorting_asc, +table.dataTable thead > tr > td.sorting_desc, +table.dataTable thead > tr > td.sorting_asc_disabled, +table.dataTable thead > tr > td.sorting_desc_disabled { + cursor: pointer; + position: relative; + padding-right: 26px; +} +table.dataTable thead > tr > th.sorting:before, table.dataTable thead > tr > th.sorting:after, table.dataTable thead > tr > th.sorting_asc:before, table.dataTable thead > tr > th.sorting_asc:after, table.dataTable thead > tr > th.sorting_desc:before, table.dataTable thead > tr > th.sorting_desc:after, table.dataTable thead > tr > th.sorting_asc_disabled:before, table.dataTable thead > tr > th.sorting_asc_disabled:after, table.dataTable thead > tr > th.sorting_desc_disabled:before, table.dataTable thead > tr > th.sorting_desc_disabled:after, +table.dataTable thead > tr > td.sorting:before, +table.dataTable thead > tr > td.sorting:after, +table.dataTable thead > tr > td.sorting_asc:before, +table.dataTable thead > tr > td.sorting_asc:after, +table.dataTable thead > tr > td.sorting_desc:before, +table.dataTable thead > tr > td.sorting_desc:after, +table.dataTable thead > tr > td.sorting_asc_disabled:before, +table.dataTable thead > tr > td.sorting_asc_disabled:after, +table.dataTable thead > tr > td.sorting_desc_disabled:before, +table.dataTable thead > tr > td.sorting_desc_disabled:after { + position: absolute; + display: block; + opacity: 0.125; + right: 10px; + line-height: 9px; + font-size: 0.8em; +} +table.dataTable thead > tr > th.sorting:before, table.dataTable thead > tr > th.sorting_asc:before, table.dataTable thead > tr > th.sorting_desc:before, table.dataTable thead > tr > th.sorting_asc_disabled:before, table.dataTable thead > tr > th.sorting_desc_disabled:before, +table.dataTable thead > tr > td.sorting:before, +table.dataTable thead > tr > td.sorting_asc:before, +table.dataTable thead > tr > td.sorting_desc:before, +table.dataTable thead > tr > td.sorting_asc_disabled:before, +table.dataTable thead > tr > td.sorting_desc_disabled:before { + bottom: 50%; + content: "▲"; + content: "▲"/""; +} +table.dataTable thead > tr > th.sorting:after, table.dataTable thead > tr > th.sorting_asc:after, table.dataTable thead > tr > th.sorting_desc:after, table.dataTable thead > tr > th.sorting_asc_disabled:after, table.dataTable thead > tr > th.sorting_desc_disabled:after, +table.dataTable thead > tr > td.sorting:after, +table.dataTable thead > tr > td.sorting_asc:after, +table.dataTable thead > tr > td.sorting_desc:after, +table.dataTable thead > tr > td.sorting_asc_disabled:after, +table.dataTable thead > tr > td.sorting_desc_disabled:after { + top: 50%; + content: "▼"; + content: "▼"/""; +} +table.dataTable thead > tr > th.sorting_asc:before, table.dataTable thead > tr > th.sorting_desc:after, +table.dataTable thead > tr > td.sorting_asc:before, +table.dataTable thead > tr > td.sorting_desc:after { + opacity: 0.6; +} +table.dataTable thead > tr > th.sorting_desc_disabled:after, table.dataTable thead > tr > th.sorting_asc_disabled:before, +table.dataTable thead > tr > td.sorting_desc_disabled:after, +table.dataTable thead > tr > td.sorting_asc_disabled:before { + display: none; +} +table.dataTable thead > tr > th:active, +table.dataTable thead > tr > td:active { + outline: none; +} + +div.dataTables_scrollBody > table.dataTable > thead > tr > th:before, div.dataTables_scrollBody > table.dataTable > thead > tr > th:after, +div.dataTables_scrollBody > table.dataTable > thead > tr > td:before, +div.dataTables_scrollBody > table.dataTable > thead > tr > td:after { + display: none; +} + +div.dataTables_processing { + position: absolute; + top: 50%; + left: 50%; + width: 200px; + margin-left: -100px; + margin-top: -26px; + text-align: center; + padding: 2px; +} +div.dataTables_processing > div:last-child { + position: relative; + width: 80px; + height: 15px; + margin: 1em auto; +} +div.dataTables_processing > div:last-child > div { + position: absolute; + top: 0; + width: 13px; + height: 13px; + border-radius: 50%; + background: rgb(13, 110, 253); + background: rgb(var(--dt-row-selected)); + animation-timing-function: cubic-bezier(0, 1, 1, 0); +} +div.dataTables_processing > div:last-child > div:nth-child(1) { + left: 8px; + animation: datatables-loader-1 0.6s infinite; +} +div.dataTables_processing > div:last-child > div:nth-child(2) { + left: 8px; + animation: datatables-loader-2 0.6s infinite; +} +div.dataTables_processing > div:last-child > div:nth-child(3) { + left: 32px; + animation: datatables-loader-2 0.6s infinite; +} +div.dataTables_processing > div:last-child > div:nth-child(4) { + left: 56px; + animation: datatables-loader-3 0.6s infinite; +} + +@keyframes datatables-loader-1 { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } +} +@keyframes datatables-loader-3 { + 0% { + transform: scale(1); + } + 100% { + transform: scale(0); + } +} +@keyframes datatables-loader-2 { + 0% { + transform: translate(0, 0); + } + 100% { + transform: translate(24px, 0); + } +} +table.dataTable.nowrap th, table.dataTable.nowrap td { + white-space: nowrap; +} +table.dataTable th.dt-left, +table.dataTable td.dt-left { + text-align: left; +} +table.dataTable th.dt-center, +table.dataTable td.dt-center, +table.dataTable td.dataTables_empty { + text-align: center; +} +table.dataTable th.dt-right, +table.dataTable td.dt-right { + text-align: right; +} +table.dataTable th.dt-justify, +table.dataTable td.dt-justify { + text-align: justify; +} +table.dataTable th.dt-nowrap, +table.dataTable td.dt-nowrap { + white-space: nowrap; +} +table.dataTable thead th, +table.dataTable thead td, +table.dataTable tfoot th, +table.dataTable tfoot td { + text-align: left; +} +table.dataTable thead th.dt-head-left, +table.dataTable thead td.dt-head-left, +table.dataTable tfoot th.dt-head-left, +table.dataTable tfoot td.dt-head-left { + text-align: left; +} +table.dataTable thead th.dt-head-center, +table.dataTable thead td.dt-head-center, +table.dataTable tfoot th.dt-head-center, +table.dataTable tfoot td.dt-head-center { + text-align: center; +} +table.dataTable thead th.dt-head-right, +table.dataTable thead td.dt-head-right, +table.dataTable tfoot th.dt-head-right, +table.dataTable tfoot td.dt-head-right { + text-align: right; +} +table.dataTable thead th.dt-head-justify, +table.dataTable thead td.dt-head-justify, +table.dataTable tfoot th.dt-head-justify, +table.dataTable tfoot td.dt-head-justify { + text-align: justify; +} +table.dataTable thead th.dt-head-nowrap, +table.dataTable thead td.dt-head-nowrap, +table.dataTable tfoot th.dt-head-nowrap, +table.dataTable tfoot td.dt-head-nowrap { + white-space: nowrap; +} +table.dataTable tbody th.dt-body-left, +table.dataTable tbody td.dt-body-left { + text-align: left; +} +table.dataTable tbody th.dt-body-center, +table.dataTable tbody td.dt-body-center { + text-align: center; +} +table.dataTable tbody th.dt-body-right, +table.dataTable tbody td.dt-body-right { + text-align: right; +} +table.dataTable tbody th.dt-body-justify, +table.dataTable tbody td.dt-body-justify { + text-align: justify; +} +table.dataTable tbody th.dt-body-nowrap, +table.dataTable tbody td.dt-body-nowrap { + white-space: nowrap; +} + +/*! Bootstrap 5 integration for DataTables + * + * ©2020 SpryMedia Ltd, all rights reserved. + * License: MIT datatables.net/license/mit + */ +table.dataTable { + clear: both; + margin-top: 6px !important; + margin-bottom: 6px !important; + max-width: none !important; + border-collapse: separate !important; + border-spacing: 0; +} +table.dataTable td, +table.dataTable th { + -webkit-box-sizing: content-box; + box-sizing: content-box; +} +table.dataTable td.dataTables_empty, +table.dataTable th.dataTables_empty { + text-align: center; +} +table.dataTable.nowrap th, +table.dataTable.nowrap td { + white-space: nowrap; +} +table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * { + box-shadow: none; +} +table.dataTable > tbody > tr { + background-color: transparent; +} +table.dataTable > tbody > tr.selected > * { + box-shadow: inset 0 0 0 9999px rgb(13, 110, 253); + box-shadow: inset 0 0 0 9999px rgb(var(--dt-row-selected)); + color: rgb(255, 255, 255); + color: rgb(var(--dt-row-selected-text)); +} +table.dataTable > tbody > tr.selected a { + color: rgb(9, 10, 11); + color: rgb(var(--dt-row-selected-link)); +} +table.dataTable.table-striped > tbody > tr.odd > * { + box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-stripe), 0.05); +} +table.dataTable.table-striped > tbody > tr.odd.selected > * { + box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.95); + box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.95); +} +table.dataTable.table-hover > tbody > tr:hover > * { + box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-hover), 0.075); +} +table.dataTable.table-hover > tbody > tr.selected:hover > * { + box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.975); + box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975); +} + +div.dataTables_wrapper div.dataTables_length label { + font-weight: normal; + text-align: left; + white-space: nowrap; +} +div.dataTables_wrapper div.dataTables_length select { + width: auto; + display: inline-block; +} +div.dataTables_wrapper div.dataTables_filter { + text-align: right; +} +div.dataTables_wrapper div.dataTables_filter label { + font-weight: normal; + white-space: nowrap; + text-align: left; +} +div.dataTables_wrapper div.dataTables_filter input { + margin-left: 0.5em; + display: inline-block; + width: auto; +} +div.dataTables_wrapper div.dataTables_info { + padding-top: 0.85em; +} +div.dataTables_wrapper div.dataTables_paginate { + margin: 0; + white-space: nowrap; + text-align: right; +} +div.dataTables_wrapper div.dataTables_paginate ul.pagination { + margin: 2px 0; + white-space: nowrap; + justify-content: flex-end; +} +div.dataTables_wrapper div.dt-row { + position: relative; +} + +div.dataTables_scrollHead table.dataTable { + margin-bottom: 0 !important; +} + +div.dataTables_scrollBody > table { + border-top: none; + margin-top: 0 !important; + margin-bottom: 0 !important; +} +div.dataTables_scrollBody > table > thead .sorting:before, +div.dataTables_scrollBody > table > thead .sorting_asc:before, +div.dataTables_scrollBody > table > thead .sorting_desc:before, +div.dataTables_scrollBody > table > thead .sorting:after, +div.dataTables_scrollBody > table > thead .sorting_asc:after, +div.dataTables_scrollBody > table > thead .sorting_desc:after { + display: none; +} +div.dataTables_scrollBody > table > tbody tr:first-child th, +div.dataTables_scrollBody > table > tbody tr:first-child td { + border-top: none; +} + +div.dataTables_scrollFoot > .dataTables_scrollFootInner { + box-sizing: content-box; +} +div.dataTables_scrollFoot > .dataTables_scrollFootInner > table { + margin-top: 0 !important; + border-top: none; +} + +@media screen and (max-width: 767px) { + div.dataTables_wrapper div.dataTables_length, + div.dataTables_wrapper div.dataTables_filter, + div.dataTables_wrapper div.dataTables_info, + div.dataTables_wrapper div.dataTables_paginate { + text-align: center; + } + div.dataTables_wrapper div.dataTables_paginate ul.pagination { + justify-content: center !important; + } +} +table.dataTable.table-sm > thead > tr > th:not(.sorting_disabled) { + padding-right: 20px; +} + +table.table-bordered.dataTable { + border-right-width: 0; +} +table.table-bordered.dataTable thead tr:first-child th, +table.table-bordered.dataTable thead tr:first-child td { + border-top-width: 1px; +} +table.table-bordered.dataTable th, +table.table-bordered.dataTable td { + border-left-width: 0; +} +table.table-bordered.dataTable th:first-child, table.table-bordered.dataTable th:first-child, +table.table-bordered.dataTable td:first-child, +table.table-bordered.dataTable td:first-child { + border-left-width: 1px; +} +table.table-bordered.dataTable th:last-child, table.table-bordered.dataTable th:last-child, +table.table-bordered.dataTable td:last-child, +table.table-bordered.dataTable td:last-child { + border-right-width: 1px; +} +table.table-bordered.dataTable th, +table.table-bordered.dataTable td { + border-bottom-width: 1px; +} + +div.dataTables_scrollHead table.table-bordered { + border-bottom-width: 0; +} + +div.table-responsive > div.dataTables_wrapper > div.row { + margin: 0; +} +div.table-responsive > div.dataTables_wrapper > div.row > div[class^=col-]:first-child { + padding-left: 0; +} +div.table-responsive > div.dataTables_wrapper > div.row > div[class^=col-]:last-child { + padding-right: 0; +} + +:root[data-bs-theme=dark] { + --dt-row-hover: 255, 255, 255; + --dt-row-stripe: 255, 255, 255; + --dt-column-ordering: 255, 255, 255; +} + + diff --git a/web/js/DataTables/datatables.js b/web/js/DataTables/datatables.js new file mode 100644 index 0000000..735ba65 --- /dev/null +++ b/web/js/DataTables/datatables.js @@ -0,0 +1,15939 @@ +/* + * This combined file was created by the DataTables downloader builder: + * https://datatables.net/download + * + * To rebuild or modify this file with the latest versions of the included + * software please visit: + * https://datatables.net/download/#bs5/dt-1.13.6 + * + * Included libraries: + * DataTables 1.13.6 + */ + +/*! DataTables 1.13.6 + * ©2008-2023 SpryMedia Ltd - datatables.net/license + */ + +/** + * @summary DataTables + * @description Paginate, search and order HTML tables + * @version 1.13.6 + * @author SpryMedia Ltd + * @contact www.datatables.net + * @copyright SpryMedia Ltd. + * + * This source file is free software, available under the following license: + * MIT license - http://datatables.net/license + * + * This source file is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the license files for details. + * + * For details please refer to: http://www.datatables.net + */ + +/*jslint evil: true, undef: true, browser: true */ +/*globals $,require,jQuery,define,_selector_run,_selector_opts,_selector_first,_selector_row_indexes,_ext,_Api,_api_register,_api_registerPlural,_re_new_lines,_re_html,_re_formatted_numeric,_re_escape_regex,_empty,_intVal,_numToDecimal,_isNumber,_isHtml,_htmlNumeric,_pluck,_pluck_order,_range,_stripHtml,_unique,_fnBuildAjax,_fnAjaxUpdate,_fnAjaxParameters,_fnAjaxUpdateDraw,_fnAjaxDataSrc,_fnAddColumn,_fnColumnOptions,_fnAdjustColumnSizing,_fnVisibleToColumnIndex,_fnColumnIndexToVisible,_fnVisbleColumns,_fnGetColumns,_fnColumnTypes,_fnApplyColumnDefs,_fnHungarianMap,_fnCamelToHungarian,_fnLanguageCompat,_fnBrowserDetect,_fnAddData,_fnAddTr,_fnNodeToDataIndex,_fnNodeToColumnIndex,_fnGetCellData,_fnSetCellData,_fnSplitObjNotation,_fnGetObjectDataFn,_fnSetObjectDataFn,_fnGetDataMaster,_fnClearTable,_fnDeleteIndex,_fnInvalidate,_fnGetRowElements,_fnCreateTr,_fnBuildHead,_fnDrawHead,_fnDraw,_fnReDraw,_fnAddOptionsHtml,_fnDetectHeader,_fnGetUniqueThs,_fnFeatureHtmlFilter,_fnFilterComplete,_fnFilterCustom,_fnFilterColumn,_fnFilter,_fnFilterCreateSearch,_fnEscapeRegex,_fnFilterData,_fnFeatureHtmlInfo,_fnUpdateInfo,_fnInfoMacros,_fnInitialise,_fnInitComplete,_fnLengthChange,_fnFeatureHtmlLength,_fnFeatureHtmlPaginate,_fnPageChange,_fnFeatureHtmlProcessing,_fnProcessingDisplay,_fnFeatureHtmlTable,_fnScrollDraw,_fnApplyToChildren,_fnCalculateColumnWidths,_fnThrottle,_fnConvertToWidth,_fnGetWidestNode,_fnGetMaxLenString,_fnStringToCss,_fnSortFlatten,_fnSort,_fnSortAria,_fnSortListener,_fnSortAttachListener,_fnSortingClasses,_fnSortData,_fnSaveState,_fnLoadState,_fnSettingsFromNode,_fnLog,_fnMap,_fnBindAction,_fnCallbackReg,_fnCallbackFire,_fnLengthOverflow,_fnRenderer,_fnDataSource,_fnRowAttributes*/ + +(function( factory ) { + "use strict"; + + if ( typeof define === 'function' && define.amd ) { + // AMD + define( ['jquery'], function ( $ ) { + return factory( $, window, document ); + } ); + } + else if ( typeof exports === 'object' ) { + // CommonJS + // jQuery's factory checks for a global window - if it isn't present then it + // returns a factory function that expects the window object + var jq = require('jquery'); + + if (typeof window === 'undefined') { + module.exports = function (root, $) { + if ( ! root ) { + // CommonJS environments without a window global must pass a + // root. This will give an error otherwise + root = window; + } + + if ( ! $ ) { + $ = jq( root ); + } + + return factory( $, root, root.document ); + }; + } + else { + return factory( jq, window, window.document ); + } + } + else { + // Browser + window.DataTable = factory( jQuery, window, document ); + } +} +(function( $, window, document, undefined ) { + "use strict"; + + + var DataTable = function ( selector, options ) + { + // Check if called with a window or jQuery object for DOM less applications + // This is for backwards compatibility + if (DataTable.factory(selector, options)) { + return DataTable; + } + + // When creating with `new`, create a new DataTable, returning the API instance + if (this instanceof DataTable) { + return $(selector).DataTable(options); + } + else { + // Argument switching + options = selector; + } + + /** + * Perform a jQuery selector action on the table's TR elements (from the tbody) and + * return the resulting jQuery object. + * @param {string|node|jQuery} sSelector jQuery selector or node collection to act on + * @param {object} [oOpts] Optional parameters for modifying the rows to be included + * @param {string} [oOpts.filter=none] Select TR elements that meet the current filter + * criterion ("applied") or all TR elements (i.e. no filter). + * @param {string} [oOpts.order=current] Order of the TR elements in the processed array. + * Can be either 'current', whereby the current sorting of the table is used, or + * 'original' whereby the original order the data was read into the table is used. + * @param {string} [oOpts.page=all] Limit the selection to the currently displayed page + * ("current") or not ("all"). If 'current' is given, then order is assumed to be + * 'current' and filter is 'applied', regardless of what they might be given as. + * @returns {object} jQuery object, filtered by the given selector. + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Highlight every second row + * oTable.$('tr:odd').css('backgroundColor', 'blue'); + * } ); + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Filter to rows with 'Webkit' in them, add a background colour and then + * // remove the filter, thus highlighting the 'Webkit' rows only. + * oTable.fnFilter('Webkit'); + * oTable.$('tr', {"search": "applied"}).css('backgroundColor', 'blue'); + * oTable.fnFilter(''); + * } ); + */ + this.$ = function ( sSelector, oOpts ) + { + return this.api(true).$( sSelector, oOpts ); + }; + + + /** + * Almost identical to $ in operation, but in this case returns the data for the matched + * rows - as such, the jQuery selector used should match TR row nodes or TD/TH cell nodes + * rather than any descendants, so the data can be obtained for the row/cell. If matching + * rows are found, the data returned is the original data array/object that was used to + * create the row (or a generated array if from a DOM source). + * + * This method is often useful in-combination with $ where both functions are given the + * same parameters and the array indexes will match identically. + * @param {string|node|jQuery} sSelector jQuery selector or node collection to act on + * @param {object} [oOpts] Optional parameters for modifying the rows to be included + * @param {string} [oOpts.filter=none] Select elements that meet the current filter + * criterion ("applied") or all elements (i.e. no filter). + * @param {string} [oOpts.order=current] Order of the data in the processed array. + * Can be either 'current', whereby the current sorting of the table is used, or + * 'original' whereby the original order the data was read into the table is used. + * @param {string} [oOpts.page=all] Limit the selection to the currently displayed page + * ("current") or not ("all"). If 'current' is given, then order is assumed to be + * 'current' and filter is 'applied', regardless of what they might be given as. + * @returns {array} Data for the matched elements. If any elements, as a result of the + * selector, were not TR, TD or TH elements in the DataTable, they will have a null + * entry in the array. + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Get the data from the first row in the table + * var data = oTable._('tr:first'); + * + * // Do something useful with the data + * alert( "First cell is: "+data[0] ); + * } ); + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Filter to 'Webkit' and get all data for + * oTable.fnFilter('Webkit'); + * var data = oTable._('tr', {"search": "applied"}); + * + * // Do something with the data + * alert( data.length+" rows matched the search" ); + * } ); + */ + this._ = function ( sSelector, oOpts ) + { + return this.api(true).rows( sSelector, oOpts ).data(); + }; + + + /** + * Create a DataTables Api instance, with the currently selected tables for + * the Api's context. + * @param {boolean} [traditional=false] Set the API instance's context to be + * only the table referred to by the `DataTable.ext.iApiIndex` option, as was + * used in the API presented by DataTables 1.9- (i.e. the traditional mode), + * or if all tables captured in the jQuery object should be used. + * @return {DataTables.Api} + */ + this.api = function ( traditional ) + { + return traditional ? + new _Api( + _fnSettingsFromNode( this[ _ext.iApiIndex ] ) + ) : + new _Api( this ); + }; + + + /** + * Add a single new row or multiple rows of data to the table. Please note + * that this is suitable for client-side processing only - if you are using + * server-side processing (i.e. "bServerSide": true), then to add data, you + * must add it to the data source, i.e. the server-side, through an Ajax call. + * @param {array|object} data The data to be added to the table. This can be: + *
    + *
  • 1D array of data - add a single row with the data provided
  • + *
  • 2D array of arrays - add multiple rows in a single call
  • + *
  • object - data object when using mData
  • + *
  • array of objects - multiple data objects when using mData
  • + *
+ * @param {bool} [redraw=true] redraw the table or not + * @returns {array} An array of integers, representing the list of indexes in + * aoData ({@link DataTable.models.oSettings}) that have been added to + * the table. + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * // Global var for counter + * var giCount = 2; + * + * $(document).ready(function() { + * $('#example').dataTable(); + * } ); + * + * function fnClickAddRow() { + * $('#example').dataTable().fnAddData( [ + * giCount+".1", + * giCount+".2", + * giCount+".3", + * giCount+".4" ] + * ); + * + * giCount++; + * } + */ + this.fnAddData = function( data, redraw ) + { + var api = this.api( true ); + + /* Check if we want to add multiple rows or not */ + var rows = Array.isArray(data) && ( Array.isArray(data[0]) || $.isPlainObject(data[0]) ) ? + api.rows.add( data ) : + api.row.add( data ); + + if ( redraw === undefined || redraw ) { + api.draw(); + } + + return rows.flatten().toArray(); + }; + + + /** + * This function will make DataTables recalculate the column sizes, based on the data + * contained in the table and the sizes applied to the columns (in the DOM, CSS or + * through the sWidth parameter). This can be useful when the width of the table's + * parent element changes (for example a window resize). + * @param {boolean} [bRedraw=true] Redraw the table or not, you will typically want to + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable( { + * "sScrollY": "200px", + * "bPaginate": false + * } ); + * + * $(window).on('resize', function () { + * oTable.fnAdjustColumnSizing(); + * } ); + * } ); + */ + this.fnAdjustColumnSizing = function ( bRedraw ) + { + var api = this.api( true ).columns.adjust(); + var settings = api.settings()[0]; + var scroll = settings.oScroll; + + if ( bRedraw === undefined || bRedraw ) { + api.draw( false ); + } + else if ( scroll.sX !== "" || scroll.sY !== "" ) { + /* If not redrawing, but scrolling, we want to apply the new column sizes anyway */ + _fnScrollDraw( settings ); + } + }; + + + /** + * Quickly and simply clear a table + * @param {bool} [bRedraw=true] redraw the table or not + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Immediately 'nuke' the current rows (perhaps waiting for an Ajax callback...) + * oTable.fnClearTable(); + * } ); + */ + this.fnClearTable = function( bRedraw ) + { + var api = this.api( true ).clear(); + + if ( bRedraw === undefined || bRedraw ) { + api.draw(); + } + }; + + + /** + * The exact opposite of 'opening' a row, this function will close any rows which + * are currently 'open'. + * @param {node} nTr the table row to 'close' + * @returns {int} 0 on success, or 1 if failed (can't find the row) + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable; + * + * // 'open' an information row when a row is clicked on + * $('#example tbody tr').click( function () { + * if ( oTable.fnIsOpen(this) ) { + * oTable.fnClose( this ); + * } else { + * oTable.fnOpen( this, "Temporary row opened", "info_row" ); + * } + * } ); + * + * oTable = $('#example').dataTable(); + * } ); + */ + this.fnClose = function( nTr ) + { + this.api( true ).row( nTr ).child.hide(); + }; + + + /** + * Remove a row for the table + * @param {mixed} target The index of the row from aoData to be deleted, or + * the TR element you want to delete + * @param {function|null} [callBack] Callback function + * @param {bool} [redraw=true] Redraw the table or not + * @returns {array} The row that was deleted + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Immediately remove the first row + * oTable.fnDeleteRow( 0 ); + * } ); + */ + this.fnDeleteRow = function( target, callback, redraw ) + { + var api = this.api( true ); + var rows = api.rows( target ); + var settings = rows.settings()[0]; + var data = settings.aoData[ rows[0][0] ]; + + rows.remove(); + + if ( callback ) { + callback.call( this, settings, data ); + } + + if ( redraw === undefined || redraw ) { + api.draw(); + } + + return data; + }; + + + /** + * Restore the table to it's original state in the DOM by removing all of DataTables + * enhancements, alterations to the DOM structure of the table and event listeners. + * @param {boolean} [remove=false] Completely remove the table from the DOM + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * // This example is fairly pointless in reality, but shows how fnDestroy can be used + * var oTable = $('#example').dataTable(); + * oTable.fnDestroy(); + * } ); + */ + this.fnDestroy = function ( remove ) + { + this.api( true ).destroy( remove ); + }; + + + /** + * Redraw the table + * @param {bool} [complete=true] Re-filter and resort (if enabled) the table before the draw. + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Re-draw the table - you wouldn't want to do it here, but it's an example :-) + * oTable.fnDraw(); + * } ); + */ + this.fnDraw = function( complete ) + { + // Note that this isn't an exact match to the old call to _fnDraw - it takes + // into account the new data, but can hold position. + this.api( true ).draw( complete ); + }; + + + /** + * Filter the input based on data + * @param {string} sInput String to filter the table on + * @param {int|null} [iColumn] Column to limit filtering to + * @param {bool} [bRegex=false] Treat as regular expression or not + * @param {bool} [bSmart=true] Perform smart filtering or not + * @param {bool} [bShowGlobal=true] Show the input global filter in it's input box(es) + * @param {bool} [bCaseInsensitive=true] Do case-insensitive matching (true) or not (false) + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Sometime later - filter... + * oTable.fnFilter( 'test string' ); + * } ); + */ + this.fnFilter = function( sInput, iColumn, bRegex, bSmart, bShowGlobal, bCaseInsensitive ) + { + var api = this.api( true ); + + if ( iColumn === null || iColumn === undefined ) { + api.search( sInput, bRegex, bSmart, bCaseInsensitive ); + } + else { + api.column( iColumn ).search( sInput, bRegex, bSmart, bCaseInsensitive ); + } + + api.draw(); + }; + + + /** + * Get the data for the whole table, an individual row or an individual cell based on the + * provided parameters. + * @param {int|node} [src] A TR row node, TD/TH cell node or an integer. If given as + * a TR node then the data source for the whole row will be returned. If given as a + * TD/TH cell node then iCol will be automatically calculated and the data for the + * cell returned. If given as an integer, then this is treated as the aoData internal + * data index for the row (see fnGetPosition) and the data for that row used. + * @param {int} [col] Optional column index that you want the data of. + * @returns {array|object|string} If mRow is undefined, then the data for all rows is + * returned. If mRow is defined, just data for that row, and is iCol is + * defined, only data for the designated cell is returned. + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * // Row data + * $(document).ready(function() { + * oTable = $('#example').dataTable(); + * + * oTable.$('tr').click( function () { + * var data = oTable.fnGetData( this ); + * // ... do something with the array / object of data for the row + * } ); + * } ); + * + * @example + * // Individual cell data + * $(document).ready(function() { + * oTable = $('#example').dataTable(); + * + * oTable.$('td').click( function () { + * var sData = oTable.fnGetData( this ); + * alert( 'The cell clicked on had the value of '+sData ); + * } ); + * } ); + */ + this.fnGetData = function( src, col ) + { + var api = this.api( true ); + + if ( src !== undefined ) { + var type = src.nodeName ? src.nodeName.toLowerCase() : ''; + + return col !== undefined || type == 'td' || type == 'th' ? + api.cell( src, col ).data() : + api.row( src ).data() || null; + } + + return api.data().toArray(); + }; + + + /** + * Get an array of the TR nodes that are used in the table's body. Note that you will + * typically want to use the '$' API method in preference to this as it is more + * flexible. + * @param {int} [iRow] Optional row index for the TR element you want + * @returns {array|node} If iRow is undefined, returns an array of all TR elements + * in the table's body, or iRow is defined, just the TR element requested. + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Get the nodes from the table + * var nNodes = oTable.fnGetNodes( ); + * } ); + */ + this.fnGetNodes = function( iRow ) + { + var api = this.api( true ); + + return iRow !== undefined ? + api.row( iRow ).node() : + api.rows().nodes().flatten().toArray(); + }; + + + /** + * Get the array indexes of a particular cell from it's DOM element + * and column index including hidden columns + * @param {node} node this can either be a TR, TD or TH in the table's body + * @returns {int} If nNode is given as a TR, then a single index is returned, or + * if given as a cell, an array of [row index, column index (visible), + * column index (all)] is given. + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * $('#example tbody td').click( function () { + * // Get the position of the current data from the node + * var aPos = oTable.fnGetPosition( this ); + * + * // Get the data array for this row + * var aData = oTable.fnGetData( aPos[0] ); + * + * // Update the data array and return the value + * aData[ aPos[1] ] = 'clicked'; + * this.innerHTML = 'clicked'; + * } ); + * + * // Init DataTables + * oTable = $('#example').dataTable(); + * } ); + */ + this.fnGetPosition = function( node ) + { + var api = this.api( true ); + var nodeName = node.nodeName.toUpperCase(); + + if ( nodeName == 'TR' ) { + return api.row( node ).index(); + } + else if ( nodeName == 'TD' || nodeName == 'TH' ) { + var cell = api.cell( node ).index(); + + return [ + cell.row, + cell.columnVisible, + cell.column + ]; + } + return null; + }; + + + /** + * Check to see if a row is 'open' or not. + * @param {node} nTr the table row to check + * @returns {boolean} true if the row is currently open, false otherwise + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable; + * + * // 'open' an information row when a row is clicked on + * $('#example tbody tr').click( function () { + * if ( oTable.fnIsOpen(this) ) { + * oTable.fnClose( this ); + * } else { + * oTable.fnOpen( this, "Temporary row opened", "info_row" ); + * } + * } ); + * + * oTable = $('#example').dataTable(); + * } ); + */ + this.fnIsOpen = function( nTr ) + { + return this.api( true ).row( nTr ).child.isShown(); + }; + + + /** + * This function will place a new row directly after a row which is currently + * on display on the page, with the HTML contents that is passed into the + * function. This can be used, for example, to ask for confirmation that a + * particular record should be deleted. + * @param {node} nTr The table row to 'open' + * @param {string|node|jQuery} mHtml The HTML to put into the row + * @param {string} sClass Class to give the new TD cell + * @returns {node} The row opened. Note that if the table row passed in as the + * first parameter, is not found in the table, this method will silently + * return. + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable; + * + * // 'open' an information row when a row is clicked on + * $('#example tbody tr').click( function () { + * if ( oTable.fnIsOpen(this) ) { + * oTable.fnClose( this ); + * } else { + * oTable.fnOpen( this, "Temporary row opened", "info_row" ); + * } + * } ); + * + * oTable = $('#example').dataTable(); + * } ); + */ + this.fnOpen = function( nTr, mHtml, sClass ) + { + return this.api( true ) + .row( nTr ) + .child( mHtml, sClass ) + .show() + .child()[0]; + }; + + + /** + * Change the pagination - provides the internal logic for pagination in a simple API + * function. With this function you can have a DataTables table go to the next, + * previous, first or last pages. + * @param {string|int} mAction Paging action to take: "first", "previous", "next" or "last" + * or page number to jump to (integer), note that page 0 is the first page. + * @param {bool} [bRedraw=true] Redraw the table or not + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * oTable.fnPageChange( 'next' ); + * } ); + */ + this.fnPageChange = function ( mAction, bRedraw ) + { + var api = this.api( true ).page( mAction ); + + if ( bRedraw === undefined || bRedraw ) { + api.draw(false); + } + }; + + + /** + * Show a particular column + * @param {int} iCol The column whose display should be changed + * @param {bool} bShow Show (true) or hide (false) the column + * @param {bool} [bRedraw=true] Redraw the table or not + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Hide the second column after initialisation + * oTable.fnSetColumnVis( 1, false ); + * } ); + */ + this.fnSetColumnVis = function ( iCol, bShow, bRedraw ) + { + var api = this.api( true ).column( iCol ).visible( bShow ); + + if ( bRedraw === undefined || bRedraw ) { + api.columns.adjust().draw(); + } + }; + + + /** + * Get the settings for a particular table for external manipulation + * @returns {object} DataTables settings object. See + * {@link DataTable.models.oSettings} + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * var oSettings = oTable.fnSettings(); + * + * // Show an example parameter from the settings + * alert( oSettings._iDisplayStart ); + * } ); + */ + this.fnSettings = function() + { + return _fnSettingsFromNode( this[_ext.iApiIndex] ); + }; + + + /** + * Sort the table by a particular column + * @param {int} iCol the data index to sort on. Note that this will not match the + * 'display index' if you have hidden data entries + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Sort immediately with columns 0 and 1 + * oTable.fnSort( [ [0,'asc'], [1,'asc'] ] ); + * } ); + */ + this.fnSort = function( aaSort ) + { + this.api( true ).order( aaSort ).draw(); + }; + + + /** + * Attach a sort listener to an element for a given column + * @param {node} nNode the element to attach the sort listener to + * @param {int} iColumn the column that a click on this node will sort on + * @param {function} [fnCallback] callback function when sort is run + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * + * // Sort on column 1, when 'sorter' is clicked on + * oTable.fnSortListener( document.getElementById('sorter'), 1 ); + * } ); + */ + this.fnSortListener = function( nNode, iColumn, fnCallback ) + { + this.api( true ).order.listener( nNode, iColumn, fnCallback ); + }; + + + /** + * Update a table cell or row - this method will accept either a single value to + * update the cell with, an array of values with one element for each column or + * an object in the same format as the original data source. The function is + * self-referencing in order to make the multi column updates easier. + * @param {object|array|string} mData Data to update the cell/row with + * @param {node|int} mRow TR element you want to update or the aoData index + * @param {int} [iColumn] The column to update, give as null or undefined to + * update a whole row. + * @param {bool} [bRedraw=true] Redraw the table or not + * @param {bool} [bAction=true] Perform pre-draw actions or not + * @returns {int} 0 on success, 1 on error + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * oTable.fnUpdate( 'Example update', 0, 0 ); // Single cell + * oTable.fnUpdate( ['a', 'b', 'c', 'd', 'e'], $('tbody tr')[0] ); // Row + * } ); + */ + this.fnUpdate = function( mData, mRow, iColumn, bRedraw, bAction ) + { + var api = this.api( true ); + + if ( iColumn === undefined || iColumn === null ) { + api.row( mRow ).data( mData ); + } + else { + api.cell( mRow, iColumn ).data( mData ); + } + + if ( bAction === undefined || bAction ) { + api.columns.adjust(); + } + + if ( bRedraw === undefined || bRedraw ) { + api.draw(); + } + return 0; + }; + + + /** + * Provide a common method for plug-ins to check the version of DataTables being used, in order + * to ensure compatibility. + * @param {string} sVersion Version string to check for, in the format "X.Y.Z". Note that the + * formats "X" and "X.Y" are also acceptable. + * @returns {boolean} true if this version of DataTables is greater or equal to the required + * version, or false if this version of DataTales is not suitable + * @method + * @dtopt API + * @deprecated Since v1.10 + * + * @example + * $(document).ready(function() { + * var oTable = $('#example').dataTable(); + * alert( oTable.fnVersionCheck( '1.9.0' ) ); + * } ); + */ + this.fnVersionCheck = _ext.fnVersionCheck; + + + var _that = this; + var emptyInit = options === undefined; + var len = this.length; + + if ( emptyInit ) { + options = {}; + } + + this.oApi = this.internal = _ext.internal; + + // Extend with old style plug-in API methods + for ( var fn in DataTable.ext.internal ) { + if ( fn ) { + this[fn] = _fnExternApiFunc(fn); + } + } + + this.each(function() { + // For each initialisation we want to give it a clean initialisation + // object that can be bashed around + var o = {}; + var oInit = len > 1 ? // optimisation for single table case + _fnExtend( o, options, true ) : + options; + + /*global oInit,_that,emptyInit*/ + var i=0, iLen, j, jLen, k, kLen; + var sId = this.getAttribute( 'id' ); + var bInitHandedOff = false; + var defaults = DataTable.defaults; + var $this = $(this); + + + /* Sanity check */ + if ( this.nodeName.toLowerCase() != 'table' ) + { + _fnLog( null, 0, 'Non-table node initialisation ('+this.nodeName+')', 2 ); + return; + } + + /* Backwards compatibility for the defaults */ + _fnCompatOpts( defaults ); + _fnCompatCols( defaults.column ); + + /* Convert the camel-case defaults to Hungarian */ + _fnCamelToHungarian( defaults, defaults, true ); + _fnCamelToHungarian( defaults.column, defaults.column, true ); + + /* Setting up the initialisation object */ + _fnCamelToHungarian( defaults, $.extend( oInit, $this.data() ), true ); + + + + /* Check to see if we are re-initialising a table */ + var allSettings = DataTable.settings; + for ( i=0, iLen=allSettings.length ; i').appendTo($this); + } + oSettings.nTHead = thead[0]; + + var tbody = $this.children('tbody'); + if ( tbody.length === 0 ) { + tbody = $('').insertAfter(thead); + } + oSettings.nTBody = tbody[0]; + + var tfoot = $this.children('tfoot'); + if ( tfoot.length === 0 && captions.length > 0 && (oSettings.oScroll.sX !== "" || oSettings.oScroll.sY !== "") ) { + // If we are a scrolling table, and no footer has been given, then we need to create + // a tfoot element for the caption element to be appended to + tfoot = $('').appendTo($this); + } + + if ( tfoot.length === 0 || tfoot.children().length === 0 ) { + $this.addClass( oClasses.sNoFooter ); + } + else if ( tfoot.length > 0 ) { + oSettings.nTFoot = tfoot[0]; + _fnDetectHeader( oSettings.aoFooter, oSettings.nTFoot ); + } + + /* Check if there is data passing into the constructor */ + if ( oInit.aaData ) { + for ( i=0 ; i/g; + + // This is not strict ISO8601 - Date.parse() is quite lax, although + // implementations differ between browsers. + var _re_date = /^\d{2,4}[\.\/\-]\d{1,2}[\.\/\-]\d{1,2}([T ]{1}\d{1,2}[:\.]\d{2}([\.:]\d{2})?)?$/; + + // Escape regular expression special characters + var _re_escape_regex = new RegExp( '(\\' + [ '/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\', '$', '^', '-' ].join('|\\') + ')', 'g' ); + + // http://en.wikipedia.org/wiki/Foreign_exchange_market + // - \u20BD - Russian ruble. + // - \u20a9 - South Korean Won + // - \u20BA - Turkish Lira + // - \u20B9 - Indian Rupee + // - R - Brazil (R$) and South Africa + // - fr - Swiss Franc + // - kr - Swedish krona, Norwegian krone and Danish krone + // - \u2009 is thin space and \u202F is narrow no-break space, both used in many + // - Ƀ - Bitcoin + // - Ξ - Ethereum + // standards as thousands separators. + var _re_formatted_numeric = /['\u00A0,$£€¥%\u2009\u202F\u20BD\u20a9\u20BArfkɃΞ]/gi; + + + var _empty = function ( d ) { + return !d || d === true || d === '-' ? true : false; + }; + + + var _intVal = function ( s ) { + var integer = parseInt( s, 10 ); + return !isNaN(integer) && isFinite(s) ? integer : null; + }; + + // Convert from a formatted number with characters other than `.` as the + // decimal place, to a Javascript number + var _numToDecimal = function ( num, decimalPoint ) { + // Cache created regular expressions for speed as this function is called often + if ( ! _re_dic[ decimalPoint ] ) { + _re_dic[ decimalPoint ] = new RegExp( _fnEscapeRegex( decimalPoint ), 'g' ); + } + return typeof num === 'string' && decimalPoint !== '.' ? + num.replace( /\./g, '' ).replace( _re_dic[ decimalPoint ], '.' ) : + num; + }; + + + var _isNumber = function ( d, decimalPoint, formatted ) { + var type = typeof d; + var strType = type === 'string'; + + if ( type === 'number' || type === 'bigint') { + return true; + } + + // If empty return immediately so there must be a number if it is a + // formatted string (this stops the string "k", or "kr", etc being detected + // as a formatted number for currency + if ( _empty( d ) ) { + return true; + } + + if ( decimalPoint && strType ) { + d = _numToDecimal( d, decimalPoint ); + } + + if ( formatted && strType ) { + d = d.replace( _re_formatted_numeric, '' ); + } + + return !isNaN( parseFloat(d) ) && isFinite( d ); + }; + + + // A string without HTML in it can be considered to be HTML still + var _isHtml = function ( d ) { + return _empty( d ) || typeof d === 'string'; + }; + + + var _htmlNumeric = function ( d, decimalPoint, formatted ) { + if ( _empty( d ) ) { + return true; + } + + var html = _isHtml( d ); + return ! html ? + null : + _isNumber( _stripHtml( d ), decimalPoint, formatted ) ? + true : + null; + }; + + + var _pluck = function ( a, prop, prop2 ) { + var out = []; + var i=0, ien=a.length; + + // Could have the test in the loop for slightly smaller code, but speed + // is essential here + if ( prop2 !== undefined ) { + for ( ; i') + .css( { + position: 'fixed', + top: 0, + left: $(window).scrollLeft()*-1, // allow for scrolling + height: 1, + width: 1, + overflow: 'hidden' + } ) + .append( + $('
') + .css( { + position: 'absolute', + top: 1, + left: 1, + width: 100, + overflow: 'scroll' + } ) + .append( + $('
') + .css( { + width: '100%', + height: 10 + } ) + ) + ) + .appendTo( 'body' ); + + var outer = n.children(); + var inner = outer.children(); + + // Numbers below, in order, are: + // inner.offsetWidth, inner.clientWidth, outer.offsetWidth, outer.clientWidth + // + // IE6 XP: 100 100 100 83 + // IE7 Vista: 100 100 100 83 + // IE 8+ Windows: 83 83 100 83 + // Evergreen Windows: 83 83 100 83 + // Evergreen Mac with scrollbars: 85 85 100 85 + // Evergreen Mac without scrollbars: 100 100 100 100 + + // Get scrollbar width + browser.barWidth = outer[0].offsetWidth - outer[0].clientWidth; + + // IE6/7 will oversize a width 100% element inside a scrolling element, to + // include the width of the scrollbar, while other browsers ensure the inner + // element is contained without forcing scrolling + browser.bScrollOversize = inner[0].offsetWidth === 100 && outer[0].clientWidth !== 100; + + // In rtl text layout, some browsers (most, but not all) will place the + // scrollbar on the left, rather than the right. + browser.bScrollbarLeft = Math.round( inner.offset().left ) !== 1; + + // IE8- don't provide height and width for getBoundingClientRect + browser.bBounding = n[0].getBoundingClientRect().width ? true : false; + + n.remove(); + } + + $.extend( settings.oBrowser, DataTable.__browser ); + settings.oScroll.iBarWidth = DataTable.__browser.barWidth; + } + + + /** + * Array.prototype reduce[Right] method, used for browsers which don't support + * JS 1.6. Done this way to reduce code size, since we iterate either way + * @param {object} settings dataTables settings object + * @memberof DataTable#oApi + */ + function _fnReduce ( that, fn, init, start, end, inc ) + { + var + i = start, + value, + isSet = false; + + if ( init !== undefined ) { + value = init; + isSet = true; + } + + while ( i !== end ) { + if ( ! that.hasOwnProperty(i) ) { + continue; + } + + value = isSet ? + fn( value, that[i], i, that ) : + that[i]; + + isSet = true; + i += inc; + } + + return value; + } + + /** + * Add a column to the list used for the table with default values + * @param {object} oSettings dataTables settings object + * @param {node} nTh The th element for this column + * @memberof DataTable#oApi + */ + function _fnAddColumn( oSettings, nTh ) + { + // Add column to aoColumns array + var oDefaults = DataTable.defaults.column; + var iCol = oSettings.aoColumns.length; + var oCol = $.extend( {}, DataTable.models.oColumn, oDefaults, { + "nTh": nTh ? nTh : document.createElement('th'), + "sTitle": oDefaults.sTitle ? oDefaults.sTitle : nTh ? nTh.innerHTML : '', + "aDataSort": oDefaults.aDataSort ? oDefaults.aDataSort : [iCol], + "mData": oDefaults.mData ? oDefaults.mData : iCol, + idx: iCol + } ); + oSettings.aoColumns.push( oCol ); + + // Add search object for column specific search. Note that the `searchCols[ iCol ]` + // passed into extend can be undefined. This allows the user to give a default + // with only some of the parameters defined, and also not give a default + var searchCols = oSettings.aoPreSearchCols; + searchCols[ iCol ] = $.extend( {}, DataTable.models.oSearch, searchCols[ iCol ] ); + + // Use the default column options function to initialise classes etc + _fnColumnOptions( oSettings, iCol, $(nTh).data() ); + } + + + /** + * Apply options for a column + * @param {object} oSettings dataTables settings object + * @param {int} iCol column index to consider + * @param {object} oOptions object with sType, bVisible and bSearchable etc + * @memberof DataTable#oApi + */ + function _fnColumnOptions( oSettings, iCol, oOptions ) + { + var oCol = oSettings.aoColumns[ iCol ]; + var oClasses = oSettings.oClasses; + var th = $(oCol.nTh); + + // Try to get width information from the DOM. We can't get it from CSS + // as we'd need to parse the CSS stylesheet. `width` option can override + if ( ! oCol.sWidthOrig ) { + // Width attribute + oCol.sWidthOrig = th.attr('width') || null; + + // Style attribute + var t = (th.attr('style') || '').match(/width:\s*(\d+[pxem%]+)/); + if ( t ) { + oCol.sWidthOrig = t[1]; + } + } + + /* User specified column options */ + if ( oOptions !== undefined && oOptions !== null ) + { + // Backwards compatibility + _fnCompatCols( oOptions ); + + // Map camel case parameters to their Hungarian counterparts + _fnCamelToHungarian( DataTable.defaults.column, oOptions, true ); + + /* Backwards compatibility for mDataProp */ + if ( oOptions.mDataProp !== undefined && !oOptions.mData ) + { + oOptions.mData = oOptions.mDataProp; + } + + if ( oOptions.sType ) + { + oCol._sManualType = oOptions.sType; + } + + // `class` is a reserved word in Javascript, so we need to provide + // the ability to use a valid name for the camel case input + if ( oOptions.className && ! oOptions.sClass ) + { + oOptions.sClass = oOptions.className; + } + if ( oOptions.sClass ) { + th.addClass( oOptions.sClass ); + } + + var origClass = oCol.sClass; + + $.extend( oCol, oOptions ); + _fnMap( oCol, oOptions, "sWidth", "sWidthOrig" ); + + // Merge class from previously defined classes with this one, rather than just + // overwriting it in the extend above + if (origClass !== oCol.sClass) { + oCol.sClass = origClass + ' ' + oCol.sClass; + } + + /* iDataSort to be applied (backwards compatibility), but aDataSort will take + * priority if defined + */ + if ( oOptions.iDataSort !== undefined ) + { + oCol.aDataSort = [ oOptions.iDataSort ]; + } + _fnMap( oCol, oOptions, "aDataSort" ); + + // Fall back to the aria-label attribute on the table header if no ariaTitle is + // provided. + if (! oCol.ariaTitle) { + oCol.ariaTitle = th.attr("aria-label"); + } + } + + /* Cache the data get and set functions for speed */ + var mDataSrc = oCol.mData; + var mData = _fnGetObjectDataFn( mDataSrc ); + var mRender = oCol.mRender ? _fnGetObjectDataFn( oCol.mRender ) : null; + + var attrTest = function( src ) { + return typeof src === 'string' && src.indexOf('@') !== -1; + }; + oCol._bAttrSrc = $.isPlainObject( mDataSrc ) && ( + attrTest(mDataSrc.sort) || attrTest(mDataSrc.type) || attrTest(mDataSrc.filter) + ); + oCol._setter = null; + + oCol.fnGetData = function (rowData, type, meta) { + var innerData = mData( rowData, type, undefined, meta ); + + return mRender && type ? + mRender( innerData, type, rowData, meta ) : + innerData; + }; + oCol.fnSetData = function ( rowData, val, meta ) { + return _fnSetObjectDataFn( mDataSrc )( rowData, val, meta ); + }; + + // Indicate if DataTables should read DOM data as an object or array + // Used in _fnGetRowElements + if ( typeof mDataSrc !== 'number' && ! oCol._isArrayHost ) { + oSettings._rowReadObject = true; + } + + /* Feature sorting overrides column specific when off */ + if ( !oSettings.oFeatures.bSort ) + { + oCol.bSortable = false; + th.addClass( oClasses.sSortableNone ); // Have to add class here as order event isn't called + } + + /* Check that the class assignment is correct for sorting */ + var bAsc = $.inArray('asc', oCol.asSorting) !== -1; + var bDesc = $.inArray('desc', oCol.asSorting) !== -1; + if ( !oCol.bSortable || (!bAsc && !bDesc) ) + { + oCol.sSortingClass = oClasses.sSortableNone; + oCol.sSortingClassJUI = ""; + } + else if ( bAsc && !bDesc ) + { + oCol.sSortingClass = oClasses.sSortableAsc; + oCol.sSortingClassJUI = oClasses.sSortJUIAscAllowed; + } + else if ( !bAsc && bDesc ) + { + oCol.sSortingClass = oClasses.sSortableDesc; + oCol.sSortingClassJUI = oClasses.sSortJUIDescAllowed; + } + else + { + oCol.sSortingClass = oClasses.sSortable; + oCol.sSortingClassJUI = oClasses.sSortJUI; + } + } + + + /** + * Adjust the table column widths for new data. Note: you would probably want to + * do a redraw after calling this function! + * @param {object} settings dataTables settings object + * @memberof DataTable#oApi + */ + function _fnAdjustColumnSizing ( settings ) + { + /* Not interested in doing column width calculation if auto-width is disabled */ + if ( settings.oFeatures.bAutoWidth !== false ) + { + var columns = settings.aoColumns; + + _fnCalculateColumnWidths( settings ); + for ( var i=0 , iLen=columns.length ; i