From 8d2b52b4d64a139302dc924363a0cbef8db551cd Mon Sep 17 00:00:00 2001 From: Konstantin Myakshin Date: Tue, 8 Nov 2022 21:32:27 +0200 Subject: [PATCH 1/2] enh: Added possibility to link spreadsheet for automatic submission export in multiple formats Signed-off-by: Konstantin Myakshin --- .github/workflows/appstore-build-publish.yml | 1 + .github/workflows/lint-php-cs.yml | 1 + .github/workflows/psalm.yml | 1 + appinfo/routes.php | 24 +- composer.json | 2 +- composer.lock | 629 ++++++++++++++++-- docs/API.md | 75 ++- lib/Constants.php | 8 + lib/Controller/ApiController.php | 123 +++- lib/Db/Form.php | 8 + .../Version040010Date20240122133700.php | 62 ++ lib/Service/FormsService.php | 50 +- lib/Service/SubmissionService.php | 166 +++-- psalm.xml | 3 + src/views/Results.vue | 373 +++++++++-- tests/Integration/Api/ApiV2Test.php | 141 +++- tests/Unit/Controller/ApiControllerTest.php | 73 +- tests/Unit/FormsMigratorTest.php | 55 +- tests/Unit/Service/FormsServiceTest.php | 79 ++- tests/Unit/Service/SubmissionServiceTest.php | 79 ++- tests/stubs/oc_hooks_emitter.php | 11 + 21 files changed, 1683 insertions(+), 281 deletions(-) create mode 100644 lib/Migration/Version040010Date20240122133700.php create mode 100644 tests/stubs/oc_hooks_emitter.php diff --git a/.github/workflows/appstore-build-publish.yml b/.github/workflows/appstore-build-publish.yml index d30d6f61f..2fb687910 100644 --- a/.github/workflows/appstore-build-publish.yml +++ b/.github/workflows/appstore-build-publish.yml @@ -70,6 +70,7 @@ jobs: with: php-version: ${{ env.PHP_VERSION }} coverage: none + extensions: gd env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint-php-cs.yml b/.github/workflows/lint-php-cs.yml index 1cf1fbf3c..286a9c2d5 100644 --- a/.github/workflows/lint-php-cs.yml +++ b/.github/workflows/lint-php-cs.yml @@ -30,6 +30,7 @@ jobs: php-version: 8.2 coverage: none ini-file: development + extensions: gd env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index ea45132fd..52e98e869 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -50,6 +50,7 @@ jobs: php-version: 8.2 coverage: none ini-file: development + extensions: gd env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/appinfo/routes.php b/appinfo/routes.php index f93510885..f325adc74 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -99,7 +99,7 @@ 'url' => '/api/{apiVersion}/form/{id}', 'verb' => 'GET', 'requirements' => [ - 'apiVersion' => 'v2(\.[1-3])?' + 'apiVersion' => 'v2(\.[1-4])?' ] ], [ @@ -304,7 +304,7 @@ 'url' => '/api/{apiVersion}/submissions/export/{hash}', 'verb' => 'GET', 'requirements' => [ - 'apiVersion' => 'v2(\.[1-3])?' + 'apiVersion' => 'v2(\.[1-4])?' ] ], [ @@ -312,7 +312,7 @@ 'url' => '/api/{apiVersion}/submissions/export', 'verb' => 'POST', 'requirements' => [ - 'apiVersion' => 'v2(\.[1-3])?' + 'apiVersion' => 'v2(\.[1-4])?' ] ], [ @@ -339,5 +339,23 @@ 'apiVersion' => 'v2(\.[1-3])?' ] ], + // Submissions linking with file in cloud + [ + 'name' => 'api#linkFile', + 'url' => '/api/{apiVersion}/form/link/{fileFormat}', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v2.4', + 'fileFormat' => 'csv|ods|xlsx' + ] + ], + [ + 'name' => 'api#unlinkFile', + 'url' => '/api/{apiVersion}/form/unlink', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v2.4', + ] + ] ] ]; diff --git a/composer.json b/composer.json index 18390d81e..f76d482c7 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "phpunit/phpunit": "^9" }, "require": { - "league/csv": "^9.8.0" + "phpoffice/phpspreadsheet": "^1.29" }, "extra": { "bamarni-bin": { diff --git a/composer.lock b/composer.lock index 2eb88b663..2f4cd3105 100644 --- a/composer.lock +++ b/composer.lock @@ -4,52 +4,449 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a8c96b8fab85ad79f54e9ee7bfd41b71", + "content-hash": "fb5b7ffb977f6896be83170c3fb32813", "packages": [ { - "name": "league/csv", - "version": "9.8.0", + "name": "ezyang/htmlpurifier", + "version": "v4.17.0", "source": { "type": "git", - "url": "https://github.com/thephpleague/csv.git", - "reference": "9d2e0265c5d90f5dd601bc65ff717e05cec19b47" + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/csv/zipball/9d2e0265c5d90f5dd601bc65ff717e05cec19b47", - "reference": "9d2e0265c5d90f5dd601bc65ff717e05cec19b47", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/bbc513d79acf6691fa9cf10f192c90dd2957f18c", + "reference": "bbc513d79acf6691fa9cf10f192c90dd2957f18c", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.17.0" + }, + "time": "2023-11-17T15:01:25+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "3fa72e4c71a43f9e9118752a5c90e476a8dc9eb3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/3fa72e4c71a43f9e9118752a5c90e476a8dc9eb3", + "reference": "3fa72e4c71a43f9e9118752a5c90e476a8dc9eb3", "shasum": "" }, "require": { - "ext-json": "*", "ext-mbstring": "*", - "php": "^7.4 || ^8.0" + "myclabs/php-enum": "^1.5", + "php": "^8.0", + "psr/http-message": "^1.0" }, "require-dev": { - "ext-curl": "*", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.9", + "guzzlehttp/guzzle": "^6.5.3 || ^7.2.0", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.4", + "phpunit/phpunit": "^8.5.8 || ^9.4.2", + "vimeo/psalm": "^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/2.4.0" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + }, + { + "url": "https://opencollective.com/zipstream", + "type": "open_collective" + } + ], + "time": "2022-12-08T12:29:14+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, + { + "name": "myclabs/php-enum", + "version": "1.8.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "a867478eae49c9f59ece437ae7f9506bfaa27483" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/a867478eae49c9f59ece437ae7f9506bfaa27483", + "reference": "a867478eae49c9f59ece437ae7f9506bfaa27483", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "http://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.4" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2022-08-04T09:53:51+00:00" + }, + { + "name": "phpoffice/phpspreadsheet", + "version": "1.29.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "fde2ccf55eaef7e86021ff1acce26479160a0fa0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/fde2ccf55eaef7e86021ff1acce26479160a0fa0", + "reference": "fde2ccf55eaef7e86021ff1acce26479160a0fa0", + "shasum": "" + }, + "require": { + "ext-ctype": "*", "ext-dom": "*", - "friendsofphp/php-cs-fixer": "^v3.4.0", - "phpstan/phpstan": "^1.3.0", - "phpstan/phpstan-phpunit": "^1.0.0", - "phpstan/phpstan-strict-rules": "^1.1.0", - "phpunit/phpunit": "^9.5.11" + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "ezyang/htmlpurifier": "^4.15", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^1.0 || ^2.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^8.5 || ^9.0 || ^10.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" }, "suggest": { - "ext-dom": "Required to use the XMLConverter and or the HTMLConverter classes", - "ext-iconv": "Needed to ease transcoding CSV using iconv stream filters" + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.29.0" + }, + "time": "2023-06-14T22:48:31+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": "9.x-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { - "League\\Csv\\": "src" + "Psr\\Http\\Client\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -58,37 +455,181 @@ ], "authors": [ { - "name": "Ignace Nyamagana Butera", - "email": "nyamsprod@gmail.com", - "homepage": "https://github.com/nyamsprod/", - "role": "Developer" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "CSV data manipulation made easy in PHP", - "homepage": "https://csv.thephpleague.com", + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", "keywords": [ - "convert", - "csv", - "export", - "filter", - "import", - "read", - "transform", - "write" + "http", + "http-client", + "psr", + "psr-18" ], "support": { - "docs": "https://csv.thephpleague.com", - "issues": "https://github.com/thephpleague/csv/issues", - "rss": "https://github.com/thephpleague/csv/releases.atom", - "source": "https://github.com/thephpleague/csv" + "source": "https://github.com/php-fig/http-client" }, - "funding": [ + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "e616d01114759c4c489f93b099585439f795fe35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35", + "reference": "e616d01114759c4c489f93b099585439f795fe35", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "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": [ { - "url": "https://github.com/sponsors/nyamsprod", - "type": "github" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "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/tree/1.0.2" + }, + "time": "2023-04-10T20:10:41+00:00" + }, + { + "name": "psr/http-message", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://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/1.1" + }, + "time": "2023-04-04T09:50:52+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" ], - "time": "2022-01-04T00:13:07+00:00" + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" } ], "packages-dev": [ diff --git a/docs/API.md b/docs/API.md index 1c489c06b..82f444d8d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -28,9 +28,16 @@ This file contains the API-Documentation. For more information on the returned D - Completely new way of handling access & shares. ### Other API changes -- In API version 2.1 the endpoint `/api/v2.1/share/update` was added to update a Share -- In API version 2.2 the endpoint `/api/v2.2/form/transfer` was added to transfer ownership of a form +- In API version 2.4 the following endpoints were introduced: + - `POST /api/2.4/form/link/{fileFormat}` to link form to a file + - `POST /api/2.4/form/unlink` to unlink form from a file +- In API version 2.4 the following endpoints were changed: + - `GET /api/v2.4/submissions/export/{hash}` was extended with optional parameter `fileFormat` to export submissions in different formats + - `GET /api/v2.4/submissions/export` was extended with optional parameter `fileFormat` to export submissions to cloud in different formats + - `GET /api/v2.4/form/{id}` was extended with optional parameters `fileFormat`, `fileId`, `filePath` to link form to a file - In API version 2.3 the endpoint `/api/v2.3/question/clone` was added to clone a question +- In API version 2.2 the endpoint `/api/v2.2/form/transfer` was added to transfer ownership of a form +- In API version 2.1 the endpoint `/api/v2.1/share/update` was added to update a Share ## Form Endpoints ### List owned Forms @@ -111,7 +118,7 @@ See next section, 'Request full data of a form' ### Request full data of a form Returns the full-depth object of the requested form (without submissions). -- Endpoint: `/api/v2.2/form/{id}` +- Endpoint: `/api/v2.4/form/{id}` - Url-Parameter: | Parameter | Type | Description | |-----------|---------|-------------| @@ -132,6 +139,9 @@ Returns the full-depth object of the requested form (without submissions). "showToAllUsers": false }, "expires": 0, + "fileFormat": "csv", + "fileId": 157, + "filePath": "foo/bar", "isAnonymous": false, "submitMultiple": true, "showExpiration": false, @@ -250,6 +260,37 @@ Transfer the ownership of a form to another user "data": 3 ``` +### Link a form to a file +- Endpoint: `/api/v2.4/form/link/{fileFormat}` +- Url-Parameter: + | Parameter | Type | Description | + |--------------|---------|--------------| + | _fileFormat_ | String | csv|ods|xlsx | +- Method: `POST` +- Parameters: + | Parameter | Type | Description | + |-----------|---------|--------------------------------------------| + | _hash_ | String | Hash of the form to update | + | _path_ | String | Path within User-Dir, to store the file to | +- Response: The new question object. +``` +"data": { + "fileFormat": "csv", + "fileId": 157, + "filePath": "foo/bar", + "fileName": "Form 1 (responses).csv" +} +``` + +### Unlink file from form +- Endpoint: `/api/v2.4/form/unlink` +- Method: `POST` +- Parameters: + | Parameter | Type | Description | + |-----------|---------|----------------------------| + | _hash_ | String | Hash of the form to update | +- Response: **Status-Code OK** + ## Question Endpoints Contains only manipulative question-endpoints. To retrieve questions, request the full form data. @@ -262,7 +303,7 @@ Contains only manipulative question-endpoints. To retrieve questions, request th | _formId_ | Integer | | ID of the form, the new question will belong to | | _type_ | [QuestionType](DataStructure.md#question-types) | | The question-type of the new question | | _text_ | String | yes | *Optional* The text of the new question. | -- Response: The new question object. +- Response: The new question object. ``` "data": { "id": 3, @@ -434,7 +475,7 @@ Update a single or all properties of an option-object |------------------|----------|-------------| | _id_ | Integer | ID of the share to update | | *keyValuePairs*¹ | Array | Array of key-value pairs to update | - + ¹Currently only the _permissions_ can be updated. - Method: `PATCH` - *Method: `POST` deprecated* @@ -542,13 +583,14 @@ Get all Submissions to a Form ### Get Submissions as csv (Download) Returns all submissions to the form in form of a csv-file. -- Endpoint: `/api/v2.2/submissions/export/{hash}` +- Endpoint: `/api/v2.4/submissions/export/{hash}` - Url-Parameter: - | Parameter | Type | Description | - |-----------|---------|-------------| - | _hash_ | String | Hash of the form to get the submissions for | + | Parameter | Type | Description | + |--------------|---------|-------------| + | _hash_ | String | Hash of the form to get the submissions for | + | _fileFormat_ | String | csv|ods|xlsx | - Method: `GET` -- Response: A Data Download Response containg the headers `Content-Disposition: attachment; filename="Form 1 (responses).csv"` and `Content-Type: text/csv;charset=UTF-8`. The actual data contains all submissions to the referred form, formatted as comma separated and escaped csv. +- Response: A Data Download Response containing the headers `Content-Disposition: attachment; filename="Form 1 (responses).csv"` and `Content-Type: text/csv;charset=UTF-8`. The actual data contains all submissions to the referred form, formatted as comma separated and escaped csv. ``` "User display name","Timestamp","Question 1","Question 2" "jonas","Friday, January 22, 2021 at 12:47:29 AM GMT+0:00","Option 2","Answer" @@ -557,13 +599,14 @@ Returns all submissions to the form in form of a csv-file. ### Export Submissions to Cloud (Files-App) Creates a csv file and stores it to the cloud, resp. Files-App. -- Endpoint: `/api/v2.2/submissions/export` +- Endpoint: `/api/v2.4/submissions/export` - Method: `POST` - Parameters: - | Parameter | Type | Description | - |-----------|---------|-------------| - | _hash_ | String | Hash of the form to get the submissions for | - | _path_ | String | Path within User-Dir, to store the file to | + | Parameter | Type | Description | + |--------------|---------|-------------| + | _hash_ | String | Hash of the form to get the submissions for | + | _path_ | String | Path within User-Dir, to store the file to | + | _fileFormat_ | String | csv|ods|xlsx | - Response: Stores the file to the given path and returns the fileName. ``` "data": "Form 2 (responses).csv" @@ -592,7 +635,7 @@ Store Submission to Database | _formId_ | Integer | ID of the form to submit into | | _answers_ | Array | Array of Answers | | _shareHash_ | String | optional, only neccessary for submissions to a public share link | - + The Array of Answers has the following structure: - QuestionID as key - An **array** of values as value --> Even for short Text Answers, wrapped into Array. diff --git a/lib/Constants.php b/lib/Constants.php index 46646aba2..8b82546bd 100644 --- a/lib/Constants.php +++ b/lib/Constants.php @@ -166,4 +166,12 @@ class Constants { * Constants related to extra settings for questions */ public const QUESTION_EXTRASETTINGS_OTHER_PREFIX = 'system-other-answer:'; + + public const SUPPORTED_EXPORT_FORMATS = [ + 'csv' => 'text/csv', + 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]; + + public const DEFAULT_FILE_FORMAT = 'csv'; } diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index 5c3e8a801..7c1749ae1 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -48,12 +48,10 @@ use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; -use OCP\AppFramework\OCS\OCSException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; use OCP\Files\NotFoundException; -use OCP\Files\NotPermittedException; use OCP\IL10N; use OCP\IRequest; use OCP\IUser; @@ -378,7 +376,7 @@ public function transferOwner(int $formId, string $uid): DataResponse { } $user = $this->userManager->get($uid); - if($user == null) { + if ($user == null) { $this->logger->debug('Could not find new form owner'); throw new OCSBadRequestException('Could not find new form owner'); } @@ -393,7 +391,7 @@ public function transferOwner(int $formId, string $uid): DataResponse { // Update changed Columns in Db. $this->formMapper->update($form); - + return new DataResponse($form->getOwnerId()); } @@ -1106,6 +1104,18 @@ public function insertSubmission(int $formId, array $answers, string $shareHash //Create Activity $this->formsService->notifyNewSubmission($form, $submission->getUserId()); + if ($form->getFileId() !== null) { + try { + $filePath = $this->formsService->getFilePath($form); + $fileFormat = $form->getFileFormat(); + $ownerId = $form->getOwnerId(); + + $this->submissionService->writeFileToCloud($form, $filePath, $fileFormat, $ownerId); + } catch (NotFoundException $e) { + $this->logger->notice('Form {formId} linked to a file that doesn\'t exist anymore', ['formId' => $formId]); + } + } + return new DataResponse(); } @@ -1190,11 +1200,12 @@ public function deleteAllSubmissions(int $formId): DataResponse { * Export submissions of a specified form * * @param string $hash the form hash + * @param string $fileFormat File format used for export * @return DataDownloadResponse * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function exportSubmissions(string $hash): DataDownloadResponse { + public function exportSubmissions(string $hash, string $fileFormat = Constants::DEFAULT_FILE_FORMAT): DataDownloadResponse { $this->logger->debug('Export submissions for form: {hash}', [ 'hash' => $hash, ]); @@ -1203,7 +1214,7 @@ public function exportSubmissions(string $hash): DataDownloadResponse { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSBadRequestException(); + throw new OCSNotFoundException(); } if (!$this->formsService->canSeeResults($form)) { @@ -1211,8 +1222,10 @@ public function exportSubmissions(string $hash): DataDownloadResponse { throw new OCSForbiddenException(); } - $csv = $this->submissionService->getSubmissionsCsv($hash); - return new DataDownloadResponse($csv['data'], $csv['fileName'], 'text/csv'); + $submissionsData = $this->submissionService->getSubmissionsData($form, $fileFormat); + $fileName = $this->formsService->getFileName($form, $fileFormat); + + return new DataDownloadResponse($submissionsData, $fileName, Constants::SUPPORTED_EXPORT_FORMATS[$fileFormat]); } /** @@ -1223,21 +1236,17 @@ public function exportSubmissions(string $hash): DataDownloadResponse { * * @param string $hash of the form * @param string $path The Cloud-Path to export to + * @param string $fileFormat File format used for export * @return DataResponse * @throws OCSBadRequestException * @throws OCSForbiddenException */ - public function exportSubmissionsToCloud(string $hash, string $path) { - $this->logger->debug('Export submissions for form: {hash} to Cloud at: /{path}', [ - 'hash' => $hash, - 'path' => $path, - ]); - + public function exportSubmissionsToCloud(string $hash, string $path, string $fileFormat = Constants::DEFAULT_FILE_FORMAT) { try { $form = $this->formMapper->findByHash($hash); } catch (IMapperException $e) { $this->logger->debug('Could not find form'); - throw new OCSBadRequestException(); + throw new OCSNotFoundException(); } if (!$this->formsService->canSeeResults($form)) { @@ -1245,14 +1254,86 @@ public function exportSubmissionsToCloud(string $hash, string $path) { throw new OCSForbiddenException(); } - // Write file to cloud + $file = $this->submissionService->writeFileToCloud($form, $path, $fileFormat); + + return new DataResponse($file->getName()); + } + + /** + * @NoAdminRequired + * + * @param string $hash of the form + */ + public function unlinkFile(string $hash): DataResponse { try { - $fileName = $this->submissionService->writeCsvToCloud($hash, $path); - } catch (NotPermittedException $e) { - $this->logger->debug('Failed to export Submissions: Not allowed to write to file'); - throw new OCSException('Not allowed to write to file.'); + $form = $this->formMapper->findByHash($hash); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSNotFoundException(); } - return new DataResponse($fileName); + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + if (!$form->getFileId()) { + $this->logger->debug('Form not linked to file'); + throw new OCSBadRequestException(); + } + + $form->setFileId(null); + $form->setFileFormat(null); + + $this->formMapper->update($form); + + return new DataResponse($hash); + } + + /** + * @NoAdminRequired + * + * Export Submissions to the Cloud and Link the FileId to the form + * + * @param string $hash of the form + * @param string $path The Cloud-Path to export to + * @param string $fileFormat File format used for export + * @return DataResponse + * @throws OCSBadRequestException + * @throws OCSForbiddenException + */ + public function linkFile(string $hash, string $path, string $fileFormat): DataResponse { + $this->logger->debug('Linking form {hash} to file at /{path} in format {fileFormat}', [ + 'hash' => $hash, + 'path' => $path, + 'fileFormat' => $fileFormat, + ]); + + try { + $form = $this->formMapper->findByHash($hash); + } catch (IMapperException $e) { + $this->logger->debug('Could not find form'); + throw new OCSNotFoundException(); + } + if ($form->getOwnerId() !== $this->currentUser->getUID()) { + $this->logger->debug('This form is not owned by the current user'); + throw new OCSForbiddenException(); + } + + $file = $this->submissionService->writeFileToCloud($form, $path, $fileFormat); + + $form->setFileId($file->getId()); + $form->setFileFormat($fileFormat); + + $this->formMapper->update($form); + + $filePath = $this->formsService->getFilePath($form); + + return new DataResponse([ + 'fileId' => $file->getId(), + 'fileFormat' => $fileFormat, + 'fileName' => $file->getName(), + 'filePath' => $filePath, + ]); } } diff --git a/lib/Db/Form.php b/lib/Db/Form.php index 14e4d5803..58eaac3bf 100644 --- a/lib/Db/Form.php +++ b/lib/Db/Form.php @@ -38,6 +38,10 @@ * @method void setDescription(string $value) * @method string getOwnerId() * @method void setOwnerId(string $value) + * @method int|null getFileId() + * @method void setFileId(int|null $value) + * @method string|null getFileFormat() + * @method void setFileFormat(string|null $value) * @method array getAccess() * @method void setAccess(array $value) * @method integer getCreated() @@ -60,6 +64,8 @@ class Form extends Entity { protected $title; protected $description; protected $ownerId; + protected $fileId; + protected $fileFormat; protected $accessJson; protected $created; protected $expires; @@ -99,6 +105,8 @@ public function read() { 'title' => (string)$this->getTitle(), 'description' => (string)$this->getDescription(), 'ownerId' => $this->getOwnerId(), + 'fileId' => $this->getFileId(), + 'fileFormat' => $this->getFileFormat(), 'created' => $this->getCreated(), 'access' => $this->getAccess(), 'expires' => (int)$this->getExpires(), diff --git a/lib/Migration/Version040010Date20240122133700.php b/lib/Migration/Version040010Date20240122133700.php new file mode 100644 index 000000000..ca0704a00 --- /dev/null +++ b/lib/Migration/Version040010Date20240122133700.php @@ -0,0 +1,62 @@ + + * + * @author Kostiantyn Miakshyn + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program 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 + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Forms\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version040010Date20240122133700 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->getTable('forms_v2_forms'); + $table->addColumn('file_id', Types::BIGINT, [ + 'notnull' => false, + 'default' => null, + 'length' => 11, + 'unsigned' => true, + ]); + + $table->addColumn('file_format', Types::STRING, [ + 'notnull' => false, + 'default' => null, + 'length' => 5, + ]); + + return $schema; + } +} diff --git a/lib/Service/FormsService.php b/lib/Service/FormsService.php index 0af552978..1db854db3 100644 --- a/lib/Service/FormsService.php +++ b/lib/Service/FormsService.php @@ -33,11 +33,13 @@ use OCA\Forms\Db\Share; use OCA\Forms\Db\ShareMapper; use OCA\Forms\Db\SubmissionMapper; - use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\IMapperException; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; use OCP\IGroup; use OCP\IGroupManager; +use OCP\IL10N; use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; @@ -65,6 +67,8 @@ public function __construct( private IUserManager $userManager, private ISecureRandom $secureRandom, private CirclesService $circlesService, + private IRootFolder $storage, + private IL10N $l10n, ) { $this->currentUser = $userSession->getUser(); } @@ -162,6 +166,15 @@ public function getForm(Form $form): array { $result['submissionCount'] = $this->submissionMapper->countSubmissions($form->getId()); } + if ($result['fileId']) { + try { + $result['filePath'] = $this->getFilePath($form); + // If file was deleted, set filePath to null + } catch (NotFoundException $e) { + $result['filePath'] = null; + } + } + return $result; } @@ -205,6 +218,9 @@ public function getPublicForm(Form $form): array { unset($formData['access']); unset($formData['ownerId']); unset($formData['shares']); + unset($formData['fileId']); + unset($formData['filePath']); + unset($formData['fileFormat']); return $formData; } @@ -608,4 +624,36 @@ public function areExtraSettingsValid(array $extraSettings, string $questionType } return true; } + + public function getFilePath(Form $form): ?string { + $fileId = $form->getFileId(); + + if (null === $fileId) { + return null; + } + + $folder = $this->storage->getUserFolder($form->getOwnerId()); + $nodes = $folder->getById($fileId); + + if (empty($nodes)) { + throw new NotFoundException('File not found'); + } + + $internalPath = array_shift($nodes)->getPath(); + + return $folder->getRelativePath($internalPath); + } + + public function getFileName(Form $form, string $fileFormat): string { + if (empty(Constants::SUPPORTED_EXPORT_FORMATS[$fileFormat])) { + throw new \InvalidArgumentException('Invalid file format'); + } + + // TRANSLATORS Appendix for CSV-Export: 'Form Title (responses).csv' + $fileName = $form->getTitle() . ' (' . $this->l10n->t('responses') . ').'.$fileFormat; + + // Sanitize file name, replace all invalid characters + return str_replace(mb_str_split(\OCP\Constants::FILENAME_INVALID_CHARS), '-', $fileName); + } + } diff --git a/lib/Service/SubmissionService.php b/lib/Service/SubmissionService.php index 538211bec..eb7f92ee4 100644 --- a/lib/Service/SubmissionService.php +++ b/lib/Service/SubmissionService.php @@ -27,33 +27,36 @@ use DateTime; use DateTimeZone; -use League\Csv\EncloseField; -use League\Csv\EscapeFormula; -use League\Csv\Reader; -use League\Csv\Writer; use OCA\Forms\Constants; use OCA\Forms\Db\Answer; use OCA\Forms\Db\AnswerMapper; +use OCA\Forms\Db\Form; use OCA\Forms\Db\FormMapper; use OCA\Forms\Db\QuestionMapper; use OCA\Forms\Db\Submission; use OCA\Forms\Db\SubmissionMapper; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\OCS\OCSException; use OCP\Files\File; use OCP\Files\IRootFolder; use OCP\Files\NotPermittedException; use OCP\IConfig; use OCP\IL10N; +use OCP\ITempManager; use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; use OCP\Mail\IMailer; + +use PhpOffice\PhpSpreadsheet\IOFactory; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Writer\Csv; + use Psr\Log\LoggerInterface; class SubmissionService { - /** @var FormMapper */ private $formMapper; @@ -97,7 +100,10 @@ public function __construct(FormMapper $formMapper, LoggerInterface $logger, IUserManager $userManager, IUserSession $userSession, - IMailer $mailer) { + IMailer $mailer, + private ITempManager $tempManager, + private FormsService $formsService, + ) { $this->formMapper = $formMapper; $this->questionMapper = $questionMapper; $this->submissionMapper = $submissionMapper; @@ -163,50 +169,79 @@ public function isUniqueSubmission(Submission $newSubmission): bool { /** * Export Submissions to Cloud-Filesystem - * @param string $hash of the form + * + * @param Form $form the form * @param string $path The Cloud-Path to export to - * @return string The written fileName + * @param string $fileFormat Format to export to + * @param string $ownerId of the form creator + * @return File The written file * @throws NotPermittedException */ - public function writeCsvToCloud(string $hash, string $path): string { - /** @var \OCP\Files\Folder|\OCP\Files\File $node */ - $node = $this->storage->getUserFolder($this->currentUser->getUID())->get($path); + public function writeFileToCloud(Form $form, string $path, string $fileFormat, ?string $ownerId = null): File { + if (empty(Constants::SUPPORTED_EXPORT_FORMATS[$fileFormat])) { + throw new \InvalidArgumentException('Invalid file format'); + } - // Get Data - $csvData = $this->getSubmissionsCsv($hash); + $this->logger->debug('Export submissions for form: {hash} to Cloud at: /{path} in format {fileFormat}', [ + 'hash' => $form->getHash(), + 'path' => $path, + 'fileFormat' => $fileFormat, + ]); + + $fileName = $this->formsService->getFileName($form, $fileFormat); - // If chosen path is a file, get folder, if file is csv, use filename. + /** @var \OCP\Files\Folder|File $node */ + if ($ownerId) { + $node = $this->storage->getUserFolder($ownerId)->get($path); + } else { + $node = $this->storage->getUserFolder($this->currentUser->getUID())->get($path); + } + + // If chosen path is a file with expected extension - overwrite it and use parent folder otherwise. if ($node instanceof File) { - if ($node->getExtension() === 'csv') { - $csvData['fileName'] = $node->getName(); + if ($node->getExtension() === $fileFormat) { + $fileName = $node->getName(); } /** @var \OCP\Files\Folder $node */ $node = $node->getParent(); } - // check if file exists, create otherwise. + // Check if file exists, create otherwise. try { - /** @var \OCP\Files\File $file */ - $file = $node->get($csvData['fileName']); + /** @var File $file */ + $file = $node->get($fileName); } catch (\OCP\Files\NotFoundException $e) { - $node->newFile($csvData['fileName']); - /** @var \OCP\Files\File $file */ - $file = $node->get($csvData['fileName']); + $node->newFile($fileName); + /** @var File $file */ + $file = $node->get($fileName); } + // Get Data + $submissionsData = $this->getSubmissionsData($form, $fileFormat, $file); + // Write the data to file - $file->putContent($csvData['data']); + try { + $file->putContent($submissionsData); + } catch (NotPermittedException $e) { + $this->logger->warning('Failed to export Submissions: Not allowed to write to file'); + throw new OCSException('Not allowed to write to file.', previous: $e); + } - return $csvData['fileName']; + return $file; } /** - * Create CSV from Submissions to form - * @param string $hash Hash of the form - * @return array Array with 'fileName' and 'data' + * Create/update file from Submissions to form + * + * @param Form $form Form to export + * @param string $fileFormat Format to export to + * @param File|null $file File with already exported submissions to append to + * @return string File content */ - public function getSubmissionsCsv(string $hash): array { - $form = $this->formMapper->findByHash($hash); + public function getSubmissionsData(Form $form, string $fileFormat, ?File $file = null): string { + if (empty(Constants::SUPPORTED_EXPORT_FORMATS[$fileFormat])) { + throw new \InvalidArgumentException('Invalid file format'); + } try { $submissionEntities = $this->submissionMapper->findByForm($form->getId()); @@ -214,9 +249,17 @@ public function getSubmissionsCsv(string $hash): array { // Just ignore, if no Data. Returns empty Submissions-Array } + // Oldest first + $submissionEntities = array_reverse($submissionEntities); + $questions = $this->questionMapper->findByForm($form->getId()); $defaultTimeZone = date_default_timezone_get(); - $userTimezone = $this->config->getUserValue($this->currentUser->getUID(), 'core', 'timezone', $defaultTimeZone); + + if ($this->currentUser == null) { + $userTimezone = $this->config->getUserValue($form->getOwnerId(), 'core', 'timezone', $defaultTimeZone); + } else { + $userTimezone = $this->config->getUserValue($this->currentUser->getUID(), 'core', 'timezone', $defaultTimeZone); + } // Process initial header $header = []; @@ -245,7 +288,7 @@ public function getSubmissionsCsv(string $hash): array { $row[] = $user->getUID(); $row[] = $user->getDisplayName(); } - + // Date $row[] = date_format(date_timestamp_set(new DateTime(), $submission->getTimestamp())->setTimezone(new DateTimeZone($userTimezone)), 'c'); @@ -254,7 +297,7 @@ public function getSubmissionsCsv(string $hash): array { $questionId = $answer->getQuestionId(); // If key exists, insert separator - if (key_exists($questionId, $carry)) { + if (array_key_exists($questionId, $carry)) { $carry[$questionId] .= '; ' . $answer->getText(); } else { $carry[$questionId] = $answer->getText(); @@ -264,48 +307,46 @@ public function getSubmissionsCsv(string $hash): array { }, []); foreach ($questions as $question) { - $row[] = key_exists($question->getId(), $answers) - ? $answers[$question->getId()] - : null; + $row[] = $answers[$question->getId()] ?? null; } $data[] = $row; } - // TRANSLATORS Appendix for CSV-Export: 'Form Title (responses).csv' - $fileName = $form->getTitle() . ' (' . $this->l10n->t('responses') . ').csv'; - // Sanitize file name, replace all invalid characters - $fileName = str_replace(mb_str_split(\OCP\Constants::FILENAME_INVALID_CHARS), '-', $fileName); - - return [ - 'fileName' => $fileName, - 'data' => $this->array2csv($header, $data), - ]; + return $this->exportData($header, $data, $fileFormat, $file); } - + /** - * Convert an array to a csv string - * @param array $array - * @return string + * @param array $header + * @param array> $data */ - private function array2csv(array $header, array $records): string { - if (empty($header) && empty($records)) { - return ''; + private function exportData(array $header, array $data, string $fileFormat, ?File $file = null): string { + if ($file && $file->getContent()) { + $existentFile = $this->tempManager->getTemporaryFile($fileFormat); + file_put_contents($existentFile, $file->getContent()); + $spreadsheet = IOFactory::load($existentFile); + } else { + $spreadsheet = new Spreadsheet(); } - // load the CSV document from a string - $csv = Writer::createFromString(''); - $csv->setOutputBOM(Reader::BOM_UTF8); - $csv->addFormatter(new EscapeFormula()); - EncloseField::addTo($csv, "\t\x1f"); - - // insert the header - $csv->insertOne($header); + $activeWorksheet = $spreadsheet->getSheet(0); + foreach ($header as $columnIndex => $value) { + $activeWorksheet->setCellValue([$columnIndex + 1, 1], $value); + } + foreach ($data as $rowIndex => $row) { + foreach ($row as $columnIndex => $value) { + $activeWorksheet->setCellValue([$columnIndex + 1, $rowIndex + 2], $value); + } + } - // insert all the records - $csv->insertAll($records); + $exportedFile = $this->tempManager->getTemporaryFile($fileFormat); + $writer = IOFactory::createWriter($spreadsheet, ucfirst($fileFormat)); + if ($writer instanceof Csv) { + $writer->setUseBOM(true); + } + $writer->save($exportedFile); - return $csv->toString(); + return file_get_contents($exportedFile); } /** @@ -315,7 +356,6 @@ private function array2csv(array $header, array $records): string { * @return boolean If the submission is valid */ public function validateSubmission(array $questions, array $answers): bool { - // Check by questions foreach ($questions as $question) { $questionId = $question['id']; diff --git a/psalm.xml b/psalm.xml index 84edefd94..41f5835cc 100644 --- a/psalm.xml +++ b/psalm.xml @@ -38,4 +38,7 @@ + + + diff --git a/src/views/Results.vue b/src/views/Results.vue index 743a13dde..d83e5bc01 100644 --- a/src/views/Results.vue +++ b/src/views/Results.vue @@ -24,6 +24,13 @@ @@ -89,28 +112,98 @@ - - - - - {{ t('forms', 'Save CSV to Files') }} - - + + + {{ t('forms', 'Open spreadsheet') }} + + + + - {{ t('forms', 'Download CSV') }} + {{ t('forms', 'Create spreadsheet') }} - -