diff --git a/.gitignore b/.gitignore index 8b093469de16a3ff5b64c9f6ea98afcbe51247e0..a582212e0689392793bb1889e7e27a221769b68c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ .phpunit.result.cache nbprojec nohup.out +.DS_Store # Created by .ignore support plugin (hsz.mobi) .python_virtualenv/ @@ -71,6 +72,8 @@ composer.lock /javascript/src/.vscode /javascript/src/npm-debug.log +/javascript/build/dist +/less/dist /docker/chromedriver /docker/geckodriver diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8061bfbf5356fbb5fc2dbef824c3786a9fd62caf..6bc7ffb4ac638c7896dbd5b9ed7098500ecc7fed 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,7 +11,8 @@ variables: stages: - before - build -# - selenium + - test + # - selenium #documentation: # stage: before @@ -29,10 +30,10 @@ snapshot: paths: - build/ script: - - make VERSION=${VERSION} phpunit_snapshot + - make VERSION=${VERSION} snapshot - chmod a+r qfq_${VERSION}_*.zip - - echo "mv qfq_${VERSION}_*.zip qfq_${VERSION}_${RELDATE}-${CI_BUILD_REF_NAME}.zip" - - mv qfq_${VERSION}_*.zip qfq_${VERSION}_${RELDATE}-${CI_BUILD_REF_NAME}.zip + - echo "mv qfq_${VERSION}_*.zip qfq_${VERSION}_${RELDATE}-${CI_COMMIT_REF_NAME}.zip" + - mv qfq_${VERSION}_*.zip qfq_${VERSION}_${RELDATE}-${CI_COMMIT_REF_NAME}.zip - scp qfq_${VERSION}_*.zip w16:qfq/snapshots/ - mv qfq_${VERSION}_*.zip build/qfq.zip @@ -45,11 +46,17 @@ release: paths: - build/ script: - - make VERSION=${VERSION} phpunit_release + - make VERSION=${VERSION} release - chmod a+r qfq_${VERSION}_*.zip - scp qfq_${VERSION}_*.zip w16:qfq/releases/ - mv qfq_${VERSION}_*.zip build/qfq.zip +tests: + stage: test + script: + - make phpunit + + #selenium: # stage: selenium # script: diff --git a/.readthedocs.yml b/.readthedocs.yml index b977cbddbf74da9c2923a377fb46909faa617843..7ad741af604aba0f9151d8273789ca3a85f11e6e 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -5,6 +5,12 @@ # Required version: 2 +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.12" + # Build documentation in the docs/ directory with Sphinx sphinx: configuration: Documentation/conf.py @@ -14,8 +20,7 @@ formats: - pdf - epub -# Optionally set the version of Python and requirements required to build your docs +# Requirements to build your docs python: - version: 3.7 install: - requirements: Documentation/docker-sphinx-qfq/requirements.txt \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 90eaf33da2d587dc612e9edf411106b80ec6c161..c44fc8d33280a8106715348a05031774c28c745b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ .. --------------------------------------------used to the update the records specified ------ .. Best Practice T3 reST: https://docs.typo3.org/m/typo3/docs-how-to-document/master/en-us/WritingReST/CheatSheet.html .. Reference: https://docs.typo3.org/m/typo3/docs-how-to-document/master/en-us/WritingReST/Index.html +.. .. Italic *italic* .. Bold **bold** .. Code ``text`` @@ -19,7 +20,6 @@ .. Internal Link: :ref:`downloadButton` (default url text) or :ref:`download Button<downloadButton>` (explicit url text) .. Add Images: .. image:: ./Images/a4.jpg .. -.. .. Admonitions .. .. note:: .. important:: .. tip:: .. warning:: .. Color: (blue) (orange) (green) (red) @@ -52,6 +52,59 @@ Features Bug Fixes ^^^^^^^^^ +Version 23.10.1 +--------------- + +Date: 22.10.2023 + +Notes +^^^^^ + +Features +^^^^^^^^ + +* #15682 / Subrecord hide please save record first if there is no table title. +* #15098 / Implemented qfqFunction in QFQ variable for usage in forms. +* Doc: Replace many places single back tick by double back tick. Add hint for 'Row size too large'. Added Enis & Jan + as Developer. Add hint use mysqldump with one row per record. + +Bug Fixes +^^^^^^^^^ + +* #17003 / inline edit - dark mode has wrong css path. +* #17075 / Fix broken '... AS _restClient'. +* #17091 / upload_Incorrect_integer_value_fileSize. +* RTD: Fix broken readthedocs rendering. + +Version 23.10.0 +--------------- + +Date: 05.10.2023 + +Features +^^^^^^^^ + +* #16350 / QFQ Table 'FormSubmiLog': update recordid after insert. +* #16350 / sql.log: reference to FormSubmitLog entry. All SQL statements generated by one HTTP Post (Form Submit) can + be identified. +* #16350 / Do not log Dirty. +* #16350 / If the T3 instance is behind a proxy, log HTTP_X_REAL_IP instead of REMOTE_ADDR in logfiles. +* #16584 / FormEditor Report: Default without statistics. +* #16589 / Implemented language configuration in backend for tt-content type qfq. +* #16798 / Report inline edit v2. Improved search inside inline edit report. Whole content will be searched. Added + ability to switch the editor to dark mode. +* Doc: Refactor description of {{random:V}}. +* Doc: Add config option 'protectedFolderCheck'. + +Bug Fixes +^^^^^^^^^ + +* #16573 / Fixed wrong built date and datetime string if default value was given. +* #16574 / Added multiple siteConfigurations compatibility for typo3 v10 and 11. +* #16616 / Fixed typeahead api query response problem if typeahead sql is not used. +* #16664 / Fix multi db user error. +* #16975 / Fix problem if a 'Form Submit' contains more than 64kB data. This can happen easily for 'fabric' elements. + Version 23.6.4 -------------- @@ -99,6 +152,7 @@ Notes Bug Fixes ^^^^^^^^^ +* #16372 / Upload Element 'Undefined index htmlDownloadButton' und 'unknown mode ID' * #16381 / Form title dynamic update broken. * #16392 / Reevaluate sanitize class for each store. * Fix db column enum dropdown 'data truncated' @@ -156,6 +210,7 @@ Bug Fixes * #16004 / Template Group Fields broken. * #16046 / Datepicker not initialized in Template Group. * #16051 / Dropdown menu: Download file broken with ZIP File. +* #16055 / Dropdown option with render mode 3 format problem. * #16064 / Dynamic Update Checkbox and Form Store in parameter. * #16073 / saveButtonActive is disabled after first save. * #16201 / Character Count Class Quotation Mark. diff --git a/Documentation-develop/CONFIG.md b/Documentation-develop/CONFIG.md index 27f440c5bbf8d493c87546244f564348c8701404..b71f07f1b81d3839ee5bd16fde6744276de914aa 100644 --- a/Documentation-develop/CONFIG.md +++ b/Documentation-develop/CONFIG.md @@ -19,17 +19,109 @@ IMATHUZH\Qfq\Core\Store\Store::fillStoreSystem() IMATHUZH\Qfq\Core\Store\Config::getConfigArray() IMATHUZH\Qfq\Core\Store\Config::readConfig() -To create a new QFQ Config option: +How to create a new config option +================================== -ext_conf_template.txt: +To create a new config option, you have to make the changes specified below in the following files. -* create new entry -* set a default value in ext_conf_template.txt +ext_conf_template.txt +--------------------- -config::setDefaults() +The following variables must be set: -* Define defaults if nothing is given +**cat** +(category where the new option will be located, in the extension configuration of your typo3 backend) -DatabaseUpdate=>checkT3QfqConfig(): +**type** (datatype of the config option) -* In case existing installations should get a new default during QFQ update. +possible datatypes: +* boolean (checkbox) +* color (colorpicker) +* int (integer value) +* int+ (positive integer value) +* integer (integer value) +* offset (offset) +* options (option select) +```type=options[label1=value1,label2=value2,value3];``` +* small (small text field) +* string (text field) +* user (user function) +```type=user[Vendor\MyExtensionKey\ViewHelpers\MyConfigurationClass->render];``` +* wrap (wrap field) + +**label** (title and description of the config option, split by ":") + +**myVariable** (name the variable of the config option and assign a default value) + +**Example** + +``` +# cat=config/config; type=boolean; label=MyLabel:Description +myVariable = value1 +``` + +Constants.php +------------- + +Best practice would be defining constants with the name of your variable, +since this name should never be changed. + +``` +const SYSTEM_MY_VARIABLE = 'myVariable'; +const F_MY_VARIABLE = 'SYSTEM_MY_VARIABLE'; +const FE_MY_VARIABLE = 'SYSTEM_MY_VARIABLE'; +``` + + +Config.php +--------------------- + +In the function **setDefaults()** a default value should be set. +</br>Important in case of new variables: new variables do not exist in QFQ extension config and do not get the default defined in ext_conf_template.txt + +``` +default = [ + ... + SYSTEM_MY_VARIABLE => 'true', + ... +]; +``` + +Support.php +---------- + +To set the default value of a FormElement you can use the **setFeDefaults()** function. +</br>Wich provides the default value for the FormElement using the **system store**. +</br>The **system store** contains all the variables defined in the typo3 extension configuration. + +``` +self::setIfNotSet($formElement, FE_MY_VARIABLE, $store->getVar(SYSTEM_MY_VARIABLE, STORE_SYSTEM)); +``` + +StoreTest.php +------------- + +The expected default value must be specified in the **testConfigIniDefaultValues()** function so that the unit test can run without errors. + +``` +$expect = [ + ... + SYSTEM_MY_VARIABLE => 'true', + ... +]; +``` + +How to handle variables +-------------------------------------- + +Here is an example on how you would go about handling variables that are defined on all levels (SYSTEM -> Form -> FormElement) + +``` +$myVar = $store->getVar(SYSTEM_MY_VARIABLE, STORE_SYSTEM); +if(isset($this->formSpec[F_MY_VARIABLE])){ + $myVar = $this->formSpec[F_MY_VARIABLE]; +} +if(isset($this->formElement[FE_MY_VARIABLE])){ + $myVar = $this->formElement[FE_MY_VARIABLE]; +} +``` diff --git a/Documentation-develop/NewVersion.md b/Documentation-develop/NewVersion.md index 041021b92c6a6a873c00823d75c568f031dbb250..6d054813b09ae6158e4b4a51c86d7279dfe0787b 100644 --- a/Documentation-develop/NewVersion.md +++ b/Documentation-develop/NewVersion.md @@ -44,7 +44,7 @@ Neue Versionsnummer **Achtung**: die Release Minor darf KEINE fuehrenden Nullen enthalten!!! Ansonsten funktioniert die Verteilung vie TER nicht. - **Auto**: ./setVersion.sh 23.6.4 + **Auto**: ./setVersion.sh 23.10.1 Manuell: @@ -56,7 +56,7 @@ Neue Versionsnummer * **Commit & Push** to develop branch: - New version v23.6.4 + New version v23.10.1 6) * Merge 'Develop' to **Master**: git.math.uzh.ch > QFQ > Merge Requests > New merge request > 'Develop >> Master' @@ -64,12 +64,12 @@ Neue Versionsnummer 7) **New Tag**: - * Neuen tag via Browser auf dem **master** branch setzen: git.math.uzh.ch > QFQ > Repository > Tags > New tag + * Neuen tag via Browser auf dem **master** branch setzen: git.math.uzh.ch > QFQ > Repository > Code > Tags > New tag - Tag: v23.6.4 + Tag: v23.10.1 # Den tag mit diesem Command zu setzen scheint den Build Prozess nicht zu triggern. - git tag -a v23.6.4 -m 'New version v23.6.4' git push + git tag -a v23.10.1 -m 'New version v23.10.1' git push 7) **Merge 'master' into 'develop'** diff --git a/Documentation-develop/PARSER.md b/Documentation-develop/PARSER.md new file mode 100644 index 0000000000000000000000000000000000000000..6303f165397742e861471067ebdbbb9c2f2cd03b --- /dev/null +++ b/Documentation-develop/PARSER.md @@ -0,0 +1,119 @@ +# Parsers and Tokenizer + +## Motivation and use cases + +Parsing values for special QFQ columns starting from simple lists +of key-value pairs to enhanced JSON strings. + +## Overview of classes + +All classes are defined in the namespace `IMATHUZH\Qfq\Core\Parser`. + +### StringTokenizer + +This class provides a generator that iterates over a string +and returns tokens bound by predefined delimiters. The delimiters +are search in a smart way: +* delimiters escaped with a backslash are ignored in the search +* the parser can distinguish between escaping and escaped backslashes, + i.e. the colon (as a delimiter) is ignored in the string `ab\:cd` + but not in `ab\\:cd` +* a part of a string between quotes is treated as a plain text - all delimiters + are ignored (and the quote characters are removed). + +#### Examples with delimiters `:,|`: + +| Input string | Resulting sequence of tokens | +|-------------------|------------------------------| +| `ab:cd,ef\|gh` | `'ab' 'cd' 'ef' 'gh'` | +| `"ab:cd",ef\\|gh` | `'ab:cd' 'ef\|gh'` | + +#### Usage +<pre><code class="php">$tokenizer = new StringTokenizer(':,|'); +foreach ($tokenizer->tokenized('ab:cd,ef\|gh') as list($token, $delimiter)) { + // $token is an instance of Token class: + // $token->value is a string representation of the token + // $token->isString is true if the token is a string (quotes were used) + // $token->empty() is true for a token generated only from whitespace characters + // $delimiter === null when the end of the string is reached +} +</code></pre> + +### SimpleParser + +This class parses a string into a list of tokens separated by delimiters. +Comparing to `StringTokenizer`, the returned tokens literal values or special objects +the processing can be tweaked by options provided as an array in the second parameter. + +| Parameters key | Type | Meaning | +|------------------------|------|------------------------------------------------------------------------| +| `OPTION_PARSE_NUMBERS` | bool | Convert tokens to numbers when possible | +| `OPTION_KEEP_SIGN` | bool | Creates an instance of `SignedNumber` if a number has an explicit sign | +| `OPTION_KEY_IS_VALUE` | bool | Keys with no values are assigned its name as the value | +| `OPTION_EMPTY` | any | The value used for empty tokens | + +Note that the option `OPTION_KEY_IS_VALUE` is not used by `SimpleParser` but it is used +by derived classes. + +**Note**: the option `OPTION_KEEP_SIGN` is used by `jwt` column, so that claims +`exp` and `nbf` can be specified either with absolute (no plus) or relative +(with a plus) timestamps. + +#### Usage + +<pre><code class="php">$parser = new SimpleParser(":|"); +// By default five special values are configured: +// 'null' -> null +// 'true', 'yes' -> true +// 'false', 'no' -> false +// More can be defined by updating $specialValues property: +$parser->specialValues['qfq'] = 'QFQ is great'; + +// This returns an array ['abc', 'efg', 123, true, 'QFQ is great'] +$parser->parse("abc:efg|123|yes:qfq"); + +// The tokens can be iterated as follows +foreach($parser->iterate("abc:efg|123|yes") as $token) { + ... +} +</code></pre> + +### KVPairListParser + +This class parses a list of key-value pairs into an associative array. +It requires two arguments: the list separator and key-value separator. + +#### Usage +<pre><code class="php">// Default separators are , and : +$parser = new KVPairListParser("|", "="); +$parser->parse("a=43|b=false|xyz='a|b'"); +// result: [ 'a' => 43, 'b' => false, 'xyz' => 'a|b' ] + +foreach ($parser->iterate("a=43|b=false|xyz='a|b'") as $key => $value) { + ... +} +</code></pre> + +### MixedTypeParser + +This parser understands both lists and dictionaries and both structures can be nested. +The constructor must be provided six delimiters in one string: list separator, +key-value separator, list delimiters (begin and end), and dictionary delimiters +(begin and end). The default value is `,:[]{}`. It is also possible to replace +the list and dictionary delimiters with spaces, in which case the parser will +ignore it. For instance +* `new MixedTypeParser(',:[]')` can parse nested lists, but not dictionaries (the string is padded) +* `new MixedTypeParser(',: {}')` can parse nested dictionaries, but not lists + +This parser can be seen as an extension to a JSON parser: strings does not have +to be enclosed with quotes. + +#### Usage + +<pre><code class="php">$parser = new MixedTypeParser(',:[]{}', [ /* options */ ]); +$parser->parse('[0, { a: 14, b: 16 }, abc]'); +$parser->parseList('abc, [x, y, z], {a:15}, xyz'); +$parser->parseDictionary('num:15, arr:[x, y, z], dict:{a:15}, str:xyz'); +</code></pre> + +**Note**: there is no meaningful `iterate()` method. diff --git a/Documentation-develop/SETUP.md b/Documentation-develop/SETUP.md index 6bce1e5c7536a77fca23d6b971203552f8df4a9b..c63daa92a2afbf11f76c337989ad745c22be7ff9 100644 --- a/Documentation-develop/SETUP.md +++ b/Documentation-develop/SETUP.md @@ -4,9 +4,9 @@ QFQ Development Setup Requirements ============ - * Node.js - * PHP7.x - for local testing - * Python +* Node.js +* PHP7.x/8.x - for local testing +* Python * IDE (Used: PhpStorm, VSCode) Mac OS X diff --git a/Documentation/ApplicationTest.rst b/Documentation/ApplicationTest.rst index 4dfbdd9622b0650210d61c44719f9c4175affe6c..a98af9e7953189fe70b0c250efdd7e35d9cb22cf 100644 --- a/Documentation/ApplicationTest.rst +++ b/Documentation/ApplicationTest.rst @@ -40,8 +40,8 @@ Application Test With a framework like https://www.seleniumhq.org/ it's possible to play and verify unattended test cases. -To assist such frameworks and to make the tests reliable, an individual tag might be assigned to HTML elements which have to -interact with the test framework. +To assist such frameworks and to make the tests reliable, an individual tag might be assigned to HTML elements which have +to interact with the test framework. Form ---- diff --git a/Documentation/Concept.rst b/Documentation/Concept.rst index 7d3bf589c9200975486ae8b2c7ded999d4671af1..d6d7dbde60ef0247c61951b4a04e9c5a8b63c871 100644 --- a/Documentation/Concept.rst +++ b/Documentation/Concept.rst @@ -89,98 +89,100 @@ QFQ Keywords (Bodytext) **All of these parameters are optional.** -+-------------------------+---------------------------------------------------------------------------------+ -| Name | Explanation | -+=========================+=================================================================================+ -| form | | Formname. | -| | | Static: **form = person** | -| | | By SIP: **form = {{form:SE}}** | -| | | By SQL: **form = {{SELECT c.form FROM Config AS c WHERE c.id={{a:C}} }}** | -+-------------------------+---------------------------------------------------------------------------------+ -| r | | <record id>. The form will load the record with the specified id. | -| | | Static: **r = 123** | -| | | By SQL: **r = {{SELECT ...}}** | -| | | If not specified, the SIP parameter 'r' is used. | -+-------------------------+---------------------------------------------------------------------------------+ -| dbIndex | E.g. `dbIndex = {{indexQfq:Y}}` Select a DB index. Only necessary if a | -| | different than the standard DB should be used. | -+-------------------------+---------------------------------------------------------------------------------+ -| debugShowBodyText | If='1' and :ref:`configuration`:*showDebugInfo: yes*, shows a | -| | tooltip with bodytext | -+-------------------------+---------------------------------------------------------------------------------+ -| sqlLog | Overwrites :ref:`configuration`: :ref:`SQL_LOG` . Only affects `Report`, | -| | not `Form`. | -+-------------------------+---------------------------------------------------------------------------------+ -| sqlLogMode | Overwrites :ref:`configuration`: :ref:`SQL_LOG_MODE<SQL_LOG_MODE>` . | -| | Only affects `Report`, not `Form`. | -+-------------------------+---------------------------------------------------------------------------------+ -| render | See :ref:`report-render`. Overwrites :ref:`configuration`: render. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.fbeg | Start token for every field (=column) | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.fend | End token for every field (=column) | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.fsep | Separator token between fields (=columns) | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.fskipwrap | Skip wrapping (via fbeg, fsep, fend) of named columns. Comma separated list of | -| | column id's (starting at 1). See also the special column name '_noWrap' to | -| | suppress wrapping. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.shead | Static start token for whole <level>, independent if records are selected | -| | Shown before `head`. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.stail | Static end token for whole <level>, independent if records are selected. | -| | Shown after `tail`. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.head | Dynamic start token for whole <level>. Only if at least one record is select. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.tail | Dynamic end token for whole <level>. Only if at least one record is select. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.rbeg | Start token for row. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.rbgd | Alternating (per row) token. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.rend | End token for row. Will be rendered **before** subsequent levels are processed | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.renr | End token for row. Will be rendered **after** subsequent levels are processed | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.rsep | Seperator token between rows | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.sql | SQL Query | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.twig | Twig Template | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.althead | If <level>.sql has no rows selected (empty), these token will be rendered. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.altsql | If <level>.sql has no rows selected (empty) or affected (delete, update, insert)| -| | the <altsql> will be fired. Note: Sub queries of <level> are not fired, even if | -| | <altsql> selects some rows. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.content | | *show* (default): content of current and sub level are directly shown. | -| | | *hide*: content of current and sub levels are **stored** and not shown. | -| | | *hideLevel*: content of current and sub levels are **stored** and only sub | -| | | levels are shown. | -| | | *store*: content of current and sub levels are **stored** and shown. | -| | | To retrieve the content: `{{<level>.line.content}}`. | -| | | See :ref:`syntax-of-report` | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.line.count | Current row index. Will be replaced before the query is fired in case of | -| | ``<level>`` is an outer/previous level or it will be replaced after a query is | -| | fired in case ``<level>`` is the current level. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.line.total | Total rows (MySQL ``num_rows`` for *SELECT* and *SHOW*, MySQL ``affected_rows`` | -| | for *UPDATE* and *INSERT*. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.line.insertId | Last insert id for *INSERT*. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.line.content | Show content of `<level>` (content have to be stored via <level>.content=....) | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.line.altCount | Like 'line.count' but for 'alt' query. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.line.altTotal | Like 'line.total' but for 'alt' query. | -+-------------------------+---------------------------------------------------------------------------------+ -| <level>.line.altInsertId| Like 'line.insertId' but for 'alt' query. | -+-------------------------+---------------------------------------------------------------------------------+ ++--------------------------------+---------------------------------------------------------------------------------+ +| Name | Explanation | ++================================+=================================================================================+ +| form | | Formname. | +| | | Static: **form = person** | +| | | By SIP: **form = {{form:SE}}** | +| | | By SQL: **form = {{SELECT c.form FROM Config AS c WHERE c.id={{a:C}} }}** | ++--------------------------------+---------------------------------------------------------------------------------+ +| r | | <record id>. The form will load the record with the specified id. | +| | | Static: **r = 123** | +| | | By SQL: **r = {{SELECT ...}}** | +| | | If not specified, the SIP parameter 'r' is used. | ++--------------------------------+---------------------------------------------------------------------------------+ +| dbIndex | E.g. `dbIndex = {{indexQfq:Y}}` Select a DB index. Only necessary if a | +| | different than the standard DB should be used. | ++--------------------------------+---------------------------------------------------------------------------------+ +| debugShowBodyText | If='1' and :ref:`configuration`:*showDebugInfo: yes*, shows a | +| | tooltip with bodytext | ++--------------------------------+---------------------------------------------------------------------------------+ +| sqlLog | Overwrites :ref:`configuration`: :ref:`SQL_LOG` . Only affects `Report`, | +| | not `Form`. | ++--------------------------------+---------------------------------------------------------------------------------+ +| sqlLogMode | Overwrites :ref:`configuration`: :ref:`SQL_LOG_MODE<SQL_LOG_MODE>` . | +| | Only affects `Report`, not `Form`. | ++--------------------------------+---------------------------------------------------------------------------------+ +| render | See :ref:`report-render`. Overwrites :ref:`configuration`: render. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.fbeg | Start token for every field (=column) | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.fend | End token for every field (=column) | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.fsep | Separator token between fields (=columns) | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.fskipwrap | Skip wrapping (via fbeg, fsep, fend) of named columns. Comma separated list of | +| | column id's (starting at 1). See also the :ref:`special-column-names` '_noWrap' | +| | to suppress wrapping. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.shead | Static start token for whole <level>, independent if records are selected | +| | Shown before `head`. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.stail | Static end token for whole <level>, independent if records are selected. | +| | Shown after `tail`. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.head | Dynamic start token for whole <level>. Only if at least one record is select. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.tail | Dynamic end token for whole <level>. Only if at least one record is select. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.rbeg | Start token for row. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.rbgd | Alternating (per row) token. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.rend | End token for row. Will be rendered **before** subsequent levels are processed | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.renr | End token for row. Will be rendered **after** subsequent levels are processed | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.rsep | Seperator token between rows | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.sql | SQL Query | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.twig | Twig Template | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.althead | If <level>.sql has no rows selected (empty), these token will be rendered. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.altsql | If <level>.sql has no rows selected (empty) or affected (delete, update, insert)| +| | the <altsql> will be fired. Note: Sub queries of <level> are not fired, even if | +| | <altsql> selects some rows. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level>.content | | *show* (default): content of current and sub level are directly shown. | +| | | *hide*: content of current and sub levels are **stored** and not shown. | +| | | *hideLevel*: content of current and sub levels are **stored** and only sub | +| | | levels are shown. | +| | | *store*: content of current and sub levels are **stored** and shown. | +| | | To retrieve the content: `{{<level>.line.content}}`. | +| | | See :ref:`syntax-of-report` | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level|alias>.line.count | Current row index. Will be replaced before the query is fired in case of | +| | ``<level>``/``<alias>`` is an outer/previous level or it will be replaced after | +| | a query is fired in case ``<level>``/``<alias>`` is the current level. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level|alias>.line.total | Total rows (MySQL ``num_rows`` for *SELECT* and *SHOW*, MySQL ``affected_rows`` | +| | for *UPDATE* and *INSERT*. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level|alias>.line.insertId | Last insert id for *INSERT*. | +| <alias>.line.insertId | | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level|alias>.line.content | Show content of `<level>`/`<alias>` (content have to be stored via | +| | <level>.content=... or <alias>.content=...). | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level|alias>.line.altCount | Like 'line.count' but for 'alt' query. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level|alias>.line.altTotal | Like 'line.total' but for 'alt' query. | ++--------------------------------+---------------------------------------------------------------------------------+ +| <level|alias>.line.altInsertId | Like 'line.insertId' but for 'alt' query. | ++--------------------------------+---------------------------------------------------------------------------------+ .. _`report-render`: diff --git a/Documentation/Form.rst b/Documentation/Form.rst index 9c5ba9f8b9cc6104cb63a7aa30a8e9c12460ad06..383aa52fa8934afb240ecae83e581db99cbedccb 100644 --- a/Documentation/Form.rst +++ b/Documentation/Form.rst @@ -126,31 +126,41 @@ FormElement processing order: .. _record_locking: + Record locking -------------- -Forms and 'record delete'-function support basic record locking. A user opens a form: starting with the first change of content, a -record lock will be acquired in the background. If the lock is denied (e.g. another user already owns a lock on the record) an -alert is shown. -By default the record lock mode is 'exclusive' and the default timeout is 15 minutes. Both can be configured per form. -The general timeout can also be configured in :ref:`configuration` (will be copied to the form during creating the form). - -The lock timeout counts from the first change, not from the last modification on the form. - -If a timeout expires, the lock becomes invalid. During the next change in a form, the lock is acquired again. +Support for record locking is given with mode: -A lock is assigned to a record of a table. Multiple forms, with the same primary table, uses the same lock for a given record. +* *exclusive*: user can't force a write. -If a `Form` acts on further records (e.g. via FE action), those further records are not protected by this basic record locking. + * Including a timeout (default 15 mins recordLockTimeoutSeconds in :ref:`configuration`) for maximum lock time. -If a user tries to delete a record and another user already owns a lock on that record, the delete action is denied. +* *advisory*: user is only warned, but allowed to overwrite. +* *none*: no bookkeeping about locks. -If there are different locking modes in multiple forms, the most restricting mode applies for the current lock. +Details: + +* For 'new' records (r=0) there is no locking at all. +* The record locking protection is based on the `tablename` and the `record id`. Different `Forms`, with the same primary table, + will be protected by record locking. +* Action-`FormElements` updating non primary table records are not + protected by 'record locking': the QFQ record locking is *NOT 100%*. +* The 'record locking' mode will be specified per `Form`. If there are multiple Forms with different modes, and there is + already a lock for a `tablename` / `record id` pair, the most restrictive will be applied. +* A user opens a form: starting with the first change of content, a record lock will be acquired in the background. If + the lock is denied (e.g. another user already owns a lock on the record) an alert is shown. This means: the lock timeout + counts from the first change, not from the last modification on the form. +* If a timeout expires, the lock becomes invalid. During the next change in a form, the lock is acquired again. +* A lock is assigned to a record of a table. Multiple forms, with the same primary table, uses the same lock for a given record. +* If a user tries to delete a record and another user already owns a lock on that record, the delete action is denied. +* If there are different locking modes in multiple forms, the most restricting mode applies for the current lock. +* If the same user opens the same recording in different tabs or browsers, the user has the possibility to skip a lock. Exclusive ^^^^^^^^^ -An existing lock on a record forbids any write action on that record. +An existing lock on a record forbids any write action on that record. Exception: locks owned by the same user might be overwritten. Advisory ^^^^^^^^ @@ -228,7 +238,7 @@ Form Settings +-------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------+ |multiDetailForm | NOT IMPLEMENTED - Optional. Form to open, if a record is selected to edit (double click on record line) | +-------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------+ -|multiDetailFormParameter | NOT IMPLEMENTED - Optional. Translated Parameter submitted to detailform (like subrecord parameter) | +|multiDetailFormParameter | NOT IMPLEMENTED - Optional. Translated Parameter submitted to detail form (like subrecord parameter) | +-------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------+ .. _`form-permitNewEdit`: @@ -261,7 +271,7 @@ Depending on `r`, the following access permission will be taken: * is *always* the preferred way. With 'sip' it's not necessary to differ between logged in or not, cause the SIP only exist and is only valid, if it's created via QFQ/report earlier. This means 'creating' the SIP implies - 'access granted'. The grant will be revoked when the QFQ session is destroyed - this happens when a user loggs out or + 'access granted'. The grant will be revoked when the QFQ session is destroyed - this happens when a user logs out or the web browser is closed. * `logged_in` / `logged_out`: for forms which might be displayed without a SIP, but maybe on a protected or even @@ -439,7 +449,7 @@ Form.parameter +-----------------------------+--------+----------------------------------------------------------------------------------------------------------+ | typeAheadLdapSearch | string | Regular LDAP search expression. E.g.: `(|(cn=*?*)(mail=*?*))` | +-----------------------------+--------+----------------------------------------------------------------------------------------------------------+ -| typeAheadLdapValuePrintf | string | Value formatting of LDAP result, per entry. E.g.: `'%s / %s / %s', mail, roomnumber, telephonenumber` | +| typeAheadLdapValuePrintf | string | Value formatting of LDAP result, per entry. E.g.: `'%s / %s / %s', mail, room number, telephone number` | +-----------------------------+--------+----------------------------------------------------------------------------------------------------------+ | typeAheadLdapIdPrintf | string | Key formatting of LDAP result, per entry. E.g.: `'%s', mail` | +-----------------------------+--------+----------------------------------------------------------------------------------------------------------+ @@ -531,7 +541,7 @@ submitButtonText """""""""""""""" If specified and non empty, display a regular submit button at the bottom of the page with the given text. -This gives the form a ordinary HTML-form look'n' feel. With this option, the standard buttons on the top right border +This gives the form a ordinary HTML-form look 'n' feel. With this option, the standard buttons on the top right border should be hidden to not confuse the user. * Optional. @@ -652,9 +662,9 @@ Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=readonly|s|t:View| * The form is called with SIP parameter ``formModeGlobal=readonly`` or ``form.parameter.mode=readonly``. * The user can't change any data. -*Readonly systemwide* +*Readonly system wide* -Code (somewhere): ``SELECT 'requiredoff' AS '_=formModeGlobal'`` +Code (somewhere): ``SELECT 'requiredOff' AS '_=formModeGlobal'`` Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1|s|t:View|s|b' AS _link`` @@ -664,7 +674,7 @@ Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1|s|t:View|s|b' AS _link`` *Draft Mode 1* -Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=readquiredOff|s|t:View|s|b' AS _link`` +Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=requiredOff|s|t:View|s|b' AS _link`` * A form has one or more FormElement with 'fe.type=required'. * Opening the form with `formModeGlobal=requiredOff` will allow the user to save the form, even if not all @@ -674,7 +684,7 @@ Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=readquiredOff|s|t: *Draft Mode 2* -Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=readquiredOff|s|t:View|s|b' AS _link`` +Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=requiredOff|s|t:View|s|b' AS _link`` * A form has one or more FormElement with 'fe.type=required'. * Calling the form with `formModeGlobal=requiredOff` will allow the user to save the form, even if not all @@ -720,6 +730,16 @@ Type: fieldset * *name*: technical name, used as HTML identifier. * *label*: Shown title of the fieldset. + * *mode*: + + * `show`: all child elements will be shown. + * `required`: all child elements are also set to 'required'. + * `readonly`: technically it's like HTML/CSS `disabled`. + * `hidden`: + + * The fieldset is invisible. + * The `FormElements` within the fieldset still exist, but are not reachable for the user via UI. + * *parameter*: * *fieldsetClass*: Overwrite default from `Form.parameter.fieldsetClass` @@ -738,7 +758,7 @@ Type: pill (tab) * `show`: all child elements will be shown. * `required`: same as 'show'. This mode has no other meaning than 'show'. - * `readonly`: technical it's like HTML/CSS `disabled`. + * `readonly`: technically it's like HTML/CSS `disabled`. * The pill title is shown, but not clickable. * The `FormElements` on the pill still exist, but are not reachable for the user via UI. @@ -882,7 +902,7 @@ Fields: | | 'beforeInsert', 'beforeUpdate', 'beforeDelete', 'afterLoad', 'afterSave', 'afterInsert', 'afterUpdate', 'afterDelete', | | | 'sendMail') | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ -|Encode | 'none', 'specialchar' | With 'specialchar' (default) the chars <>"'& will be encoded to their htmlentity. _`field-encode` | +|Encode | 'none', 'specialchar' | With 'specialchar' (default) the chars <>"'& will be encoded to their html entity. _`field-encode` | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ |Check Type | enum('auto', 'alnumx', | See: :ref:`sanitize-class` | | | 'digit', 'numerical', | | @@ -919,7 +939,7 @@ Fields: +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ |value | text | Default value: See :ref:`field-value` | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ -|sql1 | text | SQL query. See individual `FormEelement`. _`sql1` | +|sql1 | text | SQL query. See individual `FormElement`. _`sql1` | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ |Parameter | text | Might contain misc parameter. See :ref:`fe-parameter-attributes` | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ @@ -1069,8 +1089,9 @@ FormElement.parameter | retypeNote, | | +---------------------------------+----------------------------------------------------------------------------------------------------------+ | characterCountWrap, | See :ref:`input-text` | -| hideZero, | | -| emptyMeansNull, | | +| hideZero | | ++---------------------------------+----------------------------------------------------------------------------------------------------------+ +| emptyMeansNull | Applies to all native FormElement types (input, checkbox, radio, select, ...). See :ref:`input-text` | +---------------------------------+----------------------------------------------------------------------------------------------------------+ | showSeconds | 0|1 - Shows the seconds on form load. Default: 0 | +---------------------------------+----------------------------------------------------------------------------------------------------------+ @@ -1106,13 +1127,15 @@ FormElement.parameter +---------------------------------+ | | expectRecords | | +---------------------------------+ | -| messageFail | | +| alert | | ++---------------------------------+ | +| qfqLog | | +---------------------------------+----------------------------------------------------------------------------------------------------------+ | dataReference | Optional. See :ref:`applicationTest` | +---------------------------------+----------------------------------------------------------------------------------------------------------+ | requiredPosition | See :ref:`requiredPosition`. | +---------------------------------+----------------------------------------------------------------------------------------------------------+ -| indicateRequired | By default, indicate 'required' by an asterix. indicateRequired=0 will hide the asterix. Default: 1 | +| indicateRequired | By default, indicate 'required' by an asterisk. indicateRequired=0 will hide the asterisk. Default: 1 | +---------------------------------+----------------------------------------------------------------------------------------------------------+ | minWidth | See :ref:`checkboxRadioMinWidth`. | +---------------------------------+----------------------------------------------------------------------------------------------------------+ @@ -1164,8 +1187,8 @@ extraButtonLock extraButtonPassword ;;;;;;;;;;;;;;;;;;; -* The user has to click on the eye (unhide) to see the value. -* After Form load, the data is hidden by asteriks. +* The user has to click on the eye (un-hide) to see the value. +* After Form load, the data is hidden by asterisk. * Shows an 'eye' on the right side of an input element of type `text`, `date`, `time` or `datetime`. * There is no value needed for this parameter. @@ -1213,8 +1236,8 @@ might be defined per Form or per FormElement. Required Position ^^^^^^^^^^^^^^^^^ -By default, input elements with `Mode=required` will be displayed with a 'red asterix' right beside the label. The position -of the 'red asterix' can be choosen via the `parameter` field:: +By default, input elements with `Mode=required` will be displayed with a 'red asterisk' right beside the label. The position +of the 'red asterisk' can be chosen via the `parameter` field:: requiredPosition = label-left|label-right|input-left|input-right|note-left|note-right @@ -1276,8 +1299,8 @@ Checkboxes can be rendered in mode: * *emptyHide*: Existence of this item hides an entry with an empty string. This is useful for e.g. Enums, which have an empty entry, but the empty value should not be selectable. - * *emptyItemAtStart*: Existence of this item inserts an empty entry at the beginning of the selectlist. - * *emptyItemAtEnd*: Existence of this item inserts an empty entry at the end of the selectlist. + * *emptyItemAtStart*: Existence of this item inserts an empty entry at the beginning of the select list. + * *emptyItemAtEnd*: Existence of this item inserts an empty entry at the end of the select list. * *buttonClass*: Instead of the plain HTML checkbox fields, Bootstrap `buttons <http://getbootstrap.com/docs/3.4/javascript/#buttons-checkbox-radio>`_. are rendered as `checkbox` elements. Use one of the following `classes <http://getbootstrap.com/docs/3.4/css/#buttons-options>`_: @@ -1594,7 +1617,7 @@ Type: editor * *FormElement.checktype* * *all*: The only useful setting for Editor. HTML tags might contain ``% ' " < >`` and so on. This is **dangerous** - due of potential inserted malicous code! But there is no other option, cause the HTML tags are required. + due of potential inserted malicious code! But there is no other option, cause the HTML tags are required. * All configuration and plugins will be configured via the 'parameter' field. Just prepend the word 'editor-' in front of each TinyMCE keyword. Check possible options under: @@ -1661,7 +1684,7 @@ can be shown in edit (and might be modified) or in readonly mode. Two modes are available: grafic - A simple grafic editor to paint on top of the image (best by a tablet with pen or grafic tablet). The uploaded image + A simple graphic editor to paint on top of the image (best by a tablet with pen or graphic tablet). The uploaded image is shown in the background. All drawings are saved as a JSON fabric.js data string. Supported file types: **png, svg**. PDF files can be easily divided into per page SVG files during upload - see :ref:`split-pdf-upload` @@ -1679,7 +1702,7 @@ Grafic """""" An image, specified by ``FormElement.parameter.imageSource={{pathFileName}}``, will be displayed in the background. On -form load, both, the image and an optional already given grafical annotations, will be displayed. The image is SIP +form load, both, the image and an optional already given graphical annotations, will be displayed. The image is SIP protected and will be loaded on demand. **Form.parameter** @@ -1782,7 +1805,7 @@ Type: radio * *vertical* or *horizontal* alignment: * `<value>`: '', 0, 1 - The radios will be aligned *vertical*. - * `<value>`: >1 - The readios will be aligned *horizontal*, with a linebreak every 'value' elements. + * `<value>`: >1 - The radios will be aligned *horizontal*, with a linebreak every 'value' elements. * *FormElement.parameter*: @@ -1845,8 +1868,8 @@ Type: select * *FormElement.parameter*: - * *emptyItemAtStart*: Existence of this item inserts an empty entry at the beginning of the selectlist. - * *emptyItemAtEnd*: Existence of this item inserts an empty entry at the end of the selectlist. + * *emptyItemAtStart*: Existence of this item inserts an empty entry at the beginning of the select list. + * *emptyItemAtEnd*: Existence of this item inserts an empty entry at the end of the select list. * *emptyHide*: Existence of this item hides the empty entry. This is useful for e.g. Enums, which have an empty entry and the empty value should not be an option to be selected. * *datalist*: Similar to 'typeAhead'. Enables the user to select a predefined option (sql1, itemList) or supply any @@ -1940,7 +1963,7 @@ will be rendered inside the form as a HTML table. * Exceptions of the default behaviour have to be defined on the target form in the corresponding *FormElement* in the field *value* by changing the default Store priority definition. E.g. `{{<columnName>:RS0}}` - For existing records, the store `R` will provide a value. For new records, store `R` is empty and store S will be searched for a value: - the value defined in `detail` will be choosen. At last the store '0' is defined as a fallback. + the value defined in `detail` will be chosen. At last the store '0' is defined as a fallback. * *source table column name*: E.g. A person form is opened with person.id=5 (r=5). The definition `detail=id:personId` and `form=address` maps person.id to address.personId. On the target record, the column personId becomes '5'. * *Constant '&'*: Indicate a 'constant' value. E.g. `&12:xId` or `{{...}}` (all possibilities, incl. further SELECT @@ -1986,6 +2009,10 @@ will be rendered inside the form as a HTML table. subrecordAppendExtraDeleteForm = address2 + * *subrecordEmptyText*: Optional. Define the text displayed when subrecord has no records:: + + subrecordEmptyText = my custom text + **Subrecord DragAndDrop** Subrecords inherently support drag-and-drop, see also :ref:`drag_and_drop`. @@ -1997,7 +2024,7 @@ The following parameters can be used in the `parameter` field to customize/activ * *orderColumn*: The dedicated order column in the specified dndTable (needs to match a column in the table definition). Default is `ord`. - * To switch off Drag'n' Drop, specify a non existing columnname. E.g.: `orderColumn=off` + * To switch off Drag 'n' Drop, specify a non existing columnname. E.g.: `orderColumn=off` If `dndTable` is a table with a column `orderColumn`, QFQ automatically applies drag-and-drop logic to the rendered subrecord. It does so by using the subrecord field *sql1*. The `sql1` query should @@ -2074,12 +2101,17 @@ and will be processed after saving the primary record and before any action Form See also :ref:`download Button<downloadButton>` to offer a download of an uploaded file. +Per default the new upload version 2 with drag & drop feature is used. Use following FormElement.parameter to switch to old upload version.:: + + uploadType=v1 + + FormElement.parameter """"""""""""""""""""" * *fileButtonText*: Overwrite default ‘Choose File’ * *capture* = `camera` - On a smartphone, after pressing the 'open file' button, the camera will be opened and a - choosen picture will be uploaded. Automatically set/overwrite `accept=image/*`. + chosen picture will be uploaded. Automatically set/overwrite `accept=image/*`. * *accept* = `<mime type>,image/*,video/*,audio/*,.doc,.docx,.pdf` @@ -2089,7 +2121,7 @@ FormElement.parameter * One or more media types might be specified, separated by ','. * Different browser respect the given definitions in different ways. Typically the 'file choose' dialog offer: - * the specified mime type (some browers only show 'custom', if more than one mime type is given), + * the specified mime type (some browsers only show 'custom', if more than one mime type is given), * the option 'All files' (the user is always free to **try** to upload other file types) - but the server won't accept them, * the 'file choose' dialog only offers files of the selected (in the dialog) type. @@ -2105,7 +2137,7 @@ FormElement.parameter * *fileTrashText* = `<string>` - Default: ''. Will be shown right beside the trash glyph-icon. * *fileDestination* = `<pathFileName>` - Destination where to copy the file. A good practice is to specify a relative `fileDestination` - - such an installation (filesystem and database) are moveable. + such an installation (filesystem and database) are movable. * If the original filename should be part of `fileDestination`, the variable *{{filename}}* (see :ref:`STORE_VARS`) can be used. Example :: @@ -2114,7 +2146,7 @@ FormElement.parameter * Several more variants of the filename and also mimetype and filesize are available. See :ref:`STORE_VARS`. - * The original filename will be sanitized: only '<alnum>', '.' and '_' characters are allowed. German 'umlaut' will + * The original filename will be sanitized: only '<alnumx>', '.' and '_' characters are allowed. German 'Umlaut' will be replaced by 'ae', 'ue', 'oe'. All non valid characters will be replaced by '_'. * If a file already exist under `fileDestination`, an error message is shown and 'save' is aborted. The user has no @@ -2168,8 +2200,8 @@ FormElement.parameter numeric mode is allowed. Will be applied to all new created directories. * *autoOrient:* images might contain EXIF data (e.g. captured via mobile phones) incl. an orientation tag like TopLeft, - BottomRight and so on. Web-Browser and other grafic programs often understand and respect those information and rotate - such images automatically. If not, the image might be displayed in an unwanted oritentation. + BottomRight and so on. Web-Browser and other graphic programs often understand and respect those information and rotate + such images automatically. If not, the image might be displayed in an unwanted orientation. With active option 'autoOrient', QFQ tries to normalize such images via 'convert' (part of ImageMagick). Especially if images are processed by the QFQ internal 'Fabric'-JS it's recommended to normalize images first. The normalization process does not solve all orientation problems. @@ -2201,7 +2233,7 @@ FormElement.parameter fileUnzip sqlValidate ={{! SELECT '' FROM (SELECT '') AS fake WHERE '{{mimeType:V}}' LIKE 'application/pdf%' }} expectRecords=1 - messageFail=Unexpected filetype + alert=Unexpected filetype # Set new sqlAfter={{INSERT INTO Upload (pathFileName) VALUES '{{filename:V}}' }} @@ -2266,7 +2298,7 @@ On form load, the column value will be displayed as the whole value (pathFileNam Deleting an uploaded file in the form (by clicking on the trash near beside) will delete the file on the filesystem as well. The column will be updated to an empty string. -This happens automatically without any further definiton in the 'upload'-FormElement. +This happens automatically without any further definition in the 'upload'-FormElement. Multiple 'upload'-FormElements per form are possible. Each of it needs an own table column. @@ -2300,15 +2332,15 @@ with 'my', e.g. 'myUpload1'. * *sqlBefore* = `{{<query>}}` - fired during a form save, before the following queries are fired. - * *sqlInsert* = `{{<query>}}` - fired if `slaveId=0` and an upload exist (user has choosen a file):: + * *sqlInsert* = `{{<query>}}` - fired if `slaveId=0` and an upload exist (user has chosen a file):: sqlInsert={{INSERT INTO Note (pId, type, pathFileName) VALUE ({{id:R0}}, 'image', '{{fileDestination}}') }} - * *sqlUpdate* = `{{<query>}}` - fired if `slaveId>0` and an upload exist (user has choosen a file). E.g.:: + * *sqlUpdate* = `{{<query>}}` - fired if `slaveId>0` and an upload exist (user has chosen a file). E.g.:: sqlUpdate={{UPDATE Note SET pathFileName = '{{fileDestination}}' WHERE id={{slaveId}} LIMIT 1}} - * *sqlDelete* = `{{<query>}}` - fired if `slaveId>0` and no upload exist (user has not choosen a file). E.g.:: + * *sqlDelete* = `{{<query>}}` - fired if `slaveId>0` and no upload exist (user has not chosen a file). E.g.:: sqlDelete={{DELETE FROM Note WHERE id={{slaveId:V}} LIMIT 1}} @@ -2339,7 +2371,7 @@ file type. * [jpeg] - default: `-density 150 -quality 90` * *fileDestinationSplit* = `<pathFileName (pattern)>` - Target directory and filename pattern for the created & - split'ed files. Default <fileDestination>.split/split.<nr>.<fileSplit>. + split files. Default <fileDestination>.split/split.<nr>.<fileSplit>. If explicit given, respect that SVG needs a printf style for <nr>, whereas JPEG is numbered automatically. E.g. :: [svg] fileDestinationSplit = fileadmin/protected/{{id:R}}.{{filenameBase:V}}.%02d.svg @@ -2359,14 +2391,14 @@ Table 'Split': +==============+============================================================================================+ | id | Uniq auto increment index | +--------------+--------------------------------------------------------------------------------------------+ -| tableName | Name of the table, where the reference to the original file (multipage PDF file) is saved. | +| tableName | Name of the table, where the reference to the original file (multi page PDF file) is saved. | +--------------+--------------------------------------------------------------------------------------------+ | xId | Primary id of the reference record. | +--------------+--------------------------------------------------------------------------------------------+ | pathFileName | Path/filename reference to one of the created files | +--------------+--------------------------------------------------------------------------------------------+ -One usecase why to split an upload: annotate individual pages by using the `FormElement`.type=`annotate`. +One use case why to split an upload: annotate individual pages by using the `FormElement`.type=`annotate`. .. _class-action: @@ -2401,7 +2433,7 @@ FormElement.parameter: sqlValidate * OK: the `expectRecords` number of records has been selected. Continue processing the next *FormElement*. * Fail: the `expectRecords` number of records has not been selected (less or more): Display the error message - `messageFail` and abort the whole (!) current form load or save. + `alert` and abort the whole (!) current form load or save. *FormElement.parameter*: @@ -2416,7 +2448,30 @@ FormElement.parameter: sqlValidate * *expectRecords* = `0` or *expectRecords* = `0,1` or *expectRecords* = `{{SELECT COUNT(id) FROM Person}}` * Separate multiple valid record numbers by ','. If at least one of those matches, the check will pass successfully. -* *messageFail* = `<string>` - Message to show. E.g.: *messageFail* = `There is already a person called {{firstname:F:all}} {{name:F:all}}` +* *qfqLog* = `<value>` - determines if the error should be logged. + + * *qfqLog* and *qfqLog* = `1` (default) - error will be logged in both cases. + * *qfqLog* = `0` - no error will be logged. + +* *alert* = `<alert text>[:<level>[:<ok button text>[:<force button text>[:<timeout>[:<flag modal>]]]]]` + + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Parameter | Description | + +======================+==========================================================================================================================+ + | Text | The text shown by the alert. HTML is allowed to format the text. Any ':' needs to be escaped. | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Level | info (default), success, warning, danger/error | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Ok button text | Default: 'Ok'. Closes the alert. | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Force button text | Forces a save of the form in case *expectRecords* fails. | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Timeout in seconds | Default: 0, no timeout. > 0, after the specified time in seconds, the alert will disappear (no forced save). | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Flag modal | Default: 1, alert behaves modal. 0, alert does not behave modal and appears on the side. | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + +* *messageFail* = `<string>` - (Deprecated) Message to show. E.g.: *messageFail* = `There is already a person called {{firstname:F:all}} {{name:F:all}}` .. _slave-id: @@ -2713,7 +2768,7 @@ record (defined by `multiSql`). +------------------+----------------------------------+------------------------------------------------+ | Name | | | +==================+==================================+================================================+ -| multiSql | {{!SELECT id, name FROM Person}} | Query to select MulitForm records | +| multiSql | {{!SELECT id, name FROM Person}} | Query to select MultiForm records | +------------------+----------------------------------+------------------------------------------------+ | multiMgsNoRecord | Default: No data | Message shown if `multiSql` selects no records | +------------------+----------------------------------+------------------------------------------------+ @@ -2744,7 +2799,7 @@ The checkbox in the header selects all/none rows at once. * *processRow* = `<string>` - the value displayed in table header next to the checkbox. -* `Form.mulitSql`: If there is a column `_processRow`, value of 0/1 per row will control unchecked/checked during form load. +* `Form.multiSql`: If there is a column `_processRow`, value of 0/1 per row will control unchecked/checked during form load. Implicit Multi Form mode ^^^^^^^^^^^^^^^^^^^^^^^^ @@ -2883,7 +2938,7 @@ Dynamic Update -------------- The 'Dynamic Update' feature makes a form more interactive. If a user changes a *FormElement* who is tagged with -'dynamicUpdate', *all* elements who are tagged with 'dynamicUpdate', will be recalculated and rerendered. +'dynamicUpdate', *all* elements who are tagged with 'dynamicUpdate', will be recalculated and re-rendered. The following fields will be recalculated during 'Dynamic Update' @@ -3061,7 +3116,7 @@ form with the following parameter * FormElement 1: Record id of the source record. * Name: `idSrc` - * Lable: `Source Form` + * Label: `Source Form` * Class: `native` * Type: `select` * sql1: `{{! SELECT id, title FROM Basket }}` @@ -3081,7 +3136,7 @@ form with the following parameter * `sqlValidate={{!SELECT f.id FROM Form AS f WHERE f.name LIKE '{{myName:FE:alnumx}}' LIMIT 1}}` * `expectRecords = 0` - * `messageFail = There is already a form with this name` + * `alert = There is already a form with this name` * `sqlAfter={{DELETE FROM Clipboard WHERE cookie='{{cookieQfq:C0:alnumx}}' }}` * FormElement 4: Update the clipboard source reference, with current {{cookieQfq:C}} identifier. @@ -3150,34 +3205,10 @@ To automatically delete slave records, use a form and create `beforeDelete` Form * class: action * type: beforeDelete - * parameter: sqlAfter={{DELETE FROM <slaveTable> WHERE <slaveTable>.<masteId>={{id:R}} }} + * parameter: sqlAfter={{DELETE FROM <slaveTable> WHERE <slaveTable>.<masterId>={{id:R}} }} You might also check the form 'form' how the slave records 'FormElement' will be deleted. -.. _locking-record: - -Locking Record / Form ---------------------- - -Support for record locking is given with mode: - -* *exclusive*: user can't force a write. - - * Including a timeout (default 15 mins recordLockTimeoutSeconds in :ref:`configuration`) for maximum lock time. - -* *advisory*: user is only warned, but allowed to overwrite. -* *none*: no bookkeeping about locks. - -For 'new' records (r=0) there is no locking at all. - -The record locking protection is based on the `tablename` and the `record id`. Different `Forms`, with the same primary table, -will be protected by record locking. On the other side, action-`FormElements` updating non primary table records are not -protected by 'record locking': the QFQ record locking is *NOT 100%*. - -The 'record locking' mode will be specified per `Form`. If there are multiple Forms with different modes, and there is -already a lock for a `tablename` / `record id` pair, the most restrictive will be applied. - - Best practice ------------- @@ -3217,7 +3248,7 @@ Example: :: FormElement.name = technicalContact Form.parameter.fillStoreVar = {{! SELECT CONCAT(p.firstName, ' ', p.name) AS technicalContact FROM Person AS p WHERE p.account='{{feUser:T}}' }} -What we use here is the default STORE prio FSRVD. If the form loads with r=0, 'F', 'S' and 'R' are empty. 'V' is filled. +What we use here is the default STORE prioritized FSRVD. If the form loads with r=0, 'F', 'S' and 'R' are empty. 'V' is filled. If r>0, than 'F' and 'S' are empty and 'R' is filled. Method 2 @@ -3785,7 +3816,7 @@ The JSON form editor allows developers to view/edit/copy/paste forms in the json * All fields of the Form and the FormElements Table are encoded into one big JSON object. Each formElement is represented as an object contained in the top-level array called `FormElement_ff`. * Form and FormElement **ids are not encoded into the json string**. Therefore the json may be freely copied and pasted i.e. reused without fear of overwriting the original form. - * **Container** : Container FormElements are referenced via their name instead of their id by other FormElements. The additional key `containerName_ff` is added to the JSON of a FormElemnt to reference a container. + * **Container** : Container FormElements are referenced via their name instead of their id by other FormElements. The additional key `containerName_ff` is added to the JSON of a FormElement to reference a container. * **Form Backups** : If a form is edited using the JSON form editor then a backup of the previous version is saved in the directory `form/.backup` inside the qfq project directory (:ref:`qfq-project-path-php`). diff --git a/Documentation/GeneralTips.rst b/Documentation/GeneralTips.rst index 50d801696c2df6d1db517e4b41b30332219507a4..57643e80ef033f0a54d837b68c7d23a44a2c1194 100644 --- a/Documentation/GeneralTips.rst +++ b/Documentation/GeneralTips.rst @@ -292,3 +292,30 @@ The FE User record (table: fe_users) * Has to be assigned to a T3 page ``fe_users.pid``. * The T3 page has to be configured as ``record store`` on the T3 Plugin login box. * Access time has to be zero or a currently valid period. + +MariaDB +------- + +Row size too large +^^^^^^^^^^^^^^^^^^ + +* Details: https://mariadb.com/kb/en/troubleshooting-row-size-too-large-errors-with-innodb/ +* Typically too many columns per table on an InnoDB Table. +* First try is to check (and to change) ROW_FORMAT: :: + + ALTER TABLE tab ROW_FORMAT=DYNAMIC; + +* Bad workaround for creating a wide table: :: + + SET SESSION innodb_strict_mode=OFF; + CREATE TABLE ... + +* Best is to change as much columns as neccessary from ``varchar()`` to ``tinytext`` / ``text`` + +SQL Dump with one record per line +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Sometimes it's helpful to have SQL dumps with each record on a single line:: + + msyqldump --skip-opt <DB-Name> + diff --git a/Documentation/Installation.rst b/Documentation/Installation.rst index 7d68b5279757aac1f3691249184ff1e0706a7a8c..b8cf472712374a8ca8d89331c91a4ed7ee7eec84 100644 --- a/Documentation/Installation.rst +++ b/Documentation/Installation.rst @@ -215,8 +215,9 @@ Usage: :ref:`column-thumbnail`. If there are no thumbnails and you see ``convert-im6.q16: no images defined`` in syslog: Check file ``/etc/ImageMagick-6/policy.xml`` and update: - old: <policy domain="coder" rights="none" pattern="PDF" /> - New: <policy domain="coder" rights="read | write" pattern="PDF" /> + old: ``<policy domain="coder" rights="none" pattern="PDF" />`` + + New: ``<policy domain="coder" rights="read | write" pattern="PDF" />`` Setup ----- @@ -561,6 +562,7 @@ Extension Manager: QFQ Configuration +-----------------------------------+-------------------------------------------------------+----------------------------------------------------------------------------+ | formSubmitLogMode | all | | *all*: every form submission will be logged. | | | | | *none*: no logging. | +| | | | *modify*: prevent logging tables: FormSubmitLog and Dirty. | | | | | See :ref:`form-submit-log-page` for example QFQ code to display the log. | +-----------------------------------+-------------------------------------------------------+----------------------------------------------------------------------------+ | redirectAllMailTo | john@doe.com | If set, redirect all QFQ generated mails (Form, Report) to the specified. | diff --git a/Documentation/License.rst b/Documentation/License.rst index 42eaaa93d76bc856339885fece2a0b5954d1d979..b192f4c194666c91eaae1637d69f4c871429cc30 100644 --- a/Documentation/License.rst +++ b/Documentation/License.rst @@ -61,3 +61,4 @@ Software distributed together with QFQ * Datetimepicker - https://getdatepicker.com/ * HTMLPurifier - https://github.com/ezyang/htmlpurifier * Font Password-Dots - https://fontstruct.com/fontstructions/show/1106896 The FontStruction “Password Dots†by “JimProuty†is licensed under a Creative Commons Attribution license (http://creativecommons.org/licenses/by/3.0/). +* Filepond - https://github.com/pqina/filepond diff --git a/Documentation/Release.rst b/Documentation/Release.rst index ecec19a501b581e0857e876d70e883429575bcd2..0203f61e86520496b7c6ce1f6d5a35f4d3d468ba 100644 --- a/Documentation/Release.rst +++ b/Documentation/Release.rst @@ -12,6 +12,7 @@ .. --------------------------------------------used to the update the records specified ------ .. Best Practice T3 reST: https://docs.typo3.org/m/typo3/docs-how-to-document/master/en-us/WritingReST/CheatSheet.html .. Reference: https://docs.typo3.org/m/typo3/docs-how-to-document/master/en-us/WritingReST/Index.html +.. .. Italic *italic* .. Bold **bold** .. Code ``text`` @@ -19,7 +20,6 @@ .. Internal Link: :ref:`downloadButton` (default url text) or :ref:`download Button<downloadButton>` (explicit url text) .. Add Images: .. image:: ./Images/a4.jpg .. -.. .. Admonitions .. .. note:: .. important:: .. tip:: .. warning:: .. Color: (blue) (orange) (green) (red) @@ -52,6 +52,269 @@ Features Bug Fixes ^^^^^^^^^ +Version 23.10.1 +--------------- + +Date: 22.10.2023 + +Notes +^^^^^ + +Features +^^^^^^^^ + +* #15682 / Subrecord hide please save record first if there is no table title. +* #15098 / Implemented qfqFunction in QFQ variable for usage in forms. +* Doc: Replace many places single back tick by double back tick. Add hint for 'Row size too large'. Add hint how to use + mysqldump to export one row per record. +* index.rst: Added Enis & Jan as Developer. + +Bug Fixes +^^^^^^^^^ + +* #17003 / inline edit - dark mode has wrong css path. +* #17075 / Fix broken '... AS _restClient'. +* #17091 / upload_Incorrect_integer_value_fileSize. +* #17148 / RTD: Fix broken readthedocs rendering. + +Version 23.10.0 +--------------- + +Date: 05.10.2023 + +Features +^^^^^^^^ + +* #16350 / QFQ Table 'FormSubmiLog': update recordid after insert. +* #16350 / sql.log: reference to FormSubmitLog entry. All SQL statements generated by one HTTP Post (Form Submit) can + be identified. +* #16350 / Do not log Dirty. +* #16350 / If the T3 instance is behind a proxy, log HTTP_X_REAL_IP instead of REMOTE_ADDR in logfiles. +* #16584 / FormEditor Report: Default without statistics. +* #16589 / Implemented language configuration in backend for tt-content type qfq. +* #16798 / Report inline edit v2. Improved search inside inline edit report. Whole content will be searched. Added + ability to switch the editor to dark mode. +* Doc: Refactor description of {{random:V}}. +* Doc: Add config option 'protectedFolderCheck'. + +Bug Fixes +^^^^^^^^^ + +* #16573 / Fixed wrong built date and datetime string if default value was given. +* #16574 / Added multiple siteConfigurations compatibility for typo3 v10 and 11. +* #16616 / Fixed typeahead api query response problem if typeahead sql is not used. +* #16664 / Fix multi db user error. +* #16975 / Fix problem if a 'Form Submit' contains more than 64kB data. This can happen easily for 'fabric' elements. + +Version 23.6.4 +-------------- + +Date: 26.06.2023 + +Bug Fixes +^^^^^^^^^ + +* #16485 / TypeAhead: strpos() string, array given error. +* #16488 / Missing default values break saving records. New custom FE.parameter.defaultValue. +* #16491 / FE Typ Upload - JS failure: document.querySelector() is null. + +Version 23.6.3 +-------------- + +Date: 22.06.2023 + +Bug Fixes +^^^^^^^^^ + +* #16478 / Rest API: Fixed stream_get_contents failure in PHP8.1 Generic Error. + +Version 23.6.2 +-------------- + +Date: 21.06.2023 + +Bug Fixes +^^^^^^^^^ + +* #16475 / Spontaneous spaces in HTML emails. +* #16476 / SQL columns Text/Blog with default empty string becomes "''" + + +Version 23.6.1 +-------------- + +Date: 16.06.2023 + +Notes +^^^^^ + +* QFQ is Typo3 V11 compatible + +Bug Fixes +^^^^^^^^^ + +* #16372 / Upload Element 'Undefined index htmlDownloadButton' und 'unknown mode ID' +* #16381 / Form title dynamic update broken. +* #16392 / Reevaluate sanitize class for each store. +* Fix db column enum dropdown 'data truncated' +* DB Update 'alter index' pre check if exists +* FE: Upload - fix undefined index. + +Version 23.6.0 +-------------- + +Date: 09.06.2023 + +Features +^^^^^^^^ + +* Typo3 V11 compatible +* #9579 / Multiform with Process Row. +* #15770 / httpOrigin: Parameter missing in QFQ Config. +* #16285 / PHP V8 Migration. +* #16364 / T3 >=V10 pageSlug and ForwardPage. +* Add .phpunit.result.cache to .gitignore. +* Add bullet-orange.gif. +* Add index createdFeUserFormId to table FormSubmitLog. +* Doc: Add link to icons. Update use-case self-registration and add info in case of namesake. Unify word 'spam' to 'junk'. + Add bootstrap links. +* Remove Form/FormElement parameter 'feGroup'. +* QFQ Typo3 Updatewizard: Sort output by page and tt-content ordering. Add page uid an title to be reported. + Inline Edit: add tt_content uid and header as tooltip. +* Update package.json. +* Update to PHPUnit version 9. + +Bug Fixes +^^^^^^^^^ + +* #12468 / Form: Dynamic Update Form.title after save. +* #14636 / 'Clear Me'-cross lighter and 2 px up. +* #15445 / Doc: JS Output Widged added. +* #15497 / Delete link: a) broken for 'table', b) broken with 'r:3'. +* #15527 / Form/subrecord: class 'qfq-table-50' no impact. +* #15654 / Form FE required 'Undefined index Error'. +* #15659 / Multi Form: Datetime Picker not aligned. +* #15691 / T3 V10: Export PDF (PDF Generator) invalid link causes error. +* #15726 / Formelement upload stays required when changed from required to hidden. +* #15729 / Form: text readonly input should show newline as '<br>'. +* #15747 / Typeahead does not work with special character. Added croatian special chars (Ć,ć,ÄŒ,Ä,Ä,Ä‘,Å ,Å¡,Ž,ž) to alnumx + whitelist. +* #15763 / Search/Refactor: a) search for ID, b) message if nothing is found - New highlighting for disabled content. + Added output for not found in search. +* #15773 / TypeAhead missing check type warning. +* #15813 / Pressing 'Enter' in Form asks: Do you really want to delete the record? +* #15875 / Unecessary qfq-config save. +* #15905 / FE type time undefined index. +* #15913 / Refactor: Footer displayed at the wrong place. +* #15921 / Frontend edit Report Codemirror resizeable. +* #16004 / Template Group Fields broken. +* #16046 / Datepicker not initialized in Template Group. +* #16051 / Dropdown menu: Download file broken with ZIP File. +* #16055 / Dropdown option with render mode 3 format problem. +* #16064 / Dynamic Update Checkbox and Form Store in parameter. +* #16073 / saveButtonActive is disabled after first save. +* #16201 / Character Count Class Quotation Mark. +* #16204 / TypeAhead Prefetch Expects String Array Given. +* #16228 / extraButtonPassword unhide broken. +* #16264 / Multi DB Broken since QFQ V23.2.0. +* #16273 / TinyMCE image upload path incorrect for T3 v10 and upwards. + +Version 23.3.1 +-------------- + +Date: 31.03.2023 + +Bug Fixes +^^^^^^^^^ + +* #15920 / QFQ variable evaluation broken: wrong variable name + +Version 23.3.0 +-------------- + +Date: 30.03.2023 + +Features +^^^^^^^^ + +* #15491 / Search refactor redesign and T3 V10 compatiblity: underscore character, counter fixed, page alias and slug handling. +* #15529 / Form/subrecord: Design Titel / Box. +* #15570 / Changed DB handling in class FormAction. Fixed multi-db problem with this. Included change for check of + existing form editor report. +* #15579 / Ability for searching all possible characters, includes not replaced QFQ variables. +* #15627 / Added character count and maxLength feature for TinyMCE. +* Add various icons to documentation. +* Doc Form.rst: reference 'orderColumn'. +* Doc Report.rst: fix typo, add icons, improved example for tablesorter. +* Add indexes for table FormSubmitLog. +* FormEditor: Show Tablename in pill 'table definition'. +* FormEditor: FE subrecord > show container id below name. + +Bug Fixes +^^^^^^^^^ + +* #14754 / Using double quotes in tableview config caused sql error. +* #15474 / Form save button activated after clicking in TinyMCE editor. Should only activate after change. +* #15483 / Protected folder check fixed. Changed default of wget, preventing errors. Changed handling from protected + folder check, new once a day. +* #15521 / FormEditor assigns always container, even none is selected. Change handling of form variables from type select, + radio and checkbox. Expected 0 from client request instead of empty string. +* #15523 / Search/Refactor broken for Multi-DB. +* #15626 / Multi-DB: FormEditor save error. + +Version 23.10.1 +--------------- + +Date: 22.10.2023 + +Notes +^^^^^ + +Features +^^^^^^^^ + +* #15098 / Implemented qfqFunction in QFQ variable for usage in forms. +* Doc: Replace many places single back tick by double back tick. Add hint for 'Row size too large'. Added Enis & Jan + as Developer. Add hint use mysqldump with one row per record. + +Bug Fixes +^^^^^^^^^ + +* #17003 / inline edit - dark mode has wrong css path. +* #17075 / Fix broken '... AS _restClient'. +* #17091 / upload_Incorrect_integer_value_fileSize. +* #15795 / Upload: download button not shown after pressing save. +* RTD: Fix broken readthedocs rendering. + +Version 23.10.0 +--------------- + +Date: 05.10.2023 + +Features +^^^^^^^^ + +* #16350 / QFQ Table 'FormSubmiLog': update recordid after insert. +* #16350 / sql.log: reference to FormSubmitLog entry. All SQL statements generated by one HTTP Post (Form Submit) can + be identified. +* #16350 / Do not log Dirty. +* #16350 / If the T3 instance is behind a proxy, log HTTP_X_REAL_IP instead of REMOTE_ADDR in logfiles. +* #16584 / FormEditor Report: Default without statistics. +* #16589 / Implemented language configuration in backend for tt-content type qfq. +* #16798 / Report inline edit v2. Improved search inside inline edit report. Whole content will be searched. Added + ability to switch the editor to dark mode. +* Doc: Refactor description of {{random:V}}. +* Doc: Add config option 'protectedFolderCheck'. + +Bug Fixes +^^^^^^^^^ + +* #16573 / Fixed wrong built date and datetime string if default value was given. +* #16574 / Added multiple siteConfigurations compatibility for typo3 v10 and 11. +* #16616 / Fixed typeahead api query response problem if typeahead sql is not used. +* #16664 / Fix multi db user error. +* #16975 / Fix problem if a 'Form Submit' contains more than 64kB data. This can happen easily for 'fabric' elements. + Version 23.6.4 -------------- @@ -99,6 +362,7 @@ Notes Bug Fixes ^^^^^^^^^ +* #16372 / Upload Element 'Undefined index htmlDownloadButton' und 'unknown mode ID' * #16381 / Form title dynamic update broken. * #16392 / Reevaluate sanitize class for each store. * Fix db column enum dropdown 'data truncated' @@ -155,6 +419,7 @@ Bug Fixes * #16004 / Template Group Fields broken. * #16046 / Datepicker not initialized in Template Group. * #16051 / Dropdown menu: Download file broken with ZIP File. +* #16055 / Dropdown option with render mode 3 format problem. * #16064 / Dynamic Update Checkbox and Form Store in parameter. * #16073 / saveButtonActive is disabled after first save. * #16201 / Character Count Class Quotation Mark. diff --git a/Documentation/Report.rst b/Documentation/Report.rst index 16ba3219740ae200cc9d77df777b7a0774255bbe..520f1a4e0f46146954552fa11eaae2c9c01eb8f2 100644 --- a/Documentation/Report.rst +++ b/Documentation/Report.rst @@ -62,13 +62,14 @@ To display a report on any given TYPO3 page, create a content element of type 'Q A simple example ^^^^^^^^^^^^^^^^ -Assume that the database has a table person with columns firstName and lastName. To create a simple list of all persons, we can do the following:: +Assume that the database has a table person with columns firstName and lastName. To create a simple list of all persons, +we can do the following:: 10.sql = SELECT firstName, lastName FROM Person -The '10' indicates a *root level* of the report (see section :ref:`Structure<Structure>`). The expression '10.sql' defines an SQL query -for the specific level. When the query is executed, it will return a result having one single column name containing first and last name -separated by a space character. +The '10' indicates a *root level* of the report (see section :ref:`Structure<Structure>`). The expression '10.sql' +defines an SQL query for the specific level. When the query is executed, it will return a result having one single +column name containing first and last name separated by a space character. The HTML output, displayed on the page, resulting from only this definition, could look as follows:: @@ -215,7 +216,7 @@ Links The link syntax described in :ref:`column-link` is available inside Twig templates using the `qfqlink` filter:: - {{ "u:http://www.example.com" | qfqlink }} + {{ "u:http://www.example.com" | qfqlink }} will render a link to *http://www.example.com*. @@ -223,11 +224,11 @@ JSON Decode ^^^^^^^^^^^ A String can be JSON decoded in Twig the following way:: - {% set decoded = '["this is one", "this is two"]' | json_decode%} + {% set decoded = '["this is one", "this is two"]' | json_decode%} This can then be used as a normal object in Twig:: - {{ decoded[0] }} + {{ decoded[0] }} will render *this is one*. @@ -237,17 +238,17 @@ Available Store Variables QFQ also provides access to the following stores via the template context. - * record - * sip - * typo3 - * user - * system - * var + - record + - sip + - typo3 + - user + - system + - var All stores are accessed using their lower case name as attribute of the context variable `store`. The active Typo3 front-end user is therefore available as:: - {{ store.typo3.feUser }} + {{ store.typo3.feUser }} Example ^^^^^^^ @@ -286,7 +287,7 @@ Reserved names -------------- The following names have a special meaning in QFQ/Typo3. It is recommended to use such names only in the meaning of -QFQ/Typo3 and not to use them as free defineable user variables. +QFQ/Typo3 and not to use them as free definable user variables. ``id``, ``type``, ``L``, ``form``, ``r`` @@ -322,7 +323,8 @@ Structure A report can be divided into several levels. This can make report definitions more readable because it allows for splitting of otherwise excessively long SQL queries. For example, if your SQL query on the root level selects a number -of person records from your person table, you can use the SQL query on the second level to look up the city where each person lives. +of person records from your person table, you can use the SQL query on the second level to look up the city where each +person lives. See the example below:: @@ -345,12 +347,12 @@ Text across several lines ^^^^^^^^^^^^^^^^^^^^^^^^^ To get better human readable SQL queries, it's possible to split a line across several lines. Lines -with keywords are on their own (:ref:`QFQ Keywords (Bodytext)<qfq_keywords>` start a new line). If a line is not a 'keyword' line, it will -be appended to the last keyword line. 'Keyword' lines are detected on: +with keywords are on their own (:ref:`QFQ Keywords (Bodytext)<qfq_keywords>` start a new line). If a line is not a +'keyword' line, it will be appended to the last keyword line. 'Keyword' lines are detected on:: -* <level>.<keyword> = -* { -* <level>[.<sub level] { + <level>.<keyword> = + { + <level>[.<sub level] { Example:: @@ -398,14 +400,33 @@ To get the same result, the following is also possible:: '|p:/export', '|t:Download') AS _pdf -Nesting of levels -^^^^^^^^^^^^^^^^^ +Nesting of levels: `numeric` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Levels can be nested. E.g.:: - 10 { + 10 { + sql = SELECT ... + 5 { + sql = SELECT ... + head = ... + } + } + +This is equal to:: + + 10.sql = SELECT ... + 10.5.sql = SELECT ... + 10.5.head = ... + +Nesting of levels: `alias` +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Levels can be nested without levels. E.g.:: + + { sql = SELECT ... - 5 { + { sql = SELECT ... head = ... } @@ -413,9 +434,42 @@ Levels can be nested. E.g.:: This is equal to:: - 10.sql = SELECT ... - 10.5.sql = SELECT ... - 10.5.head = ... + 1.sql = SELECT ... + 1.2.sql = SELECT ... + 1.2.head = ... + +Levels are automatically numbered from top to bottom. + +An alias can be used instead of levels. E.g.:: + + myAlias { + sql = SELECT ... + nextAlias { + sql = SELECT ... + head = ... + } + } + +This is also equal to:: + + 1.sql = SELECT ... + 1.2.sql = SELECT ... + 1.2.head = ... + +.. important:: + +Allowed characters for an alias: [a-zA-Z0-9_-]. + +.. important:: + +The first level determines whether report notation `numeric` or `alias` is used. Using an alias or no level triggers +report notation `alias`. It requires the use of delimiters throughout the report. A combination with the notation +'10.sql = ...' is not possible. + +.. important:: + +Report notation `alias` does not require that each level be assigned an alias. If an alias is used, it must be on the +same line as the opening delimiter. Leading / trailing spaces ^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -423,17 +477,17 @@ Leading / trailing spaces By default, leading or trailing whitespaces are removed from strings behind '='. E.g. 'rend = test ' becomes 'test' for rend. To prevent any leading or trailing spaces, surround them by using single or double ticks. Example:: - 10.sql = SELECT name FROM Person - 10.rsep = ' ' - 10.head = "Names: " + 10.sql = SELECT name FROM Person + 10.rsep = ' ' + 10.head = "Names: " Braces character for nesting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ By default, curly braces '{}' are used for nesting. Alternatively angle braces '<>', round braces '()' or square -braces '[]' are also possible. To define the braces to use, the **first line** of the bodytext has to be a comment line and the -last character of that line must be one of '{[(<'. The corresponding braces are used for that QFQ record. E.g.:: +braces '[]' are also possible. To define the braces to use, the **first line** of the bodytext has to be a comment line +and the last character of that line must be one of '{[(<'. The corresponding braces are used for that QFQ record. E.g.:: # Specific code. > 10 < @@ -450,20 +504,24 @@ last character of that line must be one of '{[(<'. The corresponding braces are Per QFQ tt-content record, only one type of nesting braces can be used. +. important:: + Be careful to: -* write nothing else than whitespaces/newline behind an **open brace** -* the **closing brace** has to be alone on a line:: + - write nothing else than whitespaces/newline behind an **open brace** + - the **closing brace** has to be alone on a line + +Example:: - 10.sql = SELECT 'Yearly Report' + 10.sql = SELECT 'Yearly Report' - 20 { + 20 { sql = SELECT companyName FROM Company LIMIT 1 head = <h1> tail = </h1> - } + } - 30 { + 30 { sql = SELECT depName FROM Department head = <p> tail = </p> @@ -472,15 +530,15 @@ Be careful to: 1.sql = SELECT name FROM Person LIMIT 7 1.head = Employees: } - } + } - 30.5.tail = More will follow + 30.5.tail = More will follow - 50 + 50 - { + { sql = SELECT 'A query with braces on their own' - } + } .. _`access-column-values`: @@ -504,18 +562,18 @@ The STORE_RECORD will always be merged with previous content. The Level Keys are Example:: - 10.sql = SELECT 'p:/home?form=Person|s|b:success|t:Edit' AS _link - 10.20.sql = SELECT '{{link:R}}', '{{&link:R}}' + 10.sql = SELECT 'p:/home?form=Person|s|b:success|t:Edit' AS _link + 10.20.sql = SELECT '{{link:R}}', '{{&link:R}}' -The first column of row `10.20` returns `p:/home?form=Person|s|b:success|t:Edit`,the second column returns +The first column of row ``10.20`` returns ``p:/home?form=Person|s|b:success|t:Edit``,the second column returns '<span class="btn btn-success"><a href="?home&s=badcaffee1234">Edit</a></span>'. Example STORE_RECORD:: - 10.sql= SELECT p.id AS _pId, p.name FROM Person AS p - 10.5.sql = SELECT adr.city, 'dummy' AS _pId FROM Address AS adr WHERE adr.pId={{pId:R}} - 10.5.20.sql = SELECT '{{pId:R}}' - 10.10.sql = SELECT '{{pId:R}}' + 10.sql= SELECT p.id AS _pId, p.name FROM Person AS p + 10.5.sql = SELECT adr.city, 'dummy' AS _pId FROM Address AS adr WHERE adr.pId={{pId:R}} + 10.5.20.sql = SELECT '{{pId:R}}' + 10.10.sql = SELECT '{{pId:R}}' The line '10.10' will output 'dummy' in cases where there is at least one corresponding address. If there are no addresses (all persons) it reports the person id. @@ -523,12 +581,25 @@ If there is at least one address, it reports 'dummy', cause that's the last stor Example 'level':: - 10.sql= SELECT p.id AS _pId, p.name FROM Person AS p - 10.5.sql = SELECT adr.city, 'dummy' AS _pId FROM Address AS adr WHERE adr.pId={{10.pId}} - 10.5.20.sql = SELECT '{{10.pId}}' - 10.10.sql = SELECT '{{10.pId}}' + 10.sql= SELECT p.id AS _pId, p.name FROM Person AS p + 10.5.sql = SELECT adr.city, 'dummy' AS _pId FROM Address AS adr WHERE adr.pId={{10.pId}} + 10.5.20.sql = SELECT '{{10.pId}}' + 10.10.sql = SELECT '{{10.pId}}' +Example 'alias':: + myAlias { + sql = SELECT p.id AS _pId, p.name FROM Person AS p + myAlias2 { + sql = SELECT adr.city, 'dummy' AS _pId FROM Address AS adr WHERE adr.pId={{10.pId}} + myAlias3 { + sql = SELECT '{{myAlias.pId}}' + } + } + myAlias4 { + sql = SELECT '{{myAlias.pId}}' + } + } Notes to the level: +-------------+------------------------------------------------------------------------------------------------------------------------+ @@ -542,6 +613,8 @@ Notes to the level: +-------------+------------------------------------------------------------------------------------------------------------------------+ | Child |The level *30* has one child and child child: *30.5* and *30.5.1* | +-------------+------------------------------------------------------------------------------------------------------------------------+ +| Alias |A variable that can be assigned to a level and used to retrieve its values. | ++-------------+------------------------------------------------------------------------------------------------------------------------+ | Example | *10*, *20*, *30*, *50** are root level and will be completely processed one after each other. | | | *30.5* will be executed as many times as *30* has row numbers. | | | *30.5.1* will be executed as many times as *30.5* has row numbers. | @@ -598,7 +671,7 @@ QFQ don't care about the content of any SQL-Query - it just copy the content to One exception are columns, whose name starts with '_'. E.g.:: - 10.sql = SELECT 'All', 'cats' AS red, 'are' AS _green, 'grey in the night' AS _link + 10.sql = SELECT 'All', 'cats' AS red, 'are' AS _green, 'grey in the night' AS _link * The first and second column are regular columns. No QFQ processing. * The third column (alias name 'green') is no QFQ special column name, but has an '_' at the beginning: this column @@ -667,7 +740,7 @@ Summary: +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | _check |Display a blue/gray/green/pink/red/yellow checked sign. If none color specified, show nothing. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| _nl2br |All newline characters will be converted to `<br>`. | +| _nl2br |All newline characters will be converted to ``<br>``. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | _striptags |HTML Tags will be stripped. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ @@ -700,14 +773,18 @@ Summary: +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |_<nonReservedName> |Suppress output. Column names with leading underscore are used to select data from the database and make it available in other parts of the report without generating any output. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -|_formJson |System internal. Return form with given id as JSON string. (`SELECT 'fid:<formId>[\|reduce][\|b64]' AS _formJson`). | -| |Flag `reduce` filters out 'modified', 'created' as well as keys which hold default values. | -| |Flag `b64` encodes the JSON string in base 64. | +|_formJson |System internal. Return form with given id as JSON string. (``SELECT 'fid:<formId>[\|reduce][\|b64]' AS _formJson``). | +| |Flag ``reduce`` filters out 'modified', 'created' as well as keys which hold default values. | +| |Flag ``b64`` encodes the JSON string in base 64. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |_encrypt |:ref:`column-encrypt` - Encrypt value. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |_decrypt |:ref:`column-decrypt` - Decrypt value. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +|_upload |:ref:`column-upload` - Upload field with drag and drop and file browser. | ++------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +|_jwt |:ref:`column-jwt` - generates a json web token from the provided data. | ++------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ .. _column-link: @@ -730,7 +807,7 @@ Column: _link +---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+ |x | |Page |p:<pageSlug> |p:/impressum?foo=bar |Append optional GET parameters afeter '?', no hostname qualifier (automatically set by browser) | +---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+ -|x | |Download |d:[<exportFilename>] |d:complete.pdf |Link points to `.../typo3conf/ext/qfq/Api/download.php`. Additional parameter SIP encoded. 'Download' needs SIP. See :ref:`download`. | +|x | |Download |d:[<exportFilename>] |d:complete.pdf |Link points to ``.../typo3conf/ext/qfq/Api/download.php``. Additional parameter SIP encoded. 'Download' needs SIP. See :ref:`download`. | +---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+ |x | |Copy to |y:[some content] |y:this will be copied |Click on it copies the value of 'y:' to the clipboard. Optional a file ('F:...') might be specified as source. | | | |clipboard | | |See :ref:`copyToClipboard`. | @@ -929,15 +1006,15 @@ Alert: Question +======================+==========================================================================================================================+ | Text | The text shown by the alert. HTML is allowed to format the text. Any ':' needs to be escaped. Default: 'Please confirm'. | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ -| Level | success, info, warning, danger | +| Level | info (default), success, warning, danger/error | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ | Positive button text | Default: 'Ok' | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ | Negative button text | Default: 'Cancel'. To hide the second button: '-' | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ -| Timeout in seconds | 0: no timeout, >0: after the specified time in seconds, the alert will dissapear and behaves like 'negative answer' | +| Timeout in seconds | Default: 0, no timeout. > 0, after the specified time in seconds, the alert disappears ('negative answer'). | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ -| Flag modal | 0: Alert behaves not modal. 1: (default) Alert behaves modal. | +| Flag modal | Default: 1, alert behaves modal. 0, Alert does not behave modal and appears on the side. | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ Examples: @@ -961,22 +1038,22 @@ Text before / after link ^^^^^^^^^^^^^^^^^^^^^^^^ * Renders text before and/or after a link. -* Example: `SELECT 'p:{{pageAlias:T}}|t:Reload|v:Some text before |V: some text after' AS _link` -* A typical usecase is to get several `AS _link` columns in one HTML table cell, by still using `fbeg,fend`:: +* Example: ``SELECT 'p:{{pageAlias:T}}|t:Reload|v:Some text before |V: some text after' AS _link`` +* A typical use case is to get several ``AS _link`` columns in one HTML table cell, by still using ``fbeg,fend``:: - 10 { - sql = SELECT p.id - , 'p:{{pageAlias:T}}|t:Reload 1|v:<td>Some text before |V: - ' AS '_link|_noWrap' - , 'p:{{pageAlias:T}}|t:Reload 2|V:</td>' AS '_link|_noWrap' - , p.name - FROM Person AS p - head = <table> - tail = </table> - rbeg = <tr> - rend = </tr> - fbeg = <td> - fend = </td> - } + 10 { + sql = SELECT p.id + , 'p:{{pageAlias:T}}|t:Reload 1|v:<td>Some text before |V: - ' AS '_link|_noWrap' + , 'p:{{pageAlias:T}}|t:Reload 2|V:</td>' AS '_link|_noWrap' + , p.name + FROM Person AS p + head = <table> + tail = </table> + rbeg = <tr> + rend = </tr> + fbeg = <td> + fend = </td> + } @@ -1001,9 +1078,9 @@ The colum name is composed of the string *page* and a trailing character to spec +---------------+-----------------------------------------------+-------------------------------------+----------------------------------------------+ | column name | Purpose |default value of question parameter | Mandatory parameters | +===============+===============================================+=====================================+==============================================+ -|_page |Internal link without a grafic |empty |p:<pageSlug>[?param] | +|_page |Internal link without a graphic |empty |p:<pageSlug>[?param] | +---------------+-----------------------------------------------+-------------------------------------+----------------------------------------------+ -|_pagec |Internal link without a grafic, with question |*Please confirm!* |p:<pageSlug>[?param] | +|_pagec |Internal link without a graphic, with question |*Please confirm!* |p:<pageSlug>[?param] | +---------------+-----------------------------------------------+-------------------------------------+----------------------------------------------+ |_paged |Internal link with delete icon (trash) |*Delete record ?* | | U:form=<formname>&r=<record id> *or* | | | | | | U:table=<tablename>&r=<record id> | @@ -1019,10 +1096,9 @@ The colum name is composed of the string *page* and a trailing character to spec |_pages |Internal link with show icon (magnifier) |empty |p:<pageSlug>[?param] | +---------------+-----------------------------------------------+-------------------------------------+----------------------------------------------+ - -* All parameter are optional. -* Optional set of predefined icons. -* Optional set of dialog boxes. + * All parameter are optional. + * Optional set of predefined icons. + * Optional set of dialog boxes. +--------------+-------------------------------------------------------------------------------------------------+----------------------------------------------------------+---------------------------------------------------------------+ | Parameter | Description | Default value |Example | @@ -1061,10 +1137,10 @@ These column offers a link, with a confirmation question, to delete one record ( .. -If the record to delete contains column(s), whose column name match on `%pathFileName%` and such a +If the record to delete contains column(s), whose column name match on ``%pathFileName%`` and such a column points to a real existing file, such a file will be deleted too. If the table contains records where the specific file is multiple times referenced, than the file is not deleted (it would break the still existing references). Multiple -references are not found, if they use different colummnnames or tablenames. +references are not found, if they use different column names or table names. Mode: table """"""""""" @@ -1125,7 +1201,7 @@ Use instead :ref:`vertical-column-title` .. warning:: The '... AS _vertical' is deprecated - do not use it anymore. -Render text vertically. This is useful for tables with limited column width. The vertical rendering is achieved via CSS tranformations +Render text vertically. This is useful for tables with limited column width. The vertical rendering is achieved via CSS transformations (rotation) defined in the style attribute of the wrapping tag. You can optionally specify the rotation angle. **Syntax** :: @@ -1211,7 +1287,7 @@ The following parameters can also be written as complete words for ease of use:: [|autosubmit:<on/off>][|grid:<grid>][|xid:<xId>][|xid2:<xId2>][|xid3:<xId3>][|header:<mail header>] [|mode:html] -Send emails. Every mail will be logged in the table `mailLog`. Attachments are supported. +Send emails. Every mail will be logged in the table ``mailLog``. Attachments are supported. **Syntax** :: @@ -1227,13 +1303,13 @@ Send emails. Every mail will be logged in the table `mailLog`. Attachments are s | | f | email |**FROM**: Sender of the email. Optional: 'realname <john@doe.com>' | yes | | | from | | | | +--------------+----------------------------------------+--------------------------------------------------------------------------------------------------+------------+ -| | t | email[,email] |**TO**: Comma separated list of receiver email addresses. Optional: `realname <john@doe.com>` | yes | +| | t | email[,email] |**TO**: Comma separated list of receiver email addresses. Optional: ``realname <john@doe.com>`` | yes | | | to | | | | +--------------+----------------------------------------+--------------------------------------------------------------------------------------------------+------------+ -| | c | email[,email] |**CC**: Comma separated list of receiver email addresses. Optional: 'realname <john@doe.com>' | | +| | c | email[,email] |**CC**: Comma separated list of receiver email addresses. Optional: ``realname <john@doe.com>`` | | | | cc | | | yes | +--------------+----------------------------------------+--------------------------------------------------------------------------------------------------+------------+ -| | B | email[,email] |**BCC**: Comma separated list of receiver email addresses. Optional: 'realname <john@doe.com>' | | +| | B | email[,email] |**BCC**: Comma separated list of receiver email addresses. Optional: ``realname <john@doe.com>`` | | | | bcc | | | yes | +--------------+----------------------------------------+--------------------------------------------------------------------------------------------------+------------+ | | r | REPLY-TO:email |**Reply-to**: Email address to reply to (if different from sender) | | @@ -1283,7 +1359,7 @@ Send emails. Every mail will be logged in the table `mailLog`. Attachments are s * **e|E**: By default, QFQ stores values 'htmlspecialchars()' encoded. If such values have to send by email, the html entities are unwanted. Therefore the default setting for 'subject' und 'body' is to decode the values via 'htmlspecialchars_decode()'. - If this is not wished, it can be turned off by `e=none` and/or `E=none`. + If this is not wished, it can be turned off by ``e=none`` and/or ``E=none``. **Minimal Example** :: @@ -1330,8 +1406,8 @@ The following options are provided to attach files to an email: | d | d:myfile.pdf | Name of the attachment in the email. | +-------+------------------------------------------------------+--------------------------------------------------------+ | C | C|u:http://www.example.com|F:file1.pdf|C|F:file2.pdf | Concatenate all named sources to one PDF file. The | -| | | souces has to be PDF files or a web page, which will be| -| | | converted to a PDF first. | +| | | sources have to be PDF files or a web page, which will | +| | | be converted to a PDF first. | +-------+------------------------------------------------------+--------------------------------------------------------+ Any combination (incl. repeating them) are possible. Any source will be added as a single attachment. @@ -1340,23 +1416,23 @@ Optional any number of sources can be concatenated to a single PDF file: 'C|F:<f Examples in Report:: - # One file attached. - 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|F:fileadmin/summary.pdf" AS _sendmail + # One file attached. + 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|F:fileadmin/summary.pdf" AS _sendmail - # Two files attached. - 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|F:fileadmin/summary.pdf|F:fileadmin/detail.pdf" AS _sendmail + # Two files attached. + 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|F:fileadmin/summary.pdf|F:fileadmin/detail.pdf" AS _sendmail - # Two files and a webpage (converted to PDF) are attached. - 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|F:fileadmin/summary.pdf|F:fileadmin/detail.pdf|p:?id=export&r=123|d:person.pdf" AS _sendmail + # Two files and a webpage (converted to PDF) are attached. + 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|F:fileadmin/summary.pdf|F:fileadmin/detail.pdf|p:?id=export&r=123|d:person.pdf" AS _sendmail - # Two webpages (converted to PDF) are attached. - 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|p:?id=export&r=123|d:person123.pdf|p:?id=export&r=234|d:person234.pdf" AS _sendmail + # Two webpages (converted to PDF) are attached. + 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|p:?id=export&r=123|d:person123.pdf|p:?id=export&r=234|d:person234.pdf" AS _sendmail - # One file and two webpages (converted to PDF) are *concatenated* to one PDF and attached. - 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|C|F:fileadmin/summary.pdf|p:?id=export&r=123|p:?id=export&r=234|d:complete.pdf" AS _sendmail + # One file and two webpages (converted to PDF) are *concatenated* to one PDF and attached. + 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|C|F:fileadmin/summary.pdf|p:?id=export&r=123|p:?id=export&r=234|d:complete.pdf" AS _sendmail - # One T3 webpage, protected by a SIP, are attached. - 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|p:?id=export&r=123&_sip=1|d:person123.pdf" AS _sendmail + # One T3 webpage, protected by a SIP, are attached. + 10.sql = SELECT "t:john.doe@example.com|f:company@example.com|s:Latest News|b:The new version is now available.|p:?id=export&r=123&_sip=1|d:person123.pdf" AS _sendmail .. _column_img: @@ -1391,9 +1467,9 @@ Renders images. Allows to define an alternative text and a title attribute for t **Advanced Examples** :: - 10.sql = SELECT "fileadmin/img/img.jpg|Aternative Text" AS _img # alt="Alternative Text, no title - 20.sql = SELECT "fileadmin/img/img.jpg|Aternative Text|" AS _img # alt="Alternative Text, no title - 30.sql = SELECT "fileadmin/img/img.jpg|Aternative Text|Title Text" AS _img # alt="Alternative Text, title="Title Text" + 10.sql = SELECT "fileadmin/img/img.jpg|Alternative Text" AS _img # alt="Alternative Text, no title + 20.sql = SELECT "fileadmin/img/img.jpg|Alternative Text|" AS _img # alt="Alternative Text, no title + 30.sql = SELECT "fileadmin/img/img.jpg|Alternative Text|Title Text" AS _img # alt="Alternative Text, title="Title Text" 40.sql = SELECT "fileadmin/img/img.jpg|Alternative Text" AS _img # alt="Alternative Text", no title 50.sql = SELECT "fileadmin/img/img.jpg" AS _img # empty alt, no title 60.sql = SELECT "fileadmin/img/img.jpg|" AS _img # empty alt, no title @@ -1421,7 +1497,7 @@ Run any command on the web server. SELECT 'touch /tmp >/dev/null' AS _exec SELECT 'touch /root 2>&1 >/dev/null' AS _exec -* Multiple commands can be concatenated by `;`:: +* Multiple commands can be concatenated by ``;``:: SELECT 'date; date' AS _exec @@ -1465,7 +1541,7 @@ Run a php function defined in an external script. * The **current working directory** inside the function is the current web instance (e.g. location of index.php). * Hint: Inside the script ``dirname(__FILE__)`` gives the path of the script. * All **output (e.g. using echo) will be rendered** by the special column as is. -* If the function returns an associative array, then the **key-value pairs will be accessible via the VARS store `V`**. +* If the function returns an associative array, then the **key-value pairs will be accessible via the VARS store ``V``**. * If the function throws an **exception** then a standard QFQ error message is shown. * Text sent to 'stderr' by the php function is not returned at all. * The script has access to the following **qfq php functions** using the interface (see examples below): @@ -1510,13 +1586,13 @@ Run a php function defined in an external script. * QFQ report :: - 5.sql = SELECT "IAmInRecordStore" AS _savedInRecordStore - 10.sql = SELECT "F:fileadmin/scripts/my_script.php|call:my_function|arg:a1=Hello&a2=World" AS _script - 20.sql = SELECT "<br><br>Returened value: {{IAmInVarStore:V:alnumx}}" + 5.sql = SELECT "IAmInRecordStore" AS _savedInRecordStore + 10.sql = SELECT "F:fileadmin/scripts/my_script.php|call:my_function|arg:a1=Hello&a2=World" AS _script + 20.sql = SELECT "<br><br>Returned value: {{IAmInVarStore:V:alnumx}}" -* PHP script (`fileadmin/scripts/my_script.php`) :: +* PHP script (``fileadmin/scripts/my_script.php``) :: - <?php + <?php function my_function($param, $qfq) { echo 'The first argument contains all attributes including "F" and "c":<br>'; @@ -1544,7 +1620,7 @@ Run a php function defined in an external script. Make API call: Http code: 301 - Returened value: FooBar + Returned value: FooBar .. _column_pdf: @@ -1563,8 +1639,8 @@ Most of the other Link-Class attributes can be used to customize the link. :: * Parameter are position independent. * *<params>*: see :ref:`download-parameter-files` * For column ``_pdf`` and ``_zip``, the element sources ``p:...``, ``U:...``, ``u:...``, ``F:...`` might repeated multiple times. -* For column ``_zip``, an optional parameter might define the path and filename inside the ZIP: `F:<orig filename>:<inside ZIP path and filename>` -* To only render the page content without menus add the parameter ``type=2``. For example: ``U:id=pageToPrint&type=2&_sip=1&r=', r.id`` +* For column ``_zip``, an optional parameter might define the path and filename inside the ZIP: ``F:<orig filename>:<inside ZIP path and filename>``. +* To only render the page content without menus add the parameter ``type=2``. For example: ``U:id=pageToPrint&type=2&_sip=1&r=', r.id``. * Example:: # ... AS _file @@ -1607,13 +1683,13 @@ Tips: * Please note that this option does not render anything in the front end, but is executed each time it is parsed. You may want to add a check to prevent multiple execution. * It is not advised to generate the filename with user input for security reasons. -* If the target file already exists it will be overwriten. To save individual files, choose a new filename, +* If the target file already exists it will be overwritten. To save individual files, choose a new filename, for example by adding a timestamp. Example:: - SELECT "d:fileadmin/result.pdf|F:fileadmin/_temp_/test.pdf" AS _savePdf - SELECT "d:fileadmin/result.pdf|F:fileadmin/_temp_/test.pdf|U:id=test&--orientation=landscape" AS _savePdf + SELECT "d:fileadmin/result.pdf|F:fileadmin/_temp_/test.pdf" AS _savePdf + SELECT "d:fileadmin/result.pdf|F:fileadmin/_temp_/test.pdf|U:id=test&--orientation=landscape" AS _savePdf .. _column-save-zip: @@ -1634,9 +1710,9 @@ Save generated ZIP locally on the server. Example:: - SELECT "d:fileadmin/result.zip|F:fileadmin/_temp_/test.pdf" AS _saveZip - SELECT "d:fileadmin/result.zip|F:fileadmin/_temp_/test.pdf|U:id=test&--orientation=landscape" AS _saveZip - SELECT "d:fileadmin/result.zip|F:fileadmin/_temp_/test.pdf|F:fileadmin/_temp_/test2.xlsx|U:id=test&--orientation=landscape" AS _saveZip + SELECT "d:fileadmin/result.zip|F:fileadmin/_temp_/test.pdf" AS _saveZip + SELECT "d:fileadmin/result.zip|F:fileadmin/_temp_/test.pdf|U:id=test&--orientation=landscape" AS _saveZip + SELECT "d:fileadmin/result.zip|F:fileadmin/_temp_/test.pdf|F:fileadmin/_temp_/test2.xlsx|U:id=test&--orientation=landscape" AS _saveZip .. _column-thumbnail: @@ -1682,17 +1758,17 @@ tag. Something like ``<body style="background-image:url(bgimage.jpg)">`` could b Example:: - # SIP protected, IMG tag, thumbnail width 150px - 10.sql = SELECT 'T:fileadmin/file3.pdf' AS _thumbnail + # SIP protected, IMG tag, thumbnail width 150px + 10.sql = SELECT 'T:fileadmin/file3.pdf' AS _thumbnail - # SIP protected, IMG tag, thumbnail width 50px - 20.sql = SELECT 'T:fileadmin/file3.pdf|W:50' AS _thumbnail + # SIP protected, IMG tag, thumbnail width 50px + 20.sql = SELECT 'T:fileadmin/file3.pdf|W:50' AS _thumbnail - # No SIP protection, IMG tag, thumbnail width 150px - 30.sql = SELECT 'T:fileadmin/file3.pdf|s:0' AS _thumbnail + # No SIP protection, IMG tag, thumbnail width 150px + 30.sql = SELECT 'T:fileadmin/file3.pdf|s:0' AS _thumbnail - # SIP protected, only the URL to the image, thumbnail width 150px - 40.sql = SELECT 'T:fileadmin/file3.pdf|s:1|r:7' AS _thumbnail + # SIP protected, only the URL to the image, thumbnail width 150px + 40.sql = SELECT 'T:fileadmin/file3.pdf|s:1|r:7' AS _thumbnail Dimension @@ -1707,40 +1783,40 @@ Cleaning By default, the thumbnail directories are never cleaned. It's a good idea to install a cronjob which purges all files older than 1 year: :: - find /path/to/files -type f -mtime +365 -delete + find /path/to/files -type f -mtime +365 -delete Render """""" `Public` thumbnails are rendered at the time when the T3 QFQ record is executed. `Secure` thumbnails are rendered when the 'download.php?s=...' is called. The difference is, that the 'public' thumbnails blocks the page load until all thumbnails -are rendered, instead the `secure` thumbnails are loaded asynchonous via the browser - the main page is already delivered to +are rendered, instead the `secure` thumbnails are loaded asynchronous via the browser - the main page is already delivered to browser, all thumbnails appearing after a time. A way to *pre render* thumbnails, is a periodically called (hidden) T3 page, which iterates over all new uploaded files and -triggers the rendering via column `_thumbnail`. +triggers the rendering via column ``_thumbnail``. Thumbnail: secure """"""""""""""""" -Mode 'secure' is activated via enabling SIP (`s:1`, default). The thumbnail is saved under the path `thumbnailDirSecure` +Mode 'secure' is activated via enabling SIP (``s:1``, default). The thumbnail is saved under the path ``thumbnailDirSecure`` as configured in :ref:`configuration`. The secure path needs to be protected against direct file access by the webmaster / webserver configuration too. QFQ returns a HTML 'img'-tag: :: - <img src="api/download.php?s=badcaffee1234"> + <img src="api/download.php?s=badcaffee1234"> Thumbnail: public """"""""""""""""" -Mode 'public' has to be explicit activated by specifying `s:0`. The thumbnail is saved under the path `thumbnailDirPublic` +Mode 'public' has to be explicit activated by specifying ``s:0``. The thumbnail is saved under the path ``thumbnailDirPublic`` as configured in :ref:`configuration`. QFQ returns a HTML 'img'-tag: :: - <img src="{{thumbnailDirPublic:Y}}/<md5 hash>.png"> + <img src="{{thumbnailDirPublic:Y}}/<md5 hash>.png"> .. _column-monitor: @@ -1775,13 +1851,13 @@ then the default is used. See here for more information about default method: :r **Syntax** :: - 10.sql = SELECT firstName AS _encrypt FROM Person WHERE id = 1 - 20.sql = SELECT "Words to be encrypted" AS _encrypt=AES-128 + 10.sql = SELECT firstName AS _encrypt FROM Person WHERE id = 1 + 20.sql = SELECT "Words to be encrypted" AS _encrypt=AES-128 **A useful situation**:: - 10.sql = SELECT "Words to be encrypted" AS '_encrypt=AES-128|encryptedValue|_hide' - 20.sql = UPDATE Person SET secret = '{{&encryptedValue:RE:all}}' WHERE id = 1 + 10.sql = SELECT "Words to be encrypted" AS '_encrypt=AES-128|encryptedValue|_hide' + 20.sql = UPDATE Person SET secret = '{{&encryptedValue:RE:all}}' WHERE id = 1 Valid encryption methods: @@ -1797,7 +1873,87 @@ Decrypting selected columns or strings which are encrypted with QFQ. **Syntax** :: - 10.sql = SELECT secret AS _decrypt FROM Person WHERE id = 1 + 10.sql = SELECT secret AS _decrypt FROM Person WHERE id = 1 + +.. _column-upload: + +Column: _upload +^^^^^^^^^^^^^^^ + +Creates an upload field which allows to upload files per drag and drop or over file browser. +There is a qfq delivered table named FileUpload which will be used to store the upload information's as default. +The files will be stored directly in destination folder, no need to trigger something after upload. + ++-----------------+-----------------------------------------------+------------------------------------------------------+ +| Token | Default value | Note | ++=================+===============================================+======================================================+ +| `F` | fileadmin/protected/upload/[currentYear]/ | File destination path | +| `x` | 1 | Delete file option for preloaded files | +| `table` | FileUpload | DB destination table | +| `M` | 0 | Enable multi upload option | +| `maxFileSize` | (none) | Max. allowed file size | +| `accept` | (all) | Allowed file types | +| `allowUpload` | true | Enable file upload | +| `t` | Drag and drop or <span class="btn btn-default | Upload field text | +| | filepond--label-action"> Browse </span> | | +| `recordData` | (none) | Define own column vaules to pass in upload table. | +| `maxFiles` | (unlimited if multi upload) | Allow a max count of files (multi upload) | +| `dbIndex` | (QFQ db index number) | Database index for destination table | ++-----------------+-----------------------------------------------+------------------------------------------------------+ + +The upload destination table must have at least following columns: + * id (unique) + * pathFileName + * uploadId + * size + * type + * ord + +More columns are optional and up to you. + +For multi-database setups with destination tables outside the QFQ database index, define them using the dbIndex parameter. + +**Syntax** :: + + 10.sql = SELECT 'uploadId:0' AS _upload + + 20.sql = SELECT 'uploadId:2|M|x:0' AS _upload + + 30.sql = SELECT 'uploadId:0|F:fileadmin/protected/testfolder/file123.png|dbIndex:2|x|M|accept:image/*|recordData: xId:23,grId:125' AS _upload + + +If multi upload is enabled then the given uploadId references to the column uploadId otherwise it will reference to the column id. +.. _column-jwt: + +Column: _jwt +^^^^^^^^^^^^ + +Creates a `json web token <https://jwt.io/>`_ from the provided data. + +Supported options: + ++-----------+---------------+------------------------------------------------------+ +| Parameter | Default value | Note | ++===========+===============+======================================================+ +| `alg` | `HS256` | The signing algorithm - it is included in the header | +| `key` | (none) | The secret key used for signing the token | ++-----------+---------------+------------------------------------------------------+ + +Predefined claims: + ++-------+----------------+-------------------+-----------------------------------------------------------+ +| Claim | Present | Default value | Note | ++=======+================+===================+===========================================================+ +| `iss` | always | `qfq` | The default value might be also specified in QFQ settings | +| `iat` | always | current timestamp | Ignores any provided value | +| `exp` | when specified | none | Prefix with `+` to specify a relative timestamp | +| `nbf` | when specified | none | Prefix with `+` to specify a relative timestamp | ++-------+----------------+-------------------+-----------------------------------------------------------+ + +**Syntax** :: + + 10.sql = SELECT 'exp:+3600,data:{msg:default alg}|<secretKey>' AS _jwt + 20.sql = SELECT 'exp:+60,data:{msg:explicit agl}|<secretKey>|ES384' AS _jwt .. _copyToClipboard: @@ -1833,30 +1989,30 @@ API Call QFQ Report (e.g. AJAX) * General use API call to fire a specific QFQ tt-content record. Useful for e.g. AJAX calls. No Typo3 is involved. *No FE-Group access control*. * This defines just a simple API endpoint. For defining a rest API see: :ref:`restApi`. * Custom response headers can be defined by setting the variable `apiResponseHeader` in the record store. - * Multiple headers should be separated by `\n` or `\r\n`. e.g.: `Content-Type: application/json\ncustom-header: fooBar` + * Multiple headers should be separated by ``\n`` or ``\r\n``. e.g.: `Content-Type: application/json\ncustom-header: fooBar` * If the api call succeeds the rendered content of the report is returned as is. (no additional formatting, no JSON encoding) * You can use MYSQL to create Json. See: `MYSQL create Json <https://dev.mysql.com/doc/refman/8.0/en/json-creation-functions.html>`_ and `MariaDB Json functions <https://mariadb.com/kb/en/json-functions/>`_ -* If a QFQ error occurs then a http-status of 400 is returned together with a JSON encoded response of the form: `{"status":"error", "message":"..."}` +* If a QFQ error occurs then a http-status of 400 is returned together with a JSON encoded response of the form: ``{"status":"error", "message":"..."}`` Example QFQ record JS (with tt_content.uid=12345):: - 5.sql = SELECT "See console log for output" + 5.sql = SELECT "See console log for output" - # Register SIP with given arguments. - 10.sql = SELECT 'U:uid=12345&arg1=Hello&arg2=World|s|r:8' AS '_link|col1|_hide' + # Register SIP with given arguments. + 10.sql = SELECT 'U:uid=12345&arg1=Hello&arg2=World|s|r:8' AS '_link|col1|_hide' - # Build JS - 10.tail = <script> - console.log('start api request'); - $.ajax({ - url: 'typo3conf/ext/qfq/Classes/Api/dataReport.php?s={{&col1:RE}}', - data: {arg3:456, arg4:567}, - method: 'POST', - dataType: 'TEXT', - success: function(response, status, jqxhr) {console.log(response); console.log(jqxhr.getAllResponseHeaders());}, - error: function(jqXHR, textStatus, errorThrown) {console.log(jqXHR.responseText, textStatus, errorThrown);} - }); - </script> + # Build JS + 10.tail = <script> + console.log('start api request'); + $.ajax({ + url: 'typo3conf/ext/qfq/Classes/Api/dataReport.php?s={{&col1:RE}}', + data: {arg3:456, arg4:567}, + method: 'POST', + dataType: 'TEXT', + success: function(response, status, jqxhr) {console.log(response); console.log(jqxhr.getAllResponseHeaders());}, + error: function(jqXHR, textStatus, errorThrown) {console.log(jqXHR.responseText, textStatus, errorThrown);} + }); + </script> Example QFQ record called by above AJAX:: @@ -1868,7 +2024,7 @@ Example QFQ record called by above AJAX:: Example text returned by the above AJAX call:: - Hello World 456 5672020-09-22 18:09:47 + Hello World 456 5672020-09-22 18:09:47 .. _rest_client: @@ -1885,12 +2041,12 @@ The received data can be processed in subsequent calls. Example:: - # Retrieve information. Received data is delivered in JSON and decoded / copied on the fly to CLIENT store (CLIENT store is emptied beforehand) - 10.sql = SELECT 'n:https://www.dummy.ord/rest/person/id/123' AS _restClient - 20.sql = SELECT 'Status: {{http-status:C}}<br>Name: {{name:C:alnumx}}<br>Surname: {{surname:C:alnumx}}' + # Retrieve information. Received data is delivered in JSON and decoded / copied on the fly to CLIENT store (CLIENT store is emptied beforehand) + 10.sql = SELECT 'n:https://www.dummy.ord/rest/person/id/123' AS _restClient + 20.sql = SELECT 'Status: {{http-status:C}}<br>Name: {{name:C:alnumx}}<br>Surname: {{surname:C:alnumx}}' - # Simple POST request via https. Result is printed on the page. - 10.sql = SELECT 'n:https://www.dummy.ord/rest/person/id/123|method:POST|content:{"name":"John";"surname":"Doe"}' AS _restClient + # Simple POST request via https. Result is printed on the page. + 10.sql = SELECT 'n:https://www.dummy.ord/rest/person/id/123|method:POST|content:{"name":"John";"surname":"Doe"}' AS _restClient +-------------------+----------------------------------------------------+--------------------------------------------------------+ | Token | Example | Comment | @@ -1911,7 +2067,7 @@ Example:: **Header** -* Each header must be separated by ``\r\n`` or `\n`. +* Each header must be separated by ``\r\n`` or ``\n``. * An explicit given header will overwrite the named default header. * Default header: @@ -1923,7 +2079,7 @@ Example:: Warning: Only use base64 for SSL encrypted connections:: - 10.sql = SELECT CONCAT('n:https://sample.com/id/1234|header:Authorization: Basic ', TO_BASE64('{{username}}:{{password}}') ) + 10.sql = SELECT CONCAT('n:https://sample.com/id/1234|header:Authorization: Basic ', TO_BASE64('{{username}}:{{password}}') ) **Result received** @@ -1987,7 +2143,7 @@ QNBSP: Convert space to ' ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The SQL function QNBSP(text) replaces ` ` (space) by ` `. This prevents unwanted line breaks in text. -E.g. the title 'Prof. Dr.' should never be breaked: QNBSP('Prof. Dr.') +E.g. the title 'Prof. Dr.' should never be separated: QNBSP('Prof. Dr.') Example:: @@ -2037,7 +2193,7 @@ Example:: Output:: - This is a `more..` + This is a `more..` .. _qifempty: @@ -2052,7 +2208,7 @@ Example:: Output:: - hello world- + hello world- .. _qdate_format: @@ -2079,11 +2235,11 @@ Non alphanumerical characters are stripped off. Spaces are replaced by '-'. All Example:: - 10.sql = SELECT QSLUGIFY('abcd ABCD ae.ä.oe.ö.ue.ü z[]{}()<>.,?Z') + 10.sql = SELECT QSLUGIFY('abcd ABCD ae.ä.oe.ö.ue.ü z[]{}()<>.,?Z') Output:: - abcd-abcd-ae-a-oe-o-ue-u-z-z + abcd-abcd-ae-a-oe-o-ue-u-z-z .. _qent_squote: @@ -2094,11 +2250,11 @@ Convert all single ticks in a string to the HTML entity "'" Example:: - 10.sql = SELECT QENT_SQUOTE("John's car") + 10.sql = SELECT QENT_SQUOTE("John's car") Output:: - John's car + John's car .. _qent_dquote: @@ -2109,11 +2265,11 @@ Convert all double ticks in a string to the HTML entity """ Example:: - 10.sql = SELECT QENT_SQUOTE('A "nice" event') + 10.sql = SELECT QENT_SQUOTE('A "nice" event') Output:: - A "nice" event + A "nice" event .. _qesc_squote: @@ -2125,12 +2281,12 @@ escaped single tick. Example:: - Be Music.style = "Rock'n' Roll" - 10.sql = SELECT QESC_SQUOTE(style) FROM Music + Be Music.style = "Rock'n' Roll" + 10.sql = SELECT QESC_SQUOTE(style) FROM Music Output:: - Rock\'n\'n Roll + Rock\'n\'n Roll .. _qesc_dquote: @@ -2142,13 +2298,12 @@ escaped double tick. Example:: - Set Comment.note = 'A "nice" event' - 10.sql = SELECT QESC_DQUOTE(style) FROM Music + Set Comment.note = 'A "nice" event' + 10.sql = SELECT QESC_DQUOTE(style) FROM Music Output:: - Rock\'n\'n Roll - + Rock\'n\'n Roll .. _qmanr: @@ -2159,11 +2314,26 @@ The SQL function QMANR(manr) returns '00-000-000', if 'manr' is 00000000. Example:: - 10.sql = SELECT QMANR(12345678) + 10.sql = SELECT QMANR(12345678) Output:: - 12-345-678 + 12-345-678 + +.. _qifprepend: + +QIFPREPEND: if not empty show input with prepend separator +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The SQL function QIFPREPEND(separator, input) returns 'input' with prepend separator if 'input' is not 'empty string' / '0'. + +Example:: + + 10.sql = SELECT 'lastName', QIFPREPEND(', ','title'), QIFPREPEND(', ','') + +Output:: + + lastName, title .. _strip_tags: @@ -2174,11 +2344,11 @@ The SQL function strip_tags(input) returns 'input' without any HTML tags. Example:: - 10.sql = SELECT strip_tags('<a href="https://example.com"><b>my name</b> <i>is john</i></a> - end of sentence') + 10.sql = SELECT strip_tags('<a href="https://example.com"><b>my name</b> <i>is john</i></a> - end of sentence') Output:: - my name is john - end of sentence + my name is john - end of sentence .. _qfq_function: @@ -2205,20 +2375,20 @@ values. Example tt-content record for the function:: - Subheader: getFirstName - Code: - # - # {{pId:R}} - # - render = api - - 100 { - sql = SELECT p.firstName AS _firstName - , NOW() AS now - , CONCAT('p:{{pageSlug:T}}?form=person&r=', p.id ) AS '_pagee|_hide|myLink' - FROM Person AS p - WHERE p.id={{pId:R}} - } + Subheader: getFirstName + Code: + # + # {{pId:R}} + # + render = api + + 100 { + sql = SELECT p.firstName AS _firstName + , NOW() AS now + , CONCAT('p:{{pageSlug:T}}?form=person&r=', p.id ) AS '_pagee|_hide|myLink' + FROM Person AS p + WHERE p.id={{pId:R}} + } Example tt-content record for the calling report:: @@ -2250,9 +2420,9 @@ Example tt-content record for the calling report:: Explanation: * Level 10 iterates over all `person`. -* Level 10.20 calls QFQ function `getFirstName()` by delivering the `pId` via STORE_RECORD. The function expects the return - value `firstName` and `myLink`. -* The function selects in level 100 the person given by ``{{pId:R}}``. The `firstName` is not printed but a hidden column. +* Level 10.20 calls QFQ function ``getFirstName()`` by delivering the ``pId`` via STORE_RECORD. The function expects the return + value ``firstName`` and ``myLink``. +* The function selects in level 100 the person given by ``{{pId:R}}``. The ``firstName`` is not printed but a hidden column. Column ``now`` is printed. Column 'myLink' is a rendered link, but not printed. * Level 10.30 prints the return values ``firstName`` and ``myLink`` (as rendered link and as source definition). The last column is the output of the function - the value of ``NOW()`` @@ -2308,8 +2478,8 @@ By using the ``_link`` column name: * the optional ``M:...`` (Mode) specifies the export type (file, pdf, qfqpdf, zip, export), * the alttext ``a:...`` specifies a message in the download popup. -By using ``_pdf``, ``_Pdf``, ``_file``, ``_File``, ``_zip``, ``_Zip``, ``_excel`` as column name, the options `d`, -`M` (pdf: wkhtml) and `s` will be set. +By using ``_pdf``, ``_Pdf``, ``_file``, ``_File``, ``_zip``, ``_Zip``, ``_excel`` as column name, the options ``d``, +``M`` (pdf: wkhtml) and ``s`` will be set. All files will be read by PHP - therefore the directory might be protected against direct web access. This is the preferred option to offer secure downloads via QFQ. Check `secure-direct-file-access`_. @@ -2323,11 +2493,11 @@ Parameter and (element) sources * *exportFilename* = <filename for save as> - Name, offered in the 'File save as' browser dialog. Default: 'output.<ext>'. - If there is no `exportFilename` defined, then the original filename is taken (if there is one, else: output...). + If there is no ``exportFilename`` defined, then the original filename is taken (if there is one, else: output...). The user typically expects meaningful and distinct file names for different download links. -* mode `persistent link` (s:0) - *download*: `d:[<path/name>]<key1>[/<keyN>]` +* mode ``persistent link`` (s:0) - *download*: ``d:[<path/name>]<key1>[/<keyN>]`` This setup is divided in part a) and b): @@ -2348,16 +2518,16 @@ Parameter and (element) sources SELECT CONCAT('d|F:', n.pathFileName) FROM Note AS n WHERE n.id=? - All `?` in the SQL statement will be replaced by the specified parameter. If there are more `?` than parameter, - the last parameter will be reused for all pending `?`. + All ``?`` in the SQL statement will be replaced by the specified parameter. If there are more ``?`` than parameter, + the last parameter will be reused for all pending ``?``. E.g. ``10.sql = SELECT 'd:1234|t:File.pdf' AS _link`` creates a link ``<a href="typo3conf/ext/qfq/Classes/Api/download.php/1234"><span class="btn btn-default">File.pdf</span></span>``. - If the user clicks on the link, QFQ will extract the `1234` argument and via ``download.php`` the query (defined in + If the user clicks on the link, QFQ will extract the ``1234`` argument and via ``download.php`` the query (defined in the Typo QFQ extension config) will be prepared and fires ``SELECT CONCAT('d|F:', n.pathFileName, '|t:File.pdf') FROM Note AS n WHERE n.id=1234``. The download of the file, specified by ``n.pathFileName``, will start. - If no record ist selected, a custom error will be shown. If the query selectes more than one record, a general error will be shown. + If no record ist selected, a custom error will be shown. If the query selects more than one record, a general error will be shown. If one of ``dl.php`` or ``dl2.php`` or ``dl3.php`` should be used, please initially create the symlink(s), e.g. in the application directory (same level as typo3conf) ``ln -s typo3conf/ext/qfq/Classes/Api/download.php dl.php`` (or dl2.ph, dl3.php). @@ -2378,14 +2548,14 @@ Parameter and (element) sources * *mode* = <file | pdf | qfqpdf | zip | excel> - * pdf: `wkhtml` will be used to render the pdf. - * qfqpdf: `qfqpdf` will be used to render the pdf. + * pdf: ``wkhtml`` will be used to render the pdf. + * qfqpdf: ``qfqpdf`` will be used to render the pdf. * If ``M:file``, the mime type is derived dynamically from the specified file. In this mode, only one element source is allowed per download link (no concatenation). * In case of multiple element sources, only `pdf`, `zip` and `excel` (template mode) is supported. - * If ``M:zip`` is used together with `p:...`, `U:...` or `u:..`, those HTML pages will be converted to PDF. Those files + * If ``M:zip`` is used together with ``p:...``, ``U:...`` or ``u:..``, those HTML pages will be converted to PDF. Those files get generic filenames inside the archive. * If not specified, the **default** 'Mode' depends on the number of specified element sources (=file or web page): @@ -2418,7 +2588,7 @@ Parameter and (element) sources * The called tt-content record is identified by `function name`, specified in the subheader field. Optional the numeric id of the tt-content record (=uid) can be given. * Only the specified QFQ content record will be rendered, without any Typo3 layout elements (Menu, Body,...) - * QFQ will retrieve the tt-content's bodytext from the Typo3 database, parse it, and render it as a PDF or Execl data. + * QFQ will retrieve the tt-content's bodytext from the Typo3 database, parse it, and render it as a PDF or Excel data. * Parameters can be passed: ``uid:<tt-content record id>[&arg1=value1][&arg2=value2][...]`` and will be available via STORE_SIP in the QFQ PageContent, or passed as wkhtmltopdf arguments, if applicable. * For more obviously structuring, put the additional tt-content record on the same Typo3 page (where the QFQ @@ -2440,20 +2610,20 @@ Parameter and (element) sources * 'M:pdf' - *WKHTML Options* for `page`, `urlParam` or `url`: * The 'HTML to PDF' will be done via `wkhtmltopdf`. - * All possible options, suitable for `wkhtmltopdf`, can be submitted in the `p:...`, `u:...` or `U:...` element source. + * All possible options, suitable for `wkhtmltopdf`, can be submitted in the ``p:...``, ``u:...`` or ``U:...`` element source. Check `wkhtmltopdf.txt <https://wkhtmltopdf.org/usage/wkhtmltopdf.txt>`_ for possible options. Be aware that key/value tuple in the documentation is separated by a space, but to respect the QFQ key/value notation of URLs, - the key/value tuple in `p:...`, `u:...` or `U:...` has to be separated by '='. Please see last example below. + the key/value tuple in ``p:...``, ``u:...`` or ``U:...`` has to be separated by '='. Please see last example below. * If an option contains an '&' it must be escaped with double \\ . See example. * 'M:qfqpdf' - *qfqpdf Options* for `page`, `urlParam` or `url`: * The 'HTML to PDF' will be done via `qfqpdf`. * Check https://puppeteer.github.io/puppeteer and https://git.math.uzh.ch/bbaer/qfqpdf/-/tree/master - * All possible options, suitable for `qfqpdf`, can be submitted in the `p:...`, `u:...` or `U:...` element source. + * All possible options, suitable for `qfqpdf`, can be submitted in the ``p:...``, ``u:...`` or ``U:...`` element source. Be aware that key/value tuple in the documentation is separated by a space, but to respect the QFQ key/value notation of URLs, - the key/value tuple in `p:...`, `u:...` or `U:...` has to be separated by '='. Please see last example below. + the key/value tuple in ``p:...``, ``u:...`` or ``U:...`` has to be separated by '='. Please see last example below. * If an option contains an '&' it must be escaped with double \\ . See example. * Page numbering is done via HTML templating / CSS classes: ``--header-template '<div style="font-size:5mm;" class="pageNumber"></div>'`` @@ -2461,58 +2631,58 @@ Parameter and (element) sources Example `_link`: :: - # single `file`. Specifying a popup message window text is not necessary, cause a file directly accessed is fast. - SELECT "d:file.pdf|s|t:Download|F:fileadmin/pdf/test.pdf" AS _link + # single `file`. Specifying a popup message window text is not necessary, cause a file directly accessed is fast. + SELECT "d:file.pdf|s|t:Download|F:fileadmin/pdf/test.pdf" AS _link - # single `file`, with mode - SELECT "d:file.pdf|M:pdf|s|t:Download|F:fileadmin/pdf/test.pdf" AS _link + # single `file`, with mode + SELECT "d:file.pdf|M:pdf|s|t:Download|F:fileadmin/pdf/test.pdf" AS _link - # three sources: two pages and one file - SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1|p:id=detail2&r=1|F:fileadmin/pdf/test.pdf" AS _link + # three sources: two pages and one file + SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1|p:id=detail2&r=1|F:fileadmin/pdf/test.pdf" AS _link - # qfqpdf - three sources: two pages and one file - SELECT "d:complete.pdf|M:qfqpdf|s|t:Complete PDF|p:id=detail&r=1|p:id=detail2&r=1|F:fileadmin/pdf/test.pdf" AS _link + # qfqpdf - three sources: two pages and one file + SELECT "d:complete.pdf|M:qfqpdf|s|t:Complete PDF|p:id=detail&r=1|p:id=detail2&r=1|F:fileadmin/pdf/test.pdf" AS _link - # three sources: two pages and one file - SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1|p:id=detail2&r=1|F:fileadmin/pdf/test.pdf" AS _link + # three sources: two pages and one file + SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1|p:id=detail2&r=1|F:fileadmin/pdf/test.pdf" AS _link - # three sources: two pages and one file, parameter to wkhtml will be SIP encoded - SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1&_sip=1|p:id=detail2&r=1&_sip=1|F:fileadmin/pdf/test.pdf" AS _link + # three sources: two pages and one file, parameter to wkhtml will be SIP encoded + SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1&_sip=1|p:id=detail2&r=1&_sip=1|F:fileadmin/pdf/test.pdf" AS _link - # three sources: two pages and one file, the second page will be in landscape and pagesize A3 - SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1|p:id=detail2&r=1&--orientation=Landscape&--page-size=A3|F:fileadmin/pdf/test.pdf" AS _link + # three sources: two pages and one file, the second page will be in landscape and page size A3 + SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1|p:id=detail2&r=1&--orientation=Landscape&--page-size=A3|F:fileadmin/pdf/test.pdf" AS _link - # One source and a header file. Note: the parameter to the header URL is escaped with double backslash. - SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail2&r=1&--orientation=Landscape&--header={{URL:R}}?indexp.php?id=head\\&L=1|F:fileadmin/pdf/test.pdf" AS _link + # One source and a header file. Note: the parameter to the header URL is escaped with double backslash. + SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail2&r=1&--orientation=Landscape&--header={{URL:R}}?index.php?id=head\\&L=1|F:fileadmin/pdf/test.pdf" AS _link - # One indirect source reference - SELECT "d:complete.pdf|s|t:Complete PDF|source:centralPdf&pId=1234" AS _link + # One indirect source reference + SELECT "d:complete.pdf|s|t:Complete PDF|source:centralPdf&pId=1234" AS _link - An additional tt-content record is defined with `sub header: centralPdf`. One or multiple attachments might be concatenated. - 10.sql = SELECT '|F:', a.pathFileName FROM Attachments AS a WHERE a.pId={{pId:S}} + An additional tt-content record is defined with `sub header: centralPdf`. One or multiple attachments might be concatenated. + 10.sql = SELECT '|F:', a.pathFileName FROM Attachments AS a WHERE a.pId={{pId:S}} .. Example `_pdf`, `_zip`: :: - # Page 1: p:id=1&--orientation=Landscape&--page-size=A3 - # Page 2: p:id=form - # File 3: F:fileadmin/file.pdf - SELECT 't:PDF|a:Creating a new PDF|p:id=1&--orientation=Landscape&--page-size=A3|p:id=form|F:fileadmin/file.pdf' AS _pdf + # Page 1: p:id=1&--orientation=Landscape&--page-size=A3 + # Page 2: p:id=form + # File 3: F:fileadmin/file.pdf + SELECT 't:PDF|a:Creating a new PDF|p:id=1&--orientation=Landscape&--page-size=A3|p:id=form|F:fileadmin/file.pdf' AS _pdf - # Page 1: p:id=1 - # Page 2: u:http://www.example.com - # File 3: F:fileadmin/file.pdf - SELECT 't:PDF - 3 Files|a:Please be patient|p:id=1|u:http://www.example.com|F:fileadmin/file.pdf' AS _pdf + # Page 1: p:id=1 + # Page 2: u:http://www.example.com + # File 3: F:fileadmin/file.pdf + SELECT 't:PDF - 3 Files|a:Please be patient|p:id=1|u:http://www.example.com|F:fileadmin/file.pdf' AS _pdf - # Page 1: p:id=1 - # Page 2: p:id=form - # File 3: F:fileadmin/file.pdf - SELECT CONCAT('t:ZIP - 3 Pages|a:Please be patient|p:id=1|p:id=form|F:', p.pathFileName) AS _zip + # Page 1: p:id=1 + # Page 2: p:id=form + # File 3: F:fileadmin/file.pdf + SELECT CONCAT('t:ZIP - 3 Pages|a:Please be patient|p:id=1|p:id=form|F:', p.pathFileName) AS _zip .. -Use the `--print-media-type` as wkhtml option to access the page with media type 'printer'. Depending on the website +Use the ``--print-media-type`` as wkhtml option to access the page with media type 'printer'. Depending on the website configuration this switches off navigation and background images. @@ -2521,14 +2691,14 @@ configuration this switches off navigation and background images. Cache ----- -Parameter: `cache[:[timestamp]|[table/id[/column][,...]]` +Parameter: ``cache[:[timestamp]|[table/id[/column][,...]]`` - * Caching will be enabled if the keyword `cache` is given in the download link definition. Example see below. + * Caching will be enabled if the keyword ``cache`` is given in the download link definition. Example see below. * *On the fly* rendered files (like PDF, ZIP, Excel) can be cached on the server (_pdf, _zip, _excel, _file, _savePdf, _saveZip). * Any further access won't trigger a new rendering, instead the already cached file will be delivered. * Cached files will be identified by the md5 sum of their source definition. The md5 name doesn't affect the final *save as filename*. - * Cached files are saved under `fileadmin/protected/cache`. The directory can be configured in the + * Cached files are saved under ``fileadmin/protected/cache``. The directory can be configured in the QFQ extension configuration `File > cacheDirSecure`. See :ref:`qfq.json`. * A cached file becomes outdated (will be rendered and saved again), if: @@ -2538,13 +2708,13 @@ Parameter: `cache[:[timestamp]|[table/id[/column][,...]]` * Any of the direct given timestamps are younger than the cached file modified timestamp. * Any of the given database records returns a younger timestamp than the cached file modified timestamp. - * Optional cache parameter(s) to detect an 'outdated' situtation: + * Optional cache parameter(s) to detect an 'outdated' situation: * Multiple timestamp and/or table/id[/column] definitions are supported. * `timestamp`: format = yyyy-mm-dd [hh[:mm[:ss]]] * `table/id[/column]`: - * Fire query `SELECT <column> FROM <table> WHERE <id>=id`. Compare the result with the cached file modification timestamp. + * Fire query ``SELECT <column> FROM <table> WHERE <id>=id``. Compare the result with the cached file modification timestamp. * Default for `column` is `modified`. * If more complex queries are needed, use `timestamp` instead and precalculate them first. @@ -2557,18 +2727,18 @@ Parameter: `cache[:[timestamp]|[table/id[/column][,...]]` Example:: - # Page 1: p:id=1&--orientation=Landscape&--page-size=A3 - # Page 2: p:id=form&r=234 - # File 3: F:fileadmin/file.pdf + # Page 1: p:id=1&--orientation=Landscape&--page-size=A3 + # Page 2: p:id=form&r=234 + # File 3: F:fileadmin/file.pdf - # This definition flushes the cached file, if file.pdf is younger than the cached file. This is fine if page 1 & 2 never changes. - SELECT 't:PDF|a:Creating a new PDF|p:id=1&--orientation=Landscape&--page-size=A3|p:id=form&r=234|F:fileadmin/file.pdf|cache' AS _pdf + # This definition flushes the cached file, if file.pdf is younger than the cached file. This is fine if page 1 & 2 never changes. + SELECT 't:PDF|a:Creating a new PDF|p:id=1&--orientation=Landscape&--page-size=A3|p:id=form&r=234|F:fileadmin/file.pdf|cache' AS _pdf - # This definition flushes the cached file if file.pdf or content which represent page 1 by table form.id=234 is younger than the cached file. - SELECT 't:PDF|a:Creating a new PDF|p:id=1&--orientation=Landscape&--page-size=A3|p:id=form|F:fileadmin/file.pdf|cache:Form/{{fId:r}}' AS _pdf + # This definition flushes the cached file if file.pdf or content which represent page 1 by table form.id=234 is younger than the cached file. + SELECT 't:PDF|a:Creating a new PDF|p:id=1&--orientation=Landscape&--page-size=A3|p:id=form|F:fileadmin/file.pdf|cache:Form/{{fId:r}}' AS _pdf - # Example with limited use: Cache is skipped until 2022-12-31 23:59:59 or if record in table Form with id=123,column 'modified' younger than cached file. - SELECT 't:PDF - 3 Files|a:Please be patient|p:id=1|u:http://www.example.com|F:fileadmin/file.pdf|cache:2022-12-31 23:59:59,:Form/123' AS _pdf + # Example with limited use: Cache is skipped until 2022-12-31 23:59:59 or if record in table Form with id=123,column 'modified' younger than cached file. + SELECT 't:PDF - 3 Files|a:Please be patient|p:id=1|u:http://www.example.com|F:fileadmin/file.pdf|cache:2022-12-31 23:59:59,:Form/123' AS _pdf Rendering PDF letters ^^^^^^^^^^^^^^^^^^^^^ @@ -2645,7 +2815,7 @@ Best practice: Use in `report`:: - sql = SELECT CONCAT('d:Letter.pdf|t:',p.firstName, ' ', p.name + sql = SELECT CONCAT('d:Letter.pdf|t:',p.firstName, ' ', p.name , '|p:id=letterbody&pId=', p.id, '&_sip=1' , '&--margin-top=50mm' , '&--header-html={{BASE_URL_PRINT:Y}}?id=letterheader' @@ -2660,13 +2830,13 @@ Use in `report`:: Sendmail. Parameter: :: - sendMailAttachment={{SELECT 'd:Letter.pdf|t:', p.firstName, ' ', p.name, '|p:id=letterbody&pId=', p.id, '&_sip=1&--margin-top=50mm&--margin-bottom=20mm&--header-html={{BASE_URL_PRINT:Y}}?id=letterheader&--footer-right="Seite: [page]/[toPage]"&--footer-font-size=8&--footer-spacing=10' FROM Person AS p WHERE p.id={{id:S}} }} + sendMailAttachment={{SELECT 'd:Letter.pdf|t:', p.firstName, ' ', p.name, '|p:id=letterbody&pId=', p.id, '&_sip=1&--margin-top=50mm&--margin-bottom=20mm&--header-html={{BASE_URL_PRINT:Y}}?id=letterheader&--footer-right="Seite: [page]/[toPage]"&--footer-font-size=8&--footer-spacing=10' FROM Person AS p WHERE p.id={{id:S}} }} Replace the static content elements from 2. and 3. by QFQ Content elements as needed:: - 10.sql = SELECT '<div class="letter-receiver"><p>', p.name AS '_+br', p.street AS '_+br', p.city AS '_+br', '</p>' - FROM Person AS p - WHERE p.id={{pId:S}} + 10.sql = SELECT '<div class="letter-receiver"><p>', p.name AS '_+br', p.street AS '_+br', p.city AS '_+br', '</p>' + FROM Person AS p + WHERE p.id={{pId:S}} Export area @@ -2681,13 +2851,13 @@ started as the webserver user). Create a separated export tree in Typo3 Backend, which is IP access restricted. Only localhost or the FE_GROUP 'admin' is allowed to access: :: - tmp.restrictedIPRange = 127.0.0.1,::1 - [IP = {$tmp.restrictedIPRange} ][usergroup = admin] + tmp.restrictedIPRange = 127.0.0.1,::1 + [IP = {$tmp.restrictedIPRange} ][usergroup = admin] page.10 < styles.content.get - [else] + [else] page.10 = TEXT page.10.value = Please access from localhost or log in as 'admin' user. - [global] + [global] .. _excel-export: @@ -2716,7 +2886,7 @@ It's much easier to do all customizations via Excel and creating a template than Setup """"" -* Create a special column name `_excel` (or `_link`) in QFQ/Report. As a source, define a T3 PageContent, which has to +* Create a special column name ``_excel`` (or ``_link``) in QFQ/Report. As a source, define a T3 PageContent, which has to deliver the dynamic content (also :ref:`excel-export-sample<excel-export-sample>`). :: SELECT CONCAT('d:final.xlsx|M:excel|s:1|t:Excel (new)|uid:<tt-content record id>') AS _link @@ -2747,8 +2917,8 @@ Setup +-------------+----------------------+---------------------------------------------------------------------------------------------------+ | 'newline' | newline | Start a new row. The column will be the one of the last 'position' statement. | +-------------+----------------------+---------------------------------------------------------------------------------------------------+ -| 'str', 's' | s=hello world | Set the given string on the given position. The current position will be shift one to the right. | -| | | If the string contains newlines, option'b' (base64) should be used. | +| 'str', 's' | s=hello world | Set the given string on the given position. The current position will be shifted one to the right.| +| | | If the string contains newlines, option 'b' (base64) should be used. | +-------------+----------------------+---------------------------------------------------------------------------------------------------+ | 'b' | b=aGVsbG8gd29ybGQK | Same as 's', but the given string has to Base64 encoded and will be decoded before export. | +-------------+----------------------+---------------------------------------------------------------------------------------------------+ @@ -2845,13 +3015,13 @@ Creates a menu with custom links. The same notation and options are used as with Format String:: - <dropdown menu symbol options>||<menu entry 1>||<menu entry 2>||... + <dropdown menu symbol options>||<menu entry 1>||<menu entry 2>||... Each menu entry is separated by two bars! A menu entry itself might contain multiple single bars. Example 1:: - SELECT 'z||p:home|t:Home|o:Jump to home||p:person&form=person&r=123|t:Edit: John Doe|s' AS _link + SELECT 'z||p:home|t:Home|o:Jump to home||p:person&form=person&r=123|t:Edit: John Doe|s' AS _link This defines a menu (three vertical buttons) - a click on it shows two menu entries: 'Home' and 'Edit: John Doe' @@ -2861,13 +3031,13 @@ Format the dropdown menu symbol: * *Text*: Via ``t:Menu`` an additional text will be displayed for the menu symbol. * *Tooltip*: Via ``o:Detail menu`` a tooltip is defined. * *Render mode*: Via ``r:3`` the menu is disabled. No menu entries / links / sip are rendered. - * *Button*: Via ``b`` the dropdown meny symbol will be rendered with a button. Also `b:<style>` might set the BS color. + * *Button*: Via ``b`` the dropdown many symbols will be rendered with a button. Also `b:<style>` might set the BS color. Format a menu entry: * *qfq link*: All options as with a regular QFQ link. * *header*: If a text starts with '===', it becomes a header in the dropdown menu. Multiple headers are possible. - Headers can't be a link. An additional `r:1` is necessary. + Headers can't be a link. An additional ``r:1`` is necessary. * *separator*: If a text is exactly '---', it becomes a separator line between two menu entries. An additional ``r:1`` is necessary. * *disabled menu entry*: If a text starts with '---' (like separator), the following text becomes a disable menu entry. @@ -2906,7 +3076,7 @@ WebSocket Sending messages via WebSocket and receiving the answer is done via: :: - SELECT 'w:ws://<host>:<port>/<path>|t:<message>' AS _websocket + SELECT 'w:ws://<host>:<port>/<path>|t:<message>' AS _websocket Instead of ``... AS _websocket`` it's also possible to use ``... AS _link`` (same syntax). @@ -2919,7 +3089,7 @@ case 'websocket' or 'link'). Example:: - SELECT 'w:ws://<host>:<port>/<path>|t:<message>' AS '_websocket|_hide' + SELECT 'w:ws://<host>:<port>/<path>|t:<message>' AS '_websocket|_hide' .. tip:: @@ -2927,7 +3097,7 @@ Example:: Example:: - SELECT 'w:ws://<host>:<port>/<path>|t:<message>' AS '_websocket|myName' + SELECT 'w:ws://<host>:<port>/<path>|t:<message>' AS '_websocket|myName' .. tip:: @@ -2935,12 +3105,12 @@ Example:: Example:: - SELECT 'w:ws://<host>:<port>/<path>|t:<message>' AS '_websocket|myName' + SELECT 'w:ws://<host>:<port>/<path>|t:<message>' AS '_websocket|myName' - Results: + Results: - '{{myName:R}}' >> 'w:ws://<host>:<port>/<path>|t:<message>' - '{{&myName:R}}' >> '<received socket answer>' + '{{myName:R}}' >> 'w:ws://<host>:<port>/<path>|t:<message>' + '{{&myName:R}}' >> '<received socket answer>' .. _drag_and_drop: @@ -3034,12 +3204,12 @@ Show / update order value in the browser ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; The 'drag and drop' action does not trigger a reload of the page. In case the order number is shown and the user does -a 'drag and drop', the order number shows the old. To update the dragable elements with the latest order number, a +a 'drag and drop', the order number shows the old. To update the draggable elements with the latest order number, a predefined html id has to be assigned them. After an update, all changed order number (referenced by the html id) will be updated via AJAX. -The html id per element is defined by `qfq-dnd-ord-id-<id>` where `<id>` is the record id. Same example as above, but -with an updated `n.ord` column:: +The html id per element is defined by ``qfq-dnd-ord-id-<id>`` where ``<id>`` is the record id. Same example as above, but +with an updated ``n.ord`` column:: 10 { sql = SELECT '<tr id="anytag-', n.id,'" data-dnd-id="', n.id,'" data-columns="3">' , n.id AS '_+td', n.note AS '_+td', @@ -3060,7 +3230,7 @@ A dedicated `Form`, without any `FormElements`, is used to define the reorder lo Fields: * Name: <custom form name> - used in Part 1 in the ``_data-dnd-api`` variable. -* Table: <table with the element records> - used to update the records specified by `dragAndDropOrderSql`. +* Table: <table with the element records> - used to update the records specified by ``dragAndDropOrderSql``. * Parameter: @@ -3073,27 +3243,27 @@ Fields: +-------------------------------------------------------+--------------------------------------------------------------+ | dragAndDropOrderSql = | Query to selects the *same* records as the report in the | | {{!SELECT n.id AS id, n.ord AS ord FROM Note AS n | same *order!* Inconsistencies results in order differences. | -| ORDER BY n.ord}} | The columns `id` and `ord` are *mandatory.* | +| ORDER BY n.ord}} | The columns ``id`` and ``ord`` are *mandatory.* | +-------------------------------------------------------+--------------------------------------------------------------+ The form related to the example of part 1 ('div' or 'table'): :: - Form.name: dndSortNote - Form.table: Note - Form.parameter: orderInterval = 1 - Form.parameter: orderColumn = ord - Form.parameter: dragAndDropOrderSql = {{!SELECT n.id AS id, n.ord AS ord FROM Note AS n WHERE n.grId={{grId:S0}} ORDER BY n.ord}} + Form.name: dndSortNote + Form.table: Note + Form.parameter: orderInterval = 1 + Form.parameter: orderColumn = ord + Form.parameter: dragAndDropOrderSql = {{!SELECT n.id AS id, n.ord AS ord FROM Note AS n WHERE n.grId={{grId:S0}} ORDER BY n.ord}} Re-Order: -QFQ iterates over the result set of `dragAndDropOrderSql`. The value of column `id` have to correspond to the dragged HTML - element (given by `data-dnd-id`). Reordering always start with `orderInterval` and is incremented by `orderInterval` with each +QFQ iterates over the result set of ``dragAndDropOrderSql``. The value of column ``id`` have to correspond to the dragged HTML + element (given by ``data-dnd-id``). Reordering always start with ``orderInterval`` and is incremented by ``orderInterval`` with each record of the result set. The client reports a) the id of the dragged HTML element, b) the id of the hovered element and c) the dropped position of above or below the hovered element. This information is compared to the result set and changes are applied where appropriate. Take care that the query of part 1 (display list) does a) select the same records and b) in the same order as the query - defined in part 2 (order records) via `dragAndDropOrderSql`. + defined in part 2 (order records) via ``dragAndDropOrderSql``. If you find that the reorder does not work at expected, those two sql queries are not identical. @@ -3388,26 +3558,26 @@ Located under ``typo3conf/ext/qfq/Resources/Public/icons`` QFQ CSS Classes --------------- -* `qfq-table-50`, `qfq-table-80`, `qfq-table-100` - assigned to `<table>`, set min-width and column width to 'auto'. -* Background Color: `qfq-color-grey-1`, `qfq-color-grey-2` - assigned to different tags (table, row, cell). -* `qfq-100` - assigned to different tags, makes an element 'width: 100%'. -* `qfq-left`- assigned to different tags, Text align left. -* `qfq-sticky` - assigned to `<thead>`, makes the header sticky. -* `letter-no-break` - assigned to a `div` will protect a paragraph (CSS: page-break-before: avoid;) not to break around - a page border (converted to PDF via wkhtml). Take care that `qfq-letter.css` is included in TypoScript setup. -* `qfq-badge`, `qfq-badge-error`, `qfq-badge-warning`, `qfq-badge-success`, `qfq-badge-info`, `qfq-badge-invers` - +* ``qfq-table-50``, ``qfq-table-80``, ``qfq-table-100`` - assigned to ``<table>``, set min-width and column width to 'auto'. +* Background Color: ``qfq-color-grey-1``, ``qfq-color-grey-2`` - assigned to different tags (table, row, cell). +* ``qfq-100`` - assigned to different tags, makes an element 'width: 100%'. +* ``qfq-left``- assigned to different tags, Text align left. +* ``qfq-sticky`` - assigned to ``<thead>``, makes the header sticky. +* ``letter-no-break`` - assigned to a ``div`` will protect a paragraph (CSS: page-break-before: avoid;) not to break around + a page border (converted to PDF via wkhtml). Take care that ``qfq-letter.css`` is included in TypoScript setup. +* ``qfq-badge``, ``qfq-badge-error``, ``qfq-badge-warning``, ``qfq-badge-success``, ``qfq-badge-info``, ``qfq-badge-invers`` - colorized BS3 badges:: - <span class="badge">classic</span> - <span class="qfq-badge qfq-badge-success">qfq-badge-success</span> - <span class="qfq-badge qfq-badge-warning">qfq-badge-warning</span> - <span class="qfq-badge qfq-badge-error">qfq-badge-error</span> - <span class="qfq-badge qfq-badge-info">qfq-badge-info</span> - <span class="qfq-badge qfq-badge-inverse">qfq-badge-inverse</span> + <span class="badge">classic</span> + <span class="qfq-badge qfq-badge-success">qfq-badge-success</span> + <span class="qfq-badge qfq-badge-warning">qfq-badge-warning</span> + <span class="qfq-badge qfq-badge-error">qfq-badge-error</span> + <span class="qfq-badge qfq-badge-info">qfq-badge-info</span> + <span class="qfq-badge qfq-badge-inverse">qfq-badge-inverse</span> .. image:: ./Images/QfqCssBadge.png -* `btn-tiny`, `btn-small` - add to `'...|b:btn-info btn-small|t:..' AS link` to render button in small or tiny size. +* ``btn-tiny``, ``btn-small`` - add to ``'...|b:btn-info btn-small|t:..' AS link`` to render button in small or tiny size. .. image:: ./Images/BtnTinySmall.png @@ -3416,20 +3586,20 @@ QFQ CSS Classes Table: vertical text via CSS ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Use class `vertical` and `qfq-vertical-text`. Example:: +Use class ``vertical`` and ``qfq-vertical-text``. Example:: - <table> - <tr> - <th class="qfq-vertical"><span class="qfq-vertical-text">Column 1</span></th> - <th class="qfq-vertical"><span class="qfq-vertical-text">2</span></th> - <th class="qfq-vertical"><span class="qfq-vertical-text">Very long column title</span></th> - <th class="qfq-vertical"><span class="qfq-vertical-text">4</span></th> - <th class="qfq-vertical"><span class="qfq-vertical-text">5</span></th> - </tr> - <tr><td>1</td><td>2</td><td>3</td><td>very wide text</td><td>5</td></tr> - </table> + <table> + <tr> + <th class="qfq-vertical"><span class="qfq-vertical-text">Column 1</span></th> + <th class="qfq-vertical"><span class="qfq-vertical-text">2</span></th> + <th class="qfq-vertical"><span class="qfq-vertical-text">Very long column title</span></th> + <th class="qfq-vertical"><span class="qfq-vertical-text">4</span></th> + <th class="qfq-vertical"><span class="qfq-vertical-text">5</span></th> + </tr> + <tr><td>1</td><td>2</td><td>3</td><td>very wide text</td><td>5</td></tr> + </table> -Same effect is also possible via special column name `_vertical` (ref:`special-column-names`). +Same effect is also possible via special column name ``_vertical`` (ref:`special-column-names`). .. _bootstrap: @@ -3450,10 +3620,10 @@ Bootstrap Example:: - 10.sql = SELECT id, name, firstName, ... - 10.head = <table class='table table-condensed qfq-table-50'> + 10.sql = SELECT id, name, firstName, ... + 10.head = <table class='table table-condensed qfq-table-50'> -* `qfq-100`, `qfq-left` - makes e.g. a button full width and aligns the text left. +* ``qfq-100``, ``qfq-left`` - makes e.g. a button full width and aligns the text left. Example:: @@ -3473,48 +3643,48 @@ Make a table sortable and/or filterable: Example:: - 10 { - sql = SELECT p.name, p.firstName, p.id, FROM Person AS p - head = <table class="tablesorter tablesorter-filter tablesorter-pager tablesorter-column-selector" id="demoTable"> - <thead><tr> - <th>Name</th><th>First name</th><th class="filter-false sorter-false">ID</th> - </tr></thead> - tail = </table> - } + 10 { + sql = SELECT p.name, p.firstName, p.id, FROM Person AS p + head = <table class="tablesorter tablesorter-filter tablesorter-pager tablesorter-column-selector" id="demoTable"> + <thead><tr> + <th>Name</th><th>First name</th><th class="filter-false sorter-false">ID</th> + </tr></thead> + tail = </table> + } .. important:: Custom settings will be saved per table automatically in the browser local storage. To distinguish different table settings, define an uniq HTML id per table. - Example: `<table class="tablesorter" id="{{pageSlug:T}}-person">` - the `{{pageSlug:T}}` makes it easy to keep the + Example: ``<table class="tablesorter" id="{{pageSlug:T}}-person">`` - the ``{{pageSlug:T}}`` makes it easy to keep the overview over given name on the site. The *tablesorter* options: -* Class `tablesorter-filter` enables row filtering. -* Class `tablesorter-pager` adds table paging functionality. A page navigation +* Class ``tablesorter-filter`` enables row filtering. +* Class ``tablesorter-pager`` adds table paging functionality. A page navigation is shown. -* Class `tablesorter-column-selector` adds a column selector widget. +* Class ``tablesorter-column-selector`` adds a column selector widget. .. _tablesorter-view-saver: Tablesorter View Saver ^^^^^^^^^^^^^^^^^^^^^^ -* Tablesorter view saver: inside of a HTML `table`-tag the command:: +* Tablesorter view saver: inside of a HTML ``table``-tag the command:: {{ '<uniqueName>' AS _tablesorter-view-saver }} This adds a menu to save the current view (column filters, selected columns, sort order). - * `<uniqueName>` should be a name which is unique. Example:: + * ``<uniqueName>`` should be a name which is unique. Example:: <table {{ 'allperson' AS _tablesorter-view-saver }} class="tablesorter tablesorter-filter tablesorter-column-selector" id="{{pageSlug:T}}-example"> ... </table> .. important:: - Always speciy a unique (over your whole T3 installation) HTML ID (`id="{{pageSlug:T}}-example">`). On page load, this + Always specify a unique (over your whole T3 installation) HTML ID (``id="{{pageSlug:T}}-example">``). On page load, this reference will be used to load the last used settings again. If not specified, and if there are at least two tablesorter without an HTML ID, those will be mixed and might confuse the whole tablesorter. @@ -3522,25 +3692,25 @@ Tablesorter View Saver * 'Views' can be saved as: - * group: every user will see the `view` and can modify it. - * personal: only the user who created the `view` will see/modify it. - * readonly: manually mark a `view` as readonly (no FE User can change it) by setting column `readonly='true'` in table - `Setting` of the corresponding view (identified by `name`). + * group: every user will see the ``view`` and can modify it. + * personal: only the user who created the ``view`` will see/modify it. + * readonly: manually mark a ``view`` as readonly (no FE User can change it) by setting column ``readonly='true'`` in table + ``Setting`` of the corresponding view (identified by ``name``). * Views will be saved in the QFQ system DB table 'Setting'. * Every setting is saved with the T3 FE username. If there is no T3 FE username, the current QFQ cookie is used instead. - * Include 'font-awesome' CSS in your T3 page setup: `typo3conf/ext/qfq/Resources/Public/Css/font-awesome.min.css` to get the icons. + * Include 'font-awesome' CSS in your T3 page setup: ``typo3conf/ext/qfq/Resources/Public/Css/font-awesome.min.css`` to get the icons. * The view 'Clear' is always available and can't be modified. * To preselect a view, append a HTML anker to the current URL. Get the anker by selecting the view and copy it from the browser address bar. Example:: https://localhost/index.php?id=person#allperson=public:email - * 'allperson' is the '<uniqueName>' of the `tablesorter-view-saver` command. + * 'allperson' is the '<uniqueName>' of the ``tablesorter-view-saver`` command. * 'public' means the view is tagged as 'public' visible. * 'email' is the name of the view, as it is shown in the dropdown list. - * If there is a public view with the name 'Default' and a user has no choosen a view earlier, that one will be selected. + * If there is a public view with the name 'Default' and a user has not chosen a view earlier, that one will be selected. .. _tablesorter-export-csv: @@ -3553,8 +3723,8 @@ Tablesorter CSV Export $('table.tablesorter').trigger('outputTable'); - * Default export file name: `tableExport.csv` - * Exported with column separator `;` + * Default export file name: ``tableExport.csv`` + * Exported with column separator ``;`` * Only currently filtered rows are exported. * Values are exported as text, without HTML tags * You can change the formatting/value of each cell as follows:: @@ -3589,14 +3759,14 @@ Customization of tablesorter +-----------------------------+----------------------------------------------------------------------------------------+ - * You can pass in a default configuration object for the main `tablesorter()` function by using the attribute - `data-tablesorter-config` on the table. + * You can pass in a default configuration object for the main ``tablesorter()`` function by using the attribute + ``data-tablesorter-config`` on the table. Use JSON syntax when passing in your own configuration, such as: :: data-tablesorter-config='{"theme":"bootstrap","widthFixed":true,"headerTemplate":"{content} {icon}","dateFormat":"ddmmyyyy","widgets":["uitheme","filter","saveSort","columnSelector","output"],"widgetOptions":{"filter_columnFilters":true,"filter_reset":".reset","filter_cssFilter":"form-control","columnSelector_mediaquery":false,"output_delivery":"download","output_saveFileName":"tableExport.csv","output_separator":";"} }' * If the above customization options are not enough, you can output your own HTML for the pager and/or column selector, - as well as your own `$(document).ready()` function with the desired config. In this case, it is recommended not to + as well as your own ``$(document).ready()`` function with the desired config. In this case, it is recommended not to use the above *tablesorter* classes since the QFQ javascript code could interfere with your javascript code. Example:: @@ -3650,83 +3820,88 @@ Include the JS & CSS files via Typoscript * typo3conf/ext/qfq/Resources/Public/JavaScript/moment.min.js * typo3conf/ext/qfq/Resources/Public/JavaScript/fullcalendar.min.js -Integration: Create a `<div>` with +Integration: Create a ``<div>`` with * CSS class "qfq-calendar" -* Tag `data-config`. The content is a Javascript object. +* Tag ``data-config``. The content is a Javascript object. Example:: - 10.sql = SELECT 'Calendar, Standard' - 10.tail = <div class="qfq-calendar" - data-config='{ - "themeSystem": "bootstrap3", - "height": "auto", - "defaultDate": "2020-01-13", - "weekends": false, - "defaultView": "agendaWeek", - "minTime": "05:00:00", - "maxTime": "20:00:00", - "businessHours": { "dow": [ 1, 2, 3, 4 ], "startTime": "10:00", "endTime": "18:00" }, - "events": [ - { "id": "a", "title": "my event", - "start": "2020-01-21"}, - { "id": "b", "title": "my other event", "start": "2020-01-16T09:00:00", "end": "2020-01-16T11:30:00"} - ]}'> - </div> - - # "now" is in the past to switchoff 'highlight of today' - 20.sql = SELECT 'Calendar, 3 day, custom color, agend&list' AS '_+h2' - 20.tail = <div class="qfq-calendar" - data-config='{ - "themeSystem": "bootstrap3", - "height": "auto", - "header": { - "left": "title", - "center": "", - "right": "agenda,listWeek" - }, - "defaultDate": "2020-01-14", - "now": "1999-12-31", - "allDaySlot": false, - "weekends": false, - "defaultView": "agenda", - "dayCount": 3, - "minTime": "08:00:00", - "maxTime": "18:00:00", - "businessHours": { "dow": [ 1, 2, 3, 4 ], "startTime": "10:00", "endTime": "18:00" }, - "events": [ - { "id": "a", "title": "my event", "start": "2020-01-15T10:15:00", "end": "2020-01-15T11:50:00", "color": "#25adf1", "textColor": "#000"}, - { "id": "b", "title": "my other event", "start": "2020-01-16T09:00:00", "end": "2020-01-16T11:30:00", "color": "#5cb85c", "textColor": "#000"}, - { "id": "c", "title": "Eventli", "start": "2020-01-15T13:10:00", "end": "2020-01-15T16:30:00", "color": "#fbb64f", "textColor": "#000"}, - { "id": "d", "title": "Evento", "start": "2020-01-15T13:50:00", "end": "2020-01-15T15:00:00", "color": "#fb4f4f", "textColor": "#000"}, - { "id": "d", "title": "Busy", "start": "2020-01-14T09:00:00", "end": "2020-01-14T12:00:00", "color": "#ccc", "textColor": "#000"}, - { "id": "e", "title": "Banana", "start": "2020-01-16T13:30:00", "end": "2020-01-16T16:00:00", "color": "#fff45b", "textColor": "#000"} - ]}'> - </div> + 10.sql = SELECT 'Calendar, Standard' + 10.tail = <div class="qfq-calendar" + data-config='{ + "themeSystem": "bootstrap3", + "height": "auto", + "defaultDate": "2020-01-13", + "weekends": false, + "defaultView": "agendaWeek", + "minTime": "05:00:00", + "maxTime": "20:00:00", + "businessHours": { "dow": [ 1, 2, 3, 4 ], "startTime": "10:00", "endTime": "18:00" }, + "events": [ + { "id": "a", "title": "my event", + "start": "2020-01-21"}, + { "id": "b", "title": "my other event", "start": "2020-01-16T09:00:00", "end": "2020-01-16T11:30:00"} + ]}'> + </div> + + # "now" is in the past to switchoff 'highlight of today' + 20.sql = SELECT 'Calendar, 3 day, custom color, agend&list' AS '_+h2' + 20.tail = <div class="qfq-calendar" + data-config='{ + "themeSystem": "bootstrap3", + "height": "auto", + "header": { + "left": "title", + "center": "", + "right": "agenda,listWeek" + }, + "defaultDate": "2020-01-14", + "now": "1999-12-31", + "allDaySlot": false, + "weekends": false, + "defaultView": "agenda", + "dayCount": 3, + "minTime": "08:00:00", + "maxTime": "18:00:00", + "businessHours": { "dow": [ 1, 2, 3, 4 ], "startTime": "10:00", "endTime": "18:00" }, + "events": [ + { "id": "a", "title": "my event", "start": "2020-01-15T10:15:00", "end": "2020-01-15T11:50:00", "color": "#25adf1", "textColor": "#000"}, + { "id": "b", "title": "my other event", "start": "2020-01-16T09:00:00", "end": "2020-01-16T11:30:00", "color": "#5cb85c", "textColor": "#000"}, + { "id": "c", "title": "Eventli", "start": "2020-01-15T13:10:00", "end": "2020-01-15T16:30:00", "color": "#fbb64f", "textColor": "#000"}, + { "id": "d", "title": "Evento", "start": "2020-01-15T13:50:00", "end": "2020-01-15T15:00:00", "color": "#fb4f4f", "textColor": "#000"}, + { "id": "d", "title": "Busy", "start": "2020-01-14T09:00:00", "end": "2020-01-14T12:00:00", "color": "#ccc", "textColor": "#000"}, + { "id": "e", "title": "Banana", "start": "2020-01-16T13:30:00", "end": "2020-01-16T16:00:00", "color": "#fff45b", "textColor": "#000"} + ]}'> + </div> .. _reportAsFile: Report As File -------------- -* If the toplevel token `file` is present inside the body of a QFQ tt-content element then the given report file is loaded and rendered. +* If the toplevel token ``file`` is present inside the body of a QFQ tt-content element then the given report file is + loaded and rendered. * The tt-content body is ignored in that case. * The path to the report file must be given relative to the report directory inside the qfq project directory. See :ref:`qfq-project-path-php` - * QFQ provides some special system reports which are located inside the extension directory `typo3conf/ext/qfq/Resources/Private/Report` and can be directly rendered by prepending an underscore and omitting the file extension: + * QFQ provides some special system reports which are located inside the extension directory `typo3conf/ext/qfq/Resources/Private/Report` + and can be directly rendered by prepending an underscore and omitting the file extension: - * `file=_formEditor` will render the standard formEditor report. Check also :ref:`form-editor`. - * `file=_searchRefactor` will render the standard searchRefactor report. + * ``file=_formEditor`` will render the standard formEditor report. Check also :ref:`form-editor`. + * ``file=_searchRefactor`` will render the standard searchRefactor report. -* If the QFQ setting `reportAsFileAutoExport` (see :ref:`extension-manager-qfq-configuration`) is enabled, then every QFQ tt-content element which does not contain the `file` keyword is exported automatically when the report is rendered the first time. +* If the QFQ setting ``reportAsFileAutoExport`` (see :ref:`extension-manager-qfq-configuration`) is enabled, then every + QFQ tt-content element which does not contain the ``file`` keyword is exported automatically when the report is rendered + the first time. * The path of the created file is given by the typo3 page structure - * The tt-content element body is replaced with `file=<path-to-new-file>` + * The tt-content element body is replaced with ``file=<path-to-new-file>`` -* **Backups** : Whenever a report file is edited via the frontend report editor then a backup of the previous version is saved in the `.backup` directory located in the same directory as the report file. +* **Backups** : Whenever a report file is edited via the frontend report editor then a backup of the previous version is + saved in the ``.backup`` directory located in the same directory as the report file. Example tt-content body:: @@ -3950,90 +4125,90 @@ Two queries: nested with hidden variables in a table:: Same as above, but written in the nested notation:: - 10 { - sql = SELECT p.id AS _pId, p.name FROM ExpPerson AS p - rend = <br> - 10 { - # inner query - sql = SELECT a.street FROM ExpAddress AS a WHERE a.pId='{{10.pId}}' + sql = SELECT p.id AS _pId, p.name FROM ExpPerson AS p rend = <br> + + 10 { + # inner query + sql = SELECT a.street FROM ExpAddress AS a WHERE a.pId='{{10.pId}}' + rend = <br> + } } - } Best practice *recommendation* for using parameter - see :ref:`access-column-values`:: - 10 { - sql = SELECT p.id AS _pId, p.name FROM ExpPerson AS p - rend = <br> - 10 { - # inner query - sql = SELECT a.street FROM ExpAddress AS a WHERE a.pId='{{pId:R}}' + sql = SELECT p.id AS _pId, p.name FROM ExpPerson AS p rend = <br> + + 10 { + # inner query + sql = SELECT a.street FROM ExpAddress AS a WHERE a.pId='{{pId:R}}' + rend = <br> + } } - } Create HTML tables. Each column is wrapped in ``<td>``, each row is wrapped in ``<tr>``:: - 10 { - sql = SELECT p.firstName, p.lastName, p.country FROM Person AS p - head = <table class="table"> - tail = </table> - rbeg = <tr> - rend = </tr> - fbeg = <td> - fend = </td> - } + 10 { + sql = SELECT p.firstName, p.lastName, p.country FROM Person AS p + head = <table class="table"> + tail = </table> + rbeg = <tr> + rend = </tr> + fbeg = <td> + fend = </td> + } Maybe a few columns belongs together and should be in one table column. Joining columns, variant A: firstName and lastName in one table column:: - 10 { - sql = SELECT CONCAT(p.firstName, ' ', p.lastName), p.country FROM Person AS p - head = <table class="table"> - tail = </table> - rbeg = <tr> - rend = </tr> - fbeg = <td> - fend = </td> - } + 10 { + sql = SELECT CONCAT(p.firstName, ' ', p.lastName), p.country FROM Person AS p + head = <table class="table"> + tail = </table> + rbeg = <tr> + rend = </tr> + fbeg = <td> + fend = </td> + } Joining columns, variant B: firstName and lastName in one table column:: - 10 { - sql = SELECT '<td>', p.firstName, ' ', p.lastName, '</td><td>', p.country, '</td>' FROM Person AS p - head = <table class="table"> - tail = </table> - rbeg = <tr> - rend = </tr> - } + 10 { + sql = SELECT '<td>', p.firstName, ' ', p.lastName, '</td><td>', p.country, '</td>' FROM Person AS p + head = <table class="table"> + tail = </table> + rbeg = <tr> + rend = </tr> + } Joining columns, variant C: firstName and lastName in one table column. Notice ``fbeg``, ``fend` and ``fskipwrap``:: - 10 { - sql = SELECT '<td>', p.firstName, ' ', p.lastName, '</td>', p.country FROM Person AS p - head = <table class="table"> - tail = </table> - rbeg = <tr> - rend = </tr> - fbeg = <td> - fend = </td> - fskipwrap = 1,2,3,4,5 - } + 10 { + sql = SELECT '<td>', p.firstName, ' ', p.lastName, '</td>', p.country FROM Person AS p + head = <table class="table"> + tail = </table> + rbeg = <tr> + rend = </tr> + fbeg = <td> + fend = </td> + fskipwrap = 1,2,3,4,5 + } Joining columns, variant D: firstName and lastName in one table column. Notice ``fbeg``, ``fend` and ``fskipwrap``:: - 10 { - sql = SELECT CONCAT('<td>', p.firstName, ' ', p.lastName, '</td>') AS '_noWrap', p.country FROM Person AS p - head = <table class="table"> - tail = </table> - rbeg = <tr> - rend = </tr> - fbeg = <td> - fend = </td> - } + 10 { + sql = SELECT CONCAT('<td>', p.firstName, ' ', p.lastName, '</td>') AS '_noWrap', p.country FROM Person AS p + head = <table class="table"> + tail = </table> + rbeg = <tr> + rend = </tr> + fbeg = <td> + fend = </td> + } Recent List ^^^^^^^^^^^ @@ -4041,47 +4216,47 @@ Recent List A nice feature is to show a list with last changed records. The following will show the 10 last modified (Form or FormElement) forms:: - 10 { - sql = SELECT CONCAT('p:{{pageSlug:T}}?form=form&r=', f.id, '|t:', f.name,'|o:', GREATEST(MAX(fe.modified), f.modified)) AS _page - FROM Form AS f - LEFT JOIN FormElement AS fe - ON fe.formId = f.id - GROUP BY f.id - ORDER BY GREATEST(MAX(fe.modified), f.modified) DESC - LIMIT 10 - head = <h3>Recent Forms</h3> - rsep = ,  - } + 10 { + sql = SELECT CONCAT('p:{{pageSlug:T}}?form=form&r=', f.id, '|t:', f.name,'|o:', GREATEST(MAX(fe.modified), f.modified)) AS _page + FROM Form AS f + LEFT JOIN FormElement AS fe + ON fe.formId = f.id + GROUP BY f.id + ORDER BY GREATEST(MAX(fe.modified), f.modified) DESC + LIMIT 10 + head = <h3>Recent Forms</h3> + rsep = ,  + } .. _`vertical-column-title`: Table: vertical column title ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -To orientate a column title vertical, use the QFQ CSS classe `qfq-vertical` in td|th and `qfq-vertical-text` around the text. +To orientate a column title vertical, use the QFQ CSS class ``qfq-vertical`` in td|th and ``qfq-vertical-text`` around the text. HTML example (second column title is vertical):: - <table><thead> - <tr> - <th>horizontal</th> - <th class="qfq-vertical"><span class="qfq-vertical-text">text vertical</span></th> - </tr> - </thead></table> + <table><thead> + <tr> + <th>horizontal</th> + <th class="qfq-vertical"><span class="qfq-vertical-text">text vertical</span></th> + </tr> + </thead></table> QFQ example:: - 10 { - sql = SELECT title FROM Settings ORDER BY title - fbeg = <th class="qfq-vertical"><span class="qfq-vertical-text"> - fend = </span></th> - head = <table><thead><tr> - rend = </tr></thead> - tail = </table> - - 20.sql = SELECT ... - } + 10 { + sql = SELECT title FROM Settings ORDER BY title + fbeg = <th class="qfq-vertical"><span class="qfq-vertical-text"> + fend = </span></th> + head = <table><thead><tr> + rend = </tr></thead> + tail = </table> + + 20.sql = SELECT ... + } .. _`store_user_examples`: @@ -4131,7 +4306,7 @@ Simulate/switch user: feUser Just set the STORE_USER variable 'feUser'. -All places with `{{feUser:T}}` has to be replaced by `{{feUser:UT}}`:: +All places with ``{{feUser:T}}`` has to be replaced by ``{{feUser:UT}}``:: # Normalize 10.sql = SELECT '{{feUser:UT}}' AS '_=feUser' @@ -4145,7 +4320,7 @@ Semester switch (remember last choice) """""""""""""""""""""""""""""""""""""" A current semester is defined via configuration in STORE_SYSTEM '{{semId:Y}}'. The first column in 10.sql -`'{{semId:SUY}}' AS '_=semId'` saves +``'{{semId:SUY}}' AS '_=semId'`` saves the semester to STORE_USER via '_=semId'. The priority 'SUY' takes either the latest choose (STORE_SIP) or reuse the last used (STORE_USER) or (first time call during browser session) takes the default from config (STORE_SYSTEM):: diff --git a/Documentation/Settings.cfg b/Documentation/Settings.cfg index a3e3750c6f9533df3bb9b016a11f84b6e65363a2..b048b0981e9ad6f184c88bda9afb10ee6834ce44 100644 --- a/Documentation/Settings.cfg +++ b/Documentation/Settings.cfg @@ -21,8 +21,8 @@ ; you can use in 'conf.py' project = QFQ - Quick Form Query -version = 23.6 -release = 23.6.4 +version = 23.10 +release = 23.10.1 t3author = Carsten Rose copyright = since 2017 by the author diff --git a/Documentation/Store.rst b/Documentation/Store.rst index 80f573e8d147f366c655edc41315dd2c7fc2d602..20ad26fded14b13d8f31fca4e311c7b134faaa1f 100644 --- a/Documentation/Store.rst +++ b/Documentation/Store.rst @@ -286,10 +286,9 @@ QFQ values +-------------------+-------------+--------------------------------------------------------------------------------------------------------------------------------------------+ | Name | Scope | Explanation | +===================+=============+============================================================================================================================================+ -| random | Always | Random string with length of 32 alphanum chars (lower & upper case). This variable is always filled. Each access gives a new value. | -| | | Remember: QFQ variables will be replaced **before** a SQL statement is fired. Uniqueness is given via PHP function *uniqid()*. | +| random | Always | Random string with length of 32 alphanum chars (lower & upper case). See :ref:`RANDOM`. | +-------------------+-------------+--------------------------------------------------------------------------------------------------------------------------------------------+ -| slaveId | FE *any* | see :ref:`slave-id` | +| slaveId | FE *any* | See :ref:`slave-id` | +-------------------+-------------+--------------------------------------------------------------------------------------------------------------------------------------------+ | allRequiredGiven | Form | Form save - Set during check of all required FE. If every *required* FE is given: *1*. Else: *0*. If there is no required FE: *1*. | | | | Even with ``formModeGlobal = requiredOff | requiredOffButMark`` the variable will be set. | @@ -309,6 +308,26 @@ QFQ values | mimeType | FE *upload* | Mime type of the uploaded file. | +-------------------+-------------+--------------------------------------------------------------------------------------------------------------------------------------------+ +.. _RANDOM: + +{{random:V}} +^^^^^^^^^^^^ + +* Random string with length of 32 alphanum chars (lower & upper case). +* The variable *{{random:V}}* is always filled. +* Hint: QFQ variables will be replaced **before** a SQL statement is fired. + + * Each access gives a new value. + * Execution of a SQL statement is **one** access - therefore ``SELECT '{{random:V}}' FROM Person`` will give the same + random value for all *Person*. + * Several ``SELECT ...{{random:V}}...`` will give several different random values. + +* Uniqueness is added via PHP function *uniqid()* + + * This just prepends 13 chars at the beginning of the random string. + * *uniqid()* is only a very precise timestamp - timestamp means it becomes bigger and bigger and the first characters stays the same. + * These first 13 chars should *not* be taken as random! They will make *uniqness* for *{{random:V}}* more likely. + Custom values (via fillStoreVar) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/Documentation/Variable.rst b/Documentation/Variable.rst index 0f58273e66717d055883eadf79cf6d96584f632f..423adc417caa87f3d9ed69cced48d99ea7635824 100644 --- a/Documentation/Variable.rst +++ b/Documentation/Variable.rst @@ -44,7 +44,7 @@ provided. Access to: * :ref:`store-variables` * :ref:`sql-variables` * :ref:`row-column-variables` -* :ref:`link-column-variables` +* :ref:`link-function-column-variables` Some examples, including nesting:: @@ -72,6 +72,12 @@ Some examples, including nesting:: # Link Columns {{p:form=Person&r=1|t:Edit Person|E|s AS link}} + # Function Columns: output in {{fullname:R}} + {{getFullname(pId) => fullname AS function}} + + # Function Columns: output direct tt_content + {{getFullname(pId) AS function}} + Leading and trailing spaces inside curly braces are removed. * ``{{ SELECT "Hello World" }}`` becomes ``{{SELECT "Hello World"}}`` @@ -404,11 +410,11 @@ Example:: Row column variables -------------------- -Syntax: *{{<level>.<column>}}* +Syntax: *{{<level>.<column>}}* or *{{<alias>.<column>}}* Only used in report to access outer columns. See :ref:`access-column-values` and :ref:`syntax-of-report`. -There might be name conflicts between VarName / SQL keywords and <line identifier>. QFQ checks first for *<level>*, +There might be name conflicts between VarName / SQL keywords and <line identifier>. QFQ checks first for *<level>* and *<alias>*, than for *SQL keywords* and than for *VarNames* in stores. All types might be nested with each other. There is no limit of nesting variables. @@ -421,21 +427,42 @@ General note: using this type of variables is only the second choice. First choi :ref:`access-column-values`) - using the STORE_RECORD is more portable cause no renumbering is needed if the level keys change. -.. _`link-column-variables`: +.. _`link-function-column-variables`: -Link column variables ---------------------- +Link/Function column variables +------------------------------ + +Link column +^^^^^^^^^^^ These variables return a link, completely rendered in HTML. The syntax and all features of :ref:`column-link` are available. The following code will render a *new person* button:: {{p:form&form=Person|s|N|t:new person AS link}} -For better reading, the format string might be wrapped in single or double quotes (this is optional): :: +Optional: For better reading, the format string might be wrapped in single or double quotes: :: {{"p:form&form=Person|s|N|t:new person" AS link}} -These variables are especially helpful in: + +Function column +^^^^^^^^^^^^^^^ + +Function column variables are helpful in: * `report`, to create create links or buttons outside of an SQL statement. E.g. in `head`, `rbeg`, ... * `form`, to create links and buttons in labels or notes. + +Definition of a qfqFunction: Report with qfqFunction. Subheader: getFullname:: + + render = api + 10.sql = SELECT CONCAT(lastName, ', ', firstName) FROM Person WHERE id = {{pId:R0}} + +a) Somewhere in a different report or form:: + + {{getFullname(pId) AS function}} + +b) Somewhere in a different report, output is returned in STORE_RECORD variable `fullname`:: + + {{getFullname(pId) => fullname AS function}} + diff --git a/Documentation/conf.py b/Documentation/conf.py index c94be1264b9461cc76abccedf4afd24b03b50adb..e4e23b99d032572ae41843981085b1d9459848a6 100644 --- a/Documentation/conf.py +++ b/Documentation/conf.py @@ -55,16 +55,16 @@ master_doc = 'index' # General information about the project. project = 'QFQ' copyright = '2023' -author = 'Carsten Rose, Benjamin Baer' +author = 'Carsten Rose, Benjamin Baer, Enis Nuredini, Jan Haller' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '23.6' +version = '23.10' # The full version, including alpha/beta/rc tags. -release = '23.6.4' +release = '23.10.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/Documentation/docker-sphinx-qfq/requirements.txt b/Documentation/docker-sphinx-qfq/requirements.txt index d29a608226d9d512b1c0439e325079ec9f67b35f..8cc2b116aea948b478ba68182a97d6262e3cf87f 100644 --- a/Documentation/docker-sphinx-qfq/requirements.txt +++ b/Documentation/docker-sphinx-qfq/requirements.txt @@ -1,2 +1,2 @@ -sphinx-rtd-theme==0.4.3 +sphinx-rtd-theme git+https://github.com/readthedocs/readthedocs-sphinx-search@main diff --git a/Documentation/index.rst b/Documentation/index.rst index d847d29a260e0cc7188c53195b2d16bea97caca4..a6b39fbb3d2ad30da4cd52a25381e19ed07aebf2 100644 --- a/Documentation/index.rst +++ b/Documentation/index.rst @@ -25,13 +25,13 @@ Quick Form Query Extension 2017-2023 :Authors: - Carsten Rose, Benjamin Baer, Enis Nuredini + Carsten Rose, Benjamin Baer, Enis Nuredini, Jan Haller :Further Contributors: Nicola Chiapolini, Marc Egger, Rafael Ostertag, Elias Villiger, Jan Haller :Email: - carsten.rose@math.uzh.ch, benjamin.baer@math.uzh.ch, enis.nuredini@math.uzh.ch + carsten.rose@math.uzh.ch, benjamin.baer@math.uzh.ch, enis.nuredini@math.uzh.ch, jan.haller@math.uzh.ch :License: This document is published under the Open Publication License diff --git a/Gruntfile.js b/Gruntfile.js index 84fb901babd4cc71c89a55877a0a9daaf1e224af..f23b13320644ecec94e6205582c36d49bc814a68 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -3,6 +3,7 @@ module.exports = function (grunt) { 'use strict'; var typo3_css = 'extension/Resources/Public/Css/'; + var typo3_css_codemirror = 'extension/Resources/Public/Css/codemirror/'; var typo3_js = 'extension/Resources/Public/JavaScript/'; var typo3_fonts = 'extension/Resources/Public/fonts/'; var typo3_webfonts = 'extension/Resources/Public/webfonts/'; @@ -450,6 +451,15 @@ module.exports = function (grunt) { dest: typo3_css, flatten: true }, + { + cwd: 'node_modules/codemirror/theme', + src: [ + "monokai.css" + ], + expand: true, + dest: typo3_css_codemirror, + flatten: true + }, { cwd: 'node_modules/codemirror/lib', src: [ @@ -496,6 +506,172 @@ module.exports = function (grunt) { } ] }, + filepond: { + files: [ + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.min.css" + ], + expand: true, + dest: typo3_css, + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.css" + ], + expand: true, + dest: 'css/', + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.esm.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.esm.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-file-validate-type/dist', + src: [ + "filepond-plugin-file-validate-type.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-file-validate-type/dist', + src: [ + "filepond-plugin-file-validate-type.min.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-file-validate-size/dist', + src: [ + "filepond-plugin-file-validate-size.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-file-validate-size/dist', + src: [ + "filepond-plugin-file-validate-size.min.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-preview/dist', + src: [ + "filepond-plugin-image-preview.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-preview/dist', + src: [ + "filepond-plugin-image-preview.min.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-preview/dist', + src: [ + "filepond-plugin-image-preview.min.css" + ], + expand: true, + dest: typo3_css, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-preview/dist', + src: [ + "filepond-plugin-image-preview.min.css" + ], + expand: true, + dest: 'css/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-edit/dist', + src: [ + "filepond-plugin-image-edit.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-edit/dist', + src: [ + "filepond-plugin-image-edit.min.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-edit/dist', + src: [ + "filepond-plugin-image-edit.min.css" + ], + expand: true, + dest: typo3_css, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-edit/dist', + src: [ + "filepond-plugin-image-edit.min.css" + ], + expand: true, + dest: 'css/', + flatten: true + } + ] + }, worker: { files: [ { @@ -577,9 +753,9 @@ module.exports = function (grunt) { ] }, }, - uglify: { + terser: { options: { - banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n', + //banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n', }, build: { src: ['js/<%= pkg.name %>.debug.js'], @@ -603,7 +779,10 @@ module.exports = function (grunt) { } }, jshint: { - all: js_sources + all: js_sources, + options: { + 'esversion': 6, + } }, concat_in_order: { debug_standalone: { @@ -704,7 +883,8 @@ module.exports = function (grunt) { }); // Load the plugin that provides the "uglify" task. - grunt.loadNpmTasks('grunt-contrib-uglify'); + grunt.loadNpmTasks('grunt-terser'); + //grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.loadNpmTasks('grunt-contrib-copy'); grunt.loadNpmTasks('grunt-contrib-concat'); grunt.loadNpmTasks('grunt-concat-in-order'); @@ -714,9 +894,9 @@ module.exports = function (grunt) { grunt.loadNpmTasks('grunt-contrib-jasmine'); // Default task(s). - grunt.registerTask('default', ['jshint', 'concat_in_order', 'uglify', 'copy', 'less']); + grunt.registerTask('default', ['jshint', 'concat_in_order', 'terser', 'copy', 'less']); - grunt.registerTask('only-js', ['jshint', 'concat_in_order', 'uglify', 'copy:qfqPlugins', 'copy:worker']); + grunt.registerTask('only-js', ['jshint', 'concat_in_order', 'terser', 'copy:qfqPlugins', 'copy:worker']); grunt.registerTask('run-jasmine', ['jshint', 'concat_in_order', 'jasmine']); diff --git a/Makefile b/Makefile index af23e110dc44860f4ea30f1f9ecc97f955a88046..c0ea69dc9233943cbd797ddc2d72ad44fe711a9f 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ qfq.zip: cd extension; zip -r ../$@ $(EXTENSION_CONTENT) clean: - cd doc/diagram ; $(MAKE) $@ +# cd doc/diagram ; $(MAKE) $@ git-revision: make-dist-dir echo $(GIT_REVISION_LONG) > $(DISTDIR)/revision.git @@ -59,14 +59,15 @@ plantuml: cd doc/diagram ; $(MAKE) bootstrap: .npmpackages .plantuml_install .virtual_env - npm update - grunt default + npm install + npm run build # take care that phpOffice is located under 'qfq/Resources/Private/vendor/phpoffice' # cd extension/Resources/Private; composer update cd extension; composer update basic: .npmpackages .virtual_env - grunt default + npm install + npm run build # IMPORTANT: install composer with no-dev flag for deployment! cd extension; composer install --no-dev --optimize-autoloader; cd vendor/phpoffice/phpspreadsheet; rm -rf .github bin docs samples .g* .s* .t* C* c* m* p* @@ -81,7 +82,6 @@ basic: .npmpackages .virtual_env node --version which node echo "${PATH}" - npm ls -g grunt-cli 2>/dev/null || { echo "Please install grunt-cli npm package using 'npm install -g grunt-cli'" 1>&2 ; exit 1; } # update npm at persistent location and copy node_modules (to speed up process) mkdir -p $(VAR_TMP)/npm /bin/cp package.json $(VAR_TMP)/npm/ diff --git a/extension/Classes/Api/file.php b/extension/Classes/Api/file.php index 176c2213e5a47f6292566bfb7655c3c6610e7a4c..a3d76cb3514605bdd7c1ee67ba4e142afca7fa02 100644 --- a/extension/Classes/Api/file.php +++ b/extension/Classes/Api/file.php @@ -52,6 +52,13 @@ try { $answer[API_MESSAGE] = 'upload: success'; // $answer[API_REDIRECT] = API_ANSWER_REDIRECT_NO; $answer[API_STATUS] = API_ANSWER_STATUS_SUCCESS; + if ($fileUpload->sipTmp !== null) { + $answer['sipTmp'] = $fileUpload->sipTmp; + } + + if ($fileUpload->uniqueFileId !== null) { + $answer = array('uniqueFileId' => $fileUpload->uniqueFileId, 'groupId' => $fileUpload->groupId); + } } } catch (\UserFormException $e) { $answer[API_MESSAGE] = $e->formatMessage(); @@ -69,4 +76,3 @@ if ($fileUpload->imageUploadFilePath === null) { echo json_encode($answer); - diff --git a/extension/Classes/Api/save.php b/extension/Classes/Api/save.php index 765442db462df36a5c06c3bd0e29d27fc253ed05..5ac3db6f73febf11aa36aee5598702783d3e0a91 100644 --- a/extension/Classes/Api/save.php +++ b/extension/Classes/Api/save.php @@ -88,6 +88,16 @@ try { } catch (\UserFormException $e) { $answer[API_MESSAGE] = $e->formatMessage(); + $val = Store::getVar(FE_ALERT_TEXT,STORE_SYSTEM); + if ($val !== false) { + $answer[FE_ALERT_TEXT] = $answer[API_MESSAGE]; + $answer[FE_ALERT_LEVEL] = Store::getVar(FE_ALERT_LEVEL, STORE_SYSTEM); + $answer[FE_ALERT_BUTTON_OK] = Store::getVar(FE_ALERT_BUTTON_OK, STORE_SYSTEM); + $answer[FE_ALERT_BUTTON_FORCE] = Store::getVar(FE_ALERT_BUTTON_FORCE, STORE_SYSTEM); + $answer[FE_ALERT_TIMEOUT] = Store::getVar(FE_ALERT_TIMEOUT, STORE_SYSTEM); + $answer[FE_ALERT_FLAG_MODAL] = Store::getVar(FE_ALERT_FLAG_MODAL, STORE_SYSTEM); + } + $val = Store::getVar(SYSTEM_FORM_ELEMENT, STORE_SYSTEM); if ($val !== false) { $answer[API_FIELD_NAME] = $val; @@ -102,6 +112,18 @@ try { $answer[API_MESSAGE] = $e->formatMessage(); } catch (\DbException $e) { $answer[API_MESSAGE] = $e->formatMessage(); + } catch (\InfoException $e) { + $answer[API_MESSAGE] = $e->formatMessage(); + + $val = Store::getVar(FE_ALERT_TEXT,STORE_SYSTEM); + if ($val !== false) { + $answer[FE_ALERT_TEXT] = $answer[API_MESSAGE]; + $answer[FE_ALERT_LEVEL] = Store::getVar(FE_ALERT_LEVEL, STORE_SYSTEM); + $answer[FE_ALERT_BUTTON_OK] = Store::getVar(FE_ALERT_BUTTON_OK, STORE_SYSTEM); + $answer[FE_ALERT_BUTTON_FORCE] = Store::getVar(FE_ALERT_BUTTON_FORCE, STORE_SYSTEM); + $answer[FE_ALERT_TIMEOUT] = Store::getVar(FE_ALERT_TIMEOUT, STORE_SYSTEM); + $answer[FE_ALERT_FLAG_MODAL] = Store::getVar(FE_ALERT_FLAG_MODAL, STORE_SYSTEM); + } } } catch (\Throwable $e) { $answer[API_MESSAGE] = "Generic Exception: " . $e->getMessage(); diff --git a/extension/Classes/Core/AbstractBuildForm.php b/extension/Classes/Core/AbstractBuildForm.php index b4eeb1c09ecd490c758d15c41c781cac7505f5f4..717d75ad89eb02fb809dd2c202428cb63214f3df 100644 --- a/extension/Classes/Core/AbstractBuildForm.php +++ b/extension/Classes/Core/AbstractBuildForm.php @@ -394,7 +394,7 @@ abstract class AbstractBuildForm { $formArray[F_TITLE] = $this->formSpec[F_UNEVALUATED_TITLE]; $evaluatedTitle = $this->evaluate->parseArray($formArray); // If form title has changed, add new title to JSON - if($this->formSpec[F_TITLE] != $evaluatedTitle[F_TITLE]){ + if ($this->formSpec[F_TITLE] != $evaluatedTitle[F_TITLE]) { $element = array( 'form-element' => 'qfq-form-title', 'value' => $evaluatedTitle[F_TITLE] @@ -640,7 +640,9 @@ abstract class AbstractBuildForm { */ public function getFormId() { if ($this->formId === null) { - $this->formId = uniqid('qfq-form-'); +// $this->formId = uniqid('qfq-form-'); + $this->formId = 'qfq-form-' . $this->store->getVar(TYPO3_TT_CONTENT_UID, STORE_TYPO3) . '-' + . $this->formSpec[F_ID] . '-' . $this->store->getVar(CLIENT_RECORD_ID, STORE_TYPO3 . STORE_SIP . STORE_RECORD . STORE_ZERO); } return $this->formId; @@ -904,7 +906,7 @@ abstract class AbstractBuildForm { // Typically: $htmlElementNameIdZero = true // After Saving a record, staying on the form, the FormElements on the Client are still known as '<feName>:0'. $htmlFormElementName = HelperFormElement::buildFormElementName($formElement, ($htmlElementNameIdZero) ? 0 : $recordId); - $formElement[FE_HTML_ID] = HelperFormElement::buildFormElementId($this->formSpec[F_ID], $formElement[FE_ID], + $formElement[FE_HTML_ID] = HelperFormElement::buildFormElementId($this->getFormId(), $formElement[FE_ID], ($htmlElementNameIdZero) ? 0 : $recordId, $formElement[FE_TG_INDEX]); @@ -925,11 +927,11 @@ abstract class AbstractBuildForm { case FE_TYPE_DATETIME: case FE_TYPE_TIME: $elementHtml = DateTime::buildDateTime($formElement, $htmlFormElementName, $value, $jsonElement, $this->formSpec, $this->store, $mode, $this->wrap[WRAP_SETUP_ELEMENT][WRAP_SETUP_CLASS]); - //needed for datepicker to be positioned correctly - if ($flagMulti == true) { - $elementHtml = Support::wrapTag('<div class="col-d-12 col-lg-12">', $elementHtml); - } - break; + //needed for datepicker to be positioned correctly + if ($flagMulti == true) { + $elementHtml = Support::wrapTag('<div class="col-d-12 col-lg-12">', $elementHtml); + } + break; case FE_TYPE_TEXT: case FE_TYPE_PASSWORD: case 'email': @@ -962,13 +964,13 @@ abstract class AbstractBuildForm { case FE_TYPE_IMAGE_CUT: $elementHtml = $this->buildImageCut($formElement, $htmlFormElementName, $value, $jsonElement, $mode); break; - case 'fieldset': + case FE_TYPE_FIELDSET: $elementHtml = $this->buildFieldset($formElement, $htmlFormElementName, $value, $jsonElement, $mode); break; - case 'pill': + case FE_TYPE_PILL: $elementHtml = $this->buildPill($formElement, $htmlFormElementName, $value, $jsonElement, $mode); break; - case 'templateGroup': + case FE_TYPE_TEMPLATE_GROUP: $elementHtml = $this->buildTemplateGroup($formElement, $htmlFormElementName, $value, $jsonElement, $mode); break; default: @@ -1230,7 +1232,7 @@ abstract class AbstractBuildForm { * @throws \CodeException * @throws \UserFormException */ - public static function getFormElementForJson(string $htmlFormElementName, $value, array $formElement, $wrap = '', $optionIdx = 0, $optionClass = ''): array { + public static function getFormElementForJson(string $htmlFormElementName, $value, array $formElement, $wrap = '', $optionIdx = 0, $optionClass = ''): array { $addClassRequired = array(); $json = HelperFormElement::getJsonFeMode($formElement[FE_MODE]); // disabled, required @@ -1557,7 +1559,7 @@ abstract class AbstractBuildForm { } // when size empty but value contains \n then set auto multi line - if(!isset($formElement[FE_TYPEAHEAD_SQL])){ + if (!isset($formElement[FE_TYPEAHEAD_SQL])) { if (strpos($value, "\n") == true && empty($formElement[FE_SIZE])) { $formElement[FE_SIZE] = '50,2'; } @@ -2121,7 +2123,7 @@ abstract class AbstractBuildForm { $wrapSetupClass = $this->wrap[WRAP_SETUP_ELEMENT][WRAP_SETUP_CLASS] ?? ''; $json = array_merge($this->getFormElementForJson($htmlFormElementName, $value, $formElement, $wrapSetupClass)); - $json[API_ELEMENT_UPDATE] = array_merge($json[API_ELEMENT_UPDATE], $jsonTmp[API_ELEMENT_UPDATE]); + $json[API_ELEMENT_UPDATE] = array_merge($json[API_ELEMENT_UPDATE], ($jsonTmp[API_ELEMENT_UPDATE] ?? array())); return $html; @@ -2443,6 +2445,7 @@ abstract class AbstractBuildForm { */ private function buildSubrecordRowsHtml(array $formElement, array $rowSettings, $additionalRow = false): string { $htmlBody = ''; + $rowHtml = ''; $subrecordToken = FE_SQL1; // Configure formElement array for additional rows. @@ -2453,7 +2456,6 @@ abstract class AbstractBuildForm { foreach ($formElement[$subrecordToken] as $row) { $rowHtml = ''; - if ($formElement[FE_MODE] == FE_MODE_READONLY) { $editToolTip = 'Show'; $editSymbol = SYMBOL_SHOW; @@ -2515,6 +2517,10 @@ abstract class AbstractBuildForm { } $htmlBody .= Support::wrapTag("<tr $rowAttribute>", $rowHtml, true); } + if (empty($formElement[$subrecordToken])) { + $rowHtml .= Support::wrapTag("<td>", $formElement[SUBRECORD_EMPTY_TEXT] ?? SUBRECORD_DEFAULT_EMPTY_TEXT); + $htmlBody .= Support::wrapTag("<tr>", $rowHtml, true); + } return $htmlBody; } @@ -2941,6 +2947,8 @@ abstract class AbstractBuildForm { */ public function buildFile(array $formElement, $htmlFormElementName, $value, array &$json, $mode = FORM_LOAD) { $attribute = ''; + $uploadType = $formElement[UPLOAD_TYPE] ?? UPLOAD_TYPE_V2; + $sipDownloadKey = 'sip-' . $formElement[FE_ID]; $this->store->appendToStore(HelperFile::pathinfo($value), STORE_VAR); @@ -2967,6 +2975,7 @@ abstract class AbstractBuildForm { $arr[CLIENT_PAGE_ID] = 'fake'; $arr[EXISTING_PATH_FILE_NAME] = $value; $arr[FE_FILE_MIME_TYPE_ACCEPT] = $formElement[FE_FILE_MIME_TYPE_ACCEPT]; + $arr[UPLOAD_SIP_DOWNLOAD_KEY] = $sipDownloadKey; // Check Safari Bug #5578: in case Safari (Mac OS X or iOS) loads an 'upload element' with more than one file type, fall back to 'no preselection'. // Still do the file type check on the server side! @@ -2995,17 +3004,6 @@ abstract class AbstractBuildForm { $hiddenSipUpload = HelperFormElement::buildNativeHidden($htmlFormElementName, $sipUpload); - $attribute .= Support::doAttribute('id', $formElement[FE_HTML_ID]); - $attribute .= Support::doAttribute('name', $htmlFormElementName); -// $attribute .= Support::doAttribute('class', 'form-control'); - $attribute .= Support::doAttribute('type', 'file'); - $attribute .= Support::doAttribute('title', $formElement[FE_TOOLTIP]); - $attribute .= Support::doAttribute(FE_FILE_CAPTURE, $formElement[FE_FILE_CAPTURE], true); - $attribute .= HelperFormElement::getAttributeList($formElement, [FE_AUTOFOCUS, FE_FILE_MIME_TYPE_ACCEPT]); - $attribute .= Support::doAttribute('data-load', ($formElement[FE_DYNAMIC_UPDATE] === 'yes') ? 'data-load' : ''); - $attribute .= Support::doAttribute('data-sip', $sipUpload); - $attribute .= Support::doAttribute(ATTRIBUTE_DATA_REFERENCE, $formElement[FE_DATA_REFERENCE]); - // Below, $value and $formElement[FE_MODE]=FE_MODE_REQUIRED will be changed. JSON will be made later, therefore we will need those values unchanged $jsonValue = $value; $jsonFormElement = $formElement; @@ -3024,70 +3022,171 @@ abstract class AbstractBuildForm { // $formElement[FE_MODE] = FE_MODE_HIDDEN; // #3876, CR did not understand why we need this here. Comment. If active, this element will be hide on each dynamic update. } - $attribute .= HelperFormElement::getAttributeFeMode($formElement[FE_MODE]); - $attribute .= Support::doAttribute('class', $uploadClass, true); + $disabled = ($formElement[FE_MODE] == FE_MODE_READONLY) ? 'disabled' : ''; -// $htmlInputFile = '<input ' . $attribute . '>' . HelperFormElement::getHelpBlock(); + Support::setIfNotSet($formElement, FE_FILE_BUTTON_TEXT, FE_FILE_BUTTON_TEXT_DEFAULT); - // <input type="file"> with BS3: https://stackoverflow.com/questions/11235206/twitter-bootstrap-form-file-element-upload-button/25053973#25053973 - $attribute .= Support::doAttribute('style', "display:none;"); - $htmlInputFile = '<input ' . $attribute . '>'; - $attributeFileLabel = Support::doAttribute('for', $formElement[FE_HTML_ID]); - $attributeFileLabel .= Support::doAttribute('class', 'btn btn-default ' . $uploadClass); + if ($uploadType === UPLOAD_TYPE_V2) { + $htmlInputFile = $this->createUploadElement($formElement, $value, $sipUpload, $sipDownloadKey, $disabled); + } else { + $attribute .= Support::doAttribute('id', $formElement[FE_HTML_ID]); + $attribute .= Support::doAttribute('name', $htmlFormElementName); +// $attribute .= Support::doAttribute('class', 'form-control'); + $attribute .= Support::doAttribute('type', 'file'); + $attribute .= Support::doAttribute('title', $formElement[FE_TOOLTIP]); + $attribute .= Support::doAttribute(FE_FILE_CAPTURE, $formElement[FE_FILE_CAPTURE], true); + $attribute .= HelperFormElement::getAttributeList($formElement, [FE_AUTOFOCUS, FE_FILE_MIME_TYPE_ACCEPT]); + $attribute .= Support::doAttribute('data-load', ($formElement[FE_DYNAMIC_UPDATE] === 'yes') ? 'data-load' : ''); + $attribute .= Support::doAttribute('data-sip', $sipUpload); + $attribute .= Support::doAttribute(ATTRIBUTE_DATA_REFERENCE, $formElement[FE_DATA_REFERENCE]); - Support::setIfNotSet($formElement, FE_FILE_BUTTON_TEXT, FE_FILE_BUTTON_TEXT_DEFAULT); - $htmlInputFile = Support::wrapTag("<label $attributeFileLabel>", $htmlInputFile . $formElement[FE_FILE_BUTTON_TEXT]); + $attribute .= HelperFormElement::getAttributeFeMode($formElement[FE_MODE]); + $attribute .= Support::doAttribute('class', $uploadClass, true); - $disabled = ($formElement[FE_MODE] == FE_MODE_READONLY) ? 'disabled' : ''; +// $htmlInputFile = '<input ' . $attribute . '>' . HelperFormElement::getHelpBlock(); - // Check if a custom text right beside the trash symbol is given. - $trashText = ''; - if (!empty($formElement[FE_FILE_TRASH_TEXT])) { - $trashText = ' ' . $formElement[FE_FILE_TRASH_TEXT]; - } + // <input type="file"> with BS3: https://stackoverflow.com/questions/11235206/twitter-bootstrap-form-file-element-upload-button/25053973#25053973 + $attribute .= Support::doAttribute('style', "display:none;"); + $htmlInputFile = '<input ' . $attribute . '>'; - if (!empty($value) && Support::isEnabled($formElement, FE_FILE_DOWNLOAD_BUTTON)) { - $testValue = file_exists($value); + $attributeFileLabel = Support::doAttribute('for', $formElement[FE_HTML_ID]); + $attributeFileLabel .= Support::doAttribute('class', 'btn btn-default ' . $uploadClass); - // API calls don't recognize paths like '/fileadmin/protected/...' - if (!$testValue && isset($_GET["submit_reason"])) { - $value = $_SERVER["DOCUMENT_ROOT"] . '/'. $value; - $testValue = file_exists($value); + $htmlInputFile = Support::wrapTag("<label $attributeFileLabel>", $htmlInputFile . $formElement[FE_FILE_BUTTON_TEXT]); + + // Check if a custom text right beside the trash symbol is given. + $trashText = ''; + if (!empty($formElement[FE_FILE_TRASH_TEXT])) { + $trashText = ' ' . $formElement[FE_FILE_TRASH_TEXT]; } - if (is_readable($value)) { - $link = new Link($this->sip, $this->dbIndexData); - $value = $link->renderLink($this->evaluate->parse($formElement[FE_FILE_DOWNLOAD_BUTTON]), 's|M:file|d|F:' . $value); - $jsonFormElement[FE_FILE_DOWNLOAD_BUTTON_HTML_INTERNAL] = $value; - } else { - $msg = "Already uploaded file not found."; - // In case debugging is off: showing download button means 'never show the real pathfilename' - if ($this->showDebugInfoFlag) { - $msg .= ' ShowDebugInfo=on >> Missing file is: ' . $value; + if (!empty($value) && Support::isEnabled($formElement, FE_FILE_DOWNLOAD_BUTTON)) { + $testValue = file_exists($value); + + // API calls don't recognize paths like '/fileadmin/protected/...' + if (!$testValue && isset($_GET["submit_reason"])) { + $value = $_SERVER["DOCUMENT_ROOT"] . '/' . $value; + $testValue = file_exists($value); } - $value = $msg; + + if (is_readable($value)) { + $link = new Link($this->sip, $this->dbIndexData); + $value = $link->renderLink($this->evaluate->parse($formElement[FE_FILE_DOWNLOAD_BUTTON]), 's|M:file|d|F:' . $value); + $jsonFormElement[FE_FILE_DOWNLOAD_BUTTON_HTML_INTERNAL] = $value; + } else { + $msg = "Already uploaded file not found."; + // In case debugging is off: showing download button means 'never show the real pathfilename' + if ($this->showDebugInfoFlag) { + $msg .= ' ShowDebugInfo=on >> Missing file is: ' . $value; + } + $value = $msg; + } + } + + $deleteButton = ''; + $htmlFilename = Support::wrapTag("<span class='uploaded-file-name'>", $value, false); + + if (($formElement[FE_FILE_TRASH] ?? '1') == '1') { + $deleteButton = Support::wrapTag("<button type='button' class='btn btn-default delete-file $disabled' $disabled data-sip='$sipUpload' name='delete-$htmlFormElementName'>", $this->symbol[SYMBOL_DELETE] . $trashText); } + $htmlTextDelete = Support::wrapTag("<div class='uploaded-file $textDeleteClass'>", $htmlFilename . ' ' . $deleteButton); + +// <button type="button" class="file-delete" data-sip="571d1fc9e6974"><span class="glyphicon glyphicon-trash"></span></button> } // JSON Build $wrapSetupClass = $this->wrap[WRAP_SETUP_ELEMENT][WRAP_SETUP_CLASS] ?? ''; $json = $this->getFormElementForJson($htmlFormElementName, $jsonValue, $jsonFormElement, $wrapSetupClass); // Below, $formElement[FE_MODE]=FE_MODE_REQUIRED will be changed. Get the JSON unchanged - $deleteButton = ''; - $htmlFilename = Support::wrapTag("<span class='uploaded-file-name'>", $value, false); + $formElement = HelperFormElement::prepareExtraButton($formElement, false); - if (($formElement[FE_FILE_TRASH] ?? '1') == '1') { - $deleteButton = Support::wrapTag("<button type='button' class='btn btn-default delete-file $disabled' $disabled data-sip='$sipUpload' name='delete-$htmlFormElementName'>", $this->symbol[SYMBOL_DELETE] . $trashText); + $htmlOutputElement = $htmlInputFile . $hiddenSipUpload . $formElement[FE_TMP_EXTRA_BUTTON_HTML] . $formElement[FE_INPUT_EXTRA_BUTTON_INFO]; + if ($uploadType === UPLOAD_TYPE_V1) { + $htmlOutputElement = $htmlTextDelete . $htmlInputFile . $hiddenSipUpload . $formElement[FE_TMP_EXTRA_BUTTON_HTML] . $formElement[FE_INPUT_EXTRA_BUTTON_INFO]; } - $htmlTextDelete = Support::wrapTag("<div class='uploaded-file $textDeleteClass'>", $htmlFilename . ' ' . $deleteButton); - -// <button type="button" class="file-delete" data-sip="571d1fc9e6974"><span class="glyphicon glyphicon-trash"></span></button> - - $formElement = HelperFormElement::prepareExtraButton($formElement, false); - return $htmlTextDelete . $htmlInputFile . $hiddenSipUpload . $formElement[FE_TMP_EXTRA_BUTTON_HTML] . $formElement[FE_INPUT_EXTRA_BUTTON_INFO]; + return $htmlOutputElement; } + /** + * Create HTML upload element for filePond instance. + * + * @param array $formElement + * @param string $disabled + * @param string $value + * + */ + public function createUploadElement(array $formElement, string $value, string $sipUpload, string $sipDownloadKey, string $disabled): string { + $defaultText = 'Drag & Drop or <span class="btn btn-default filepond--label-action"> '. $formElement[FE_FILE_BUTTON_TEXT] . ' </span>'; + + // Check for upload type new or old and initialize json config for new upload type + $jsonConfig = array(); + $preloadedFiles = ''; + + $jsonConfig[UPLOAD_MIME_TYPE_ACCEPT] = $formElement[FE_FILE_MIME_TYPE_ACCEPT] ?? null; + $jsonConfig[UPLOAD_MAX_FILE_SIZE] = $arr[FE_FILE_MAX_FILE_SIZE] ?? null; + $jsonConfig[UPLOAD_MULTI_UPLOAD] = false; + $jsonConfig[UPLOAD_DELETE_OPTION] = false; + if (($formElement[FE_FILE_TRASH] ?? '1') === '1' && $disabled === '') { + $jsonConfig[UPLOAD_DELETE_OPTION] = true; + } + $jsonConfig[UPLOAD_IMAGE_EDITOR] = false; + $jsonConfig[UPLOAD_ALLOW] = true; + $jsonConfig[UPLOAD_TEXT] = $defaultText; + $jsonConfig[UPLOAD_MAX_FILES] = null; + $jsonConfig[UPLOAD_ID] = 1; + $jsonConfig[UPLOAD_GROUP_ID] = $groupId ?? 0; + $jsonConfig[UPLOAD_DROP_BACKGROUND] = 'white'; + $jsonConfig[UPLOAD_DOWNLOAD_BUTTON] = substr($this->evaluate->parse($formElement[FE_FILE_DOWNLOAD_BUTTON]), 2); + $jsonConfig[UPLOAD_TYPE_FORM] = true; + $jsonConfig[UPLOAD_FORM_ID] = $formElement[FE_HTML_ID]; + $jsonConfig[UPLOAD_SIP_DOWNLOAD_KEY] = $sipDownloadKey; + + if (!isset($jsonConfig[UPLOAD_PATH_FILE_NAME])) { + $jsonConfig[UPLOAD_PATH_FILE_NAME] = ''; + $jsonConfig[UPLOAD_PATH_DEFAULT] = 1; + } + + $encodedJsonConfig = htmlspecialchars(json_encode($jsonConfig, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + $baseUrl = $this->store::getVar(SYSTEM_BASE_URL, STORE_SYSTEM); + + // Check if fileDestination exists in extra Store + $storeExtra = $this->store::getVar($sipDownloadKey, STORE_EXTRA . STORE_EMPTY); + $sipFileDestination = $storeExtra[FE_FILE_DESTINATION] ?? ''; + if ($value !== '' || $sipFileDestination !== '') { + $path = empty($value) ? $sipFileDestination : $value; + $pathToCheck = $_SERVER["DOCUMENT_ROOT"] . '/' . $path; + if (file_exists($pathToCheck)) { + // Currently no information except path is stored for upload over form. + $preloadedFiles = '[{"id":"1","pathFileName":"'. $_SERVER["DOCUMENT_ROOT"] . '/' . $path . '","size":"null","type":"null"}]'; + $link = new Link($this->sip, $this->dbIndexData); + $sipDownload = $link->renderLink('', 's|M:file|d|r:8|F:' . $path); + $this->store->setVar($sipDownloadKey, array(),STORE_EXTRA); + + // Fill extra store for downloadable upload after save + if ($this->store::getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX) === API_SUBMIT_REASON_SAVE) { + $statusUpload[SIP_DOWNLOAD_PARAMETER] = 'F:' . $path; + $this->store->setVar($sipDownloadKey, $statusUpload, STORE_EXTRA); + } + } + } + + $encodedPreloadFilesConfig = htmlspecialchars($preloadedFiles, ENT_QUOTES, 'UTF-8'); + + $apiUrls['upload'] = $baseUrl . 'typo3conf/ext/qfq/Classes/Api/file.php'; + $apiUrls['download'] = $baseUrl . 'typo3conf/ext/qfq/Classes/Api/download.php'; + $encodedApiUrls = htmlspecialchars(json_encode($apiUrls, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + + $sipValues['download'] = $sipDownload ?? ''; + $sipValues['delete'] = $sipUpload . '&action=delete'; + $sipValues['upload'] = $sipUpload . '&action=upload'; + $encodedSipValues = htmlspecialchars(json_encode($sipValues, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + + //data-class="' . $uploadClass . '" + return '<input class="fileupload" data-preloadedFiles="' . $encodedPreloadFilesConfig . '" data-api-urls="' . $encodedApiUrls . '" data-sips="' . $encodedSipValues . '" data-config="'. $encodedJsonConfig . '" type="file">'; + + } + /** * @param array $formElement * @param $htmlFormElementName @@ -3109,7 +3208,7 @@ abstract class AbstractBuildForm { $html = $this->buildAnnotateCode($formElement, $htmlFormElementName, $value, $json, $mode); break; default: - throw new \UserFormException("Unkown " . FE_ANNOTATE_TYPE . ": '" . $formElement[FE_ANNOTATE_TYPE] . "'", ERROR_UNKNOWN_MODE); + throw new \UserFormException("Unknown " . FE_ANNOTATE_TYPE . ": '" . $formElement[FE_ANNOTATE_TYPE] . "'", ERROR_UNKNOWN_MODE); } return $html; } @@ -3532,7 +3631,7 @@ abstract class AbstractBuildForm { "file_picker_types" => "file image media", "image_advtab" => true, "automatic_uploads" => true, - "images_upload_url" => $baseUrl . Path::appToApi(API_FILE_PHP) . $completeUrl, + "images_upload_url" => $baseUrl . Path::appToApi(API_FILE_PHP) . $completeUrl, "images_reuse_filename" => true, "paste_data_images" => true ]; @@ -3976,23 +4075,30 @@ abstract class AbstractBuildForm { $attribute .= Support::doAttribute('id', $formElement[FE_HTML_ID]); $attribute .= Support::doAttribute('name', $htmlFormElementName); $attribute .= Support::doAttribute('data-load', ($formElement[FE_DYNAMIC_UPDATE] === 'yes') ? 'data-load' : ''); - $attribute .= Support::doAttribute('class', $formElement[F_FE_FIELDSET_CLASS]); + $attribute .= Support::doAttribute('class', [ $formElement[F_FE_FIELDSET_CLASS], ($formElement[FE_MODE] == FE_MODE_HIDDEN) ? FE_MODE_HIDDEN : '']); $attribute .= Support::doAttribute(ATTRIBUTE_DATA_REFERENCE, $formElement[FE_DATA_REFERENCE]); + $attribute .= HelperFormElement::getAttributeFeMode($formElement[FE_MODE], false); // <fieldset> $html = '<fieldset ' . $attribute . '>'; - if ($formElement[FE_LABEL] !== '') { $html .= '<legend>' . $formElement[FE_LABEL] . '</legend>'; } $html .= $this->wrap[WRAP_SETUP_IN_FIELDSET][WRAP_SETUP_START]; - // child FE's + // child FEs $this->feSpecNative = $this->dbArray[$this->dbIndexQfq]->getNativeFormElements(SQL_FORM_ELEMENT_SPECIFIC_CONTAINER, ['yes', $this->formSpec["id"], 'native,container', $formElement[FE_ID]], $this->formSpec); + // child FEs set to required if fieldset is required + if ($formElement[FE_MODE] == FE_MODE_REQUIRED) { + foreach ($this->feSpecNative as $key => $value) { + $this->feSpecNative[$key][FE_MODE] = FE_MODE_REQUIRED; + } + } + $html .= $this->elements($this->store->getVar(SIP_RECORD_ID, STORE_SIP), FORM_ELEMENTS_NATIVE_SUBRECORD, 0, $json); $html .= $this->wrap[WRAP_SETUP_IN_FIELDSET][WRAP_SETUP_END]; diff --git a/extension/Classes/Core/BodytextParser.php b/extension/Classes/Core/BodytextParser.php index 2b5394e383fdd34607fbd27b20bd20f5e90b30ef..9e4fd8d50caa681e325ec0b27eb42043dc5dcea0 100644 --- a/extension/Classes/Core/BodytextParser.php +++ b/extension/Classes/Core/BodytextParser.php @@ -49,6 +49,8 @@ class BodytextParser { json_encode([ERROR_MESSAGE_TO_USER => 'Report: Missing close delimiter', ERROR_MESSAGE_TO_DEVELOPER => $bodyText]), ERROR_MISSING_CLOSE_DELIMITER); } + unset($this->reportLinesTemp); + return $bodyText; } @@ -64,6 +66,7 @@ class BodytextParser { private function trimAndRemoveCommentAndEmptyLine($bodytext, &$nestingOpen, &$nestingClose) { $data = array(); + $reportLines = array(); $src = explode(PHP_EOL, $bodytext); if ($src === false) { @@ -72,22 +75,27 @@ class BodytextParser { $firstLine = trim($src[0]); - foreach ($src as $row) { + foreach ($src as $key => $row) { $row = trim($row); if ($row === '' || $row[0] === '#') { continue; } $data[] = $row; + + // Increment $key to match line from tt-content record + $key++; + $reportLines[] = $key; } + $this->reportLinesTemp = $reportLines; $this->setNestingToken($firstLine, $nestingOpen, $nestingClose); return implode(PHP_EOL, $data); } /** - * Set the 'nesting token for this tt-conten record. Valid tokens are {}, <>, [], (). + * Set the nesting token for this tt-content record. Valid tokens are {}, <>, [], (). * If the first line of bodytext is a comment line and the last char of that line is a valid token: set that one. * If not: set {} as nesting token. * @@ -170,7 +178,9 @@ class BodytextParser { */ private function joinLine($bodyText, $nestingOpen, $nestingClose) { $data = array(); + $reportLines = $this->reportLinesTemp; $bodytextArray = explode(PHP_EOL, $bodyText); + $firstToken = ''; $nestingOpenRegexp = $nestingOpen; if ($nestingOpen === '(' || $nestingOpen === '[') { @@ -179,7 +189,7 @@ class BodytextParser { $full = ''; $joinDelimiter = ' '; - foreach ($bodytextArray as $row) { + foreach ($bodytextArray as $key => $row) { // Line end with '\'? if (substr($row, -1) == '\\') { @@ -192,8 +202,11 @@ class BodytextParser { if (($row == $nestingOpen || $row == $nestingClose) || (1 === preg_match('/^\d+(\.\d+)*(\s*' . $nestingOpenRegexp . ')?$/', $row)) || (1 === preg_match('/^(\d+\.)*(' . TOKEN_VALID_LIST . ')\s*=/', $row)) - ) { + // Report notation 'alias' + // E.g. myAlias { ... + || (1 === preg_match('/^[\w-]*(\s*' . $nestingOpenRegexp . ')+$/', $row)) + ) { // if there is already something: save this. if ($full !== '') { $data[] = $full; @@ -202,9 +215,26 @@ class BodytextParser { // start new line $full = $row; + // This later determines the notation mode + // Possible values for $firstToken: '10', '10.20', '10.sql=...', '10.head=...', 'myAlias {', 'myAlias{' + // Values such as 'form={{form:SE}}' are valid but not parsed as a level/alias. + if (empty($firstToken) && 1 !== preg_match('/^(' . TOKEN_VALID_LIST . ')\s*=/', $row)) { + $firstToken = (strpos($row, $nestingOpen) !== false) ? trim(substr($row, 0, strpos($row, $nestingOpen))) : $row; + } + + // If the open delimiter is missing while using an alias, this is necessary to get the correct error message later on + // Starts a new line if the previous line only contained '}' + // It prevents that the lines '}' and 'myAlias' will be joined + } elseif ($full === $nestingClose) { + $data[] = $full; + + // start new line + $full = $row; } else { // continue row: concat - the space is necessary to join SQL statements correctly: 'SELECT ... FROM ... WHERE ... AND\np.id=...' - here a 'AND' and 'p.id' need a space. $full .= $joinDelimiter . $row; + // remove unused elements + unset($reportLines[$key]); } $joinDelimiter = $joinDelimiterNext; @@ -215,6 +245,29 @@ class BodytextParser { $data[] = $full; } + $reportLines = array_values($reportLines); + + // Combines line numbers ($key) from tt-content record with content ($value) from corresponding line: [line => content] + // E.g. [0 => 4, 1 => "[", 2 => "sql = SELECT ...", 3 => "[", 4 => "sql = SELECT ...", ...]: line 0 is empty + foreach ($reportLines as $key => $value) { + $reportLines[$value] = $data[$key]; + } + + // Removes every element that is not an SQL statement: [line => content] + // E.g. [2 => "sql = SELECT ...", 4 => "sql = SELECT ...", ...] + foreach ($reportLines as $key => $value) { + if (strpos($value, '=') !== false) { + $arr = explode('"', $value, 2); + if (strpos($arr[0], TOKEN_SQL) === false) { + unset($reportLines[$key]); + } + } else { + unset($reportLines[$key]); + } + } + $this->reportLinesTemp = $reportLines; + $this->firstToken = $firstToken; + return implode(PHP_EOL, $data); } @@ -237,7 +290,14 @@ class BodytextParser { $nestingClose = '\\' . $nestingClose; } - $bodytext = preg_replace('/^((\d+)(\.\d+)*\s*)?(' . $nestingOpen . ')$/m', '$1' . NESTING_TOKEN_OPEN, $bodytext); + // Report notation 'numeric' + // E.g. 10 { ... + // $bodytext = preg_replace('/^((\d+)(\.\d+)*\s*)?(' . $nestingOpen . ')$/m', '$1' . NESTING_TOKEN_OPEN, $bodytext); + + // Report notation 'alias' + // E.g. myAlias { ... + $bodytext = preg_replace('/^((\s*[\w-]*\s*)|((\s*\d+)(\.\d+)*\s*))?(' . $nestingOpen . ')/m', '$1' . NESTING_TOKEN_OPEN, $bodytext); + $bodytext = preg_replace('/^' . $nestingClose . '$/m', '$1' . NESTING_TOKEN_CLOSE, $bodytext); return $bodytext; @@ -277,6 +337,28 @@ class BodytextParser { $result = $bodytext; $posFirstClose = strpos($result, NESTING_TOKEN_CLOSE); + // Default: Report notation 'numeric' + $notationMode = TOKEN_NOTATION_NUMERIC; + $levels = null; + $reportLines = $this->reportLinesTemp; + $alias = null; + $aliases = null; + $firstToken = $this->firstToken; + + // No first token or non-numeric first token implies report notation 'alias' + // It supports auto numbering of blocks and aliases + if (empty($firstToken) || (1 !== preg_match('/^([0-9\._-])+$/', $firstToken) && !strpos($firstToken, '='))) { + $notationMode = TOKEN_NOTATION_ALIAS; + $aliases = array(); + $levels = array(); + $maxLevel = substr_count($bodytext, NESTING_TOKEN_CLOSE); + + // Generate an array containing all levels, e.g. [1,2,3,...] + for ($x = 1; $x <= $maxLevel; $x++) { + array_push($levels, $x); + } + } + while ($posFirstClose !== false) { $posMatchOpen = strrpos(substr($result, 0, $posFirstClose), NESTING_TOKEN_OPEN); @@ -285,7 +367,6 @@ class BodytextParser { throw new \UserFormException( json_encode([ERROR_MESSAGE_TO_USER => 'Missing open delimiter', ERROR_MESSAGE_TO_DEVELOPER => "Missing open delimiter: $result"]), ERROR_MISSING_OPEN_DELIMITER); - } $pre = substr($result, 0, $posMatchOpen); @@ -304,11 +385,57 @@ class BodytextParser { $levelStartPos = ($levelStartPos === false) ? 0 : $levelStartPos + 1; // Skip PHP_EOL $level = trim(substr($pre, $levelStartPos)); -// if($level==='') { -// $pre= -// } - // remove 'level' from last line - $pre = substr($pre, 0, $levelStartPos); + + // Report notation 'alias' + // Count open brackets in front of current level + // E.g. current $level = 2, then $index = 1, because there is 1 in front + $index = substr_count($pre, NESTING_TOKEN_OPEN); + + // Check for report notation 'alias' + if ($notationMode === TOKEN_NOTATION_ALIAS) { + $aliasLevel = null; + + // $firstToken === $level checks if we are in the 'first loop' + // $adjustLength is used later while extracting a substring and has to be zero in the first loop + $adjustLength = ($firstToken === $level && strpos($pre, PHP_EOL) === false) ? 0 : 1; + + // If the $level, from which the $alias is extracted, nothing gets saved + // Allowed characters: [a-zA-Z0-9\._-] + // '.' is only allowed to detect report notation 'numeric'. This throws an error later on. + $alias = (1 === preg_match('/^[a-zA-Z0-9\._-]+$/', $level) || $level === '') ? $level : null; + + // If no alias is set, then nothing gets saved + if (!empty($alias)) { + // Construct absolute $level of the current $alias + // E.g. 1.2.3. + for ($x = 0; $x <= $index; $x++) { + $aliasLevel .= (isset($levels[$x])) ? $levels[$x] . '.' : null; + } + + // Trailing '.' gets removed from $level: E.g. 1.2.3 + // $level is saved together with $alias: [level => alias] + $aliases[substr($aliasLevel, 0, strlen($aliasLevel) - 1)] = $alias; + } + + // Current $level can now be extracted from $levels [1,2,3,...] + $level = (isset($levels[$index])) ? $levels[$index] : null; + + // Remove current $level from $levels [1,3,...] + // This works because opening brackets get removed from $pre after every level + unset($levels[$index]); + + // Reset keys + $levels = array_values($levels); + + // Removes alias or level added by user to continue auto numbering scheme + // E.g. User input: {\nsql = SELECT ...\n}\nmyAlias{\nsql = SELECT ...\n} + // $pre = "1.sql = SELECT ...\nmyAlias" -> $pre = "1.sql = SELECT ...\n" + $pre = substr($pre, 0, strrpos($pre, PHP_EOL) + $adjustLength); + } else { + + // Remove 'level' from last line + $pre = substr($pre, 0, $levelStartPos); + } // Split nested content in single rows $lines = explode(PHP_EOL, $match); @@ -326,6 +453,33 @@ class BodytextParser { // $result = str_replace('#&]_#', '}', $result); // $result = Support::decryptDoubleCurlyBraces($result); + $resultArr = explode(PHP_EOL, $result); + + // $value (previously SQL statement) gets replaced by its level: [line => level] + // E.g. [2 => 1, 4 => 1.2, ...]: + foreach ($reportLines as $keyLines => $valueLines) { + foreach ($resultArr as $keyResult => $valueResult) { + if (strpos($valueResult, '=')) { + $arr = explode("=", $valueResult, 2); + if (strpos($arr[0], TOKEN_SQL) !== false) { + $reportLines[$keyLines] = str_replace('.' . TOKEN_SQL, '', trim($arr[0])); + } else { + continue; + } + } else { + continue; + } + unset($resultArr[$keyResult]); + break; + } + } + + // Array is flipped: [level => line] + // E.g. [1 => 2, 1.2 => 4, ...] + $this->reportLines = array_flip($reportLines); + + $this->aliases = $aliases; + return $result; } diff --git a/extension/Classes/Core/BuildFormBootstrap.php b/extension/Classes/Core/BuildFormBootstrap.php index a15737c31026c42fe1201f254774bd3f2625c6df..feb071a61f86b31cc1379483c3711adb38386918 100644 --- a/extension/Classes/Core/BuildFormBootstrap.php +++ b/extension/Classes/Core/BuildFormBootstrap.php @@ -448,8 +448,9 @@ class BuildFormBootstrap extends AbstractBuildForm { $class = Support::doAttribute('class', $class); $dataClassOnChange = Support::doAttribute('data-class-on-change', $buttonOnChangeClass); $tooltip = Support::doAttribute('title', $tooltip); + $formId = $this->getFormId(); - return "<button id='$buttonHtmlId' type='button' $class $dataClassOnChange $tooltip $disabled>$element</button>"; + return "<button id='$buttonHtmlId-$formId' type='button' $class $dataClassOnChange $tooltip $disabled>$element</button>"; } /** @@ -510,7 +511,7 @@ class BuildFormBootstrap extends AbstractBuildForm { // $a = '<a ' . Support::doAttribute('href', '#' . $this->createAnker($formElement[FE_ID])) . ' data-toggle="tab">' . $formElement[FE_LABEL] . '</a>'; $attributeLiA = 'data-toggle="tab" '; - $hrefTarget = '#' . $this->createAnker($formElement[FE_ID]); + $hrefTarget = '#' . $this->createAnker($formElement[FE_ID], $recordId); $htmlFormElementName = HelperFormElement::buildFormElementName($formElement, $recordId); switch ($formElement[FE_MODE]) { @@ -582,15 +583,16 @@ class BuildFormBootstrap extends AbstractBuildForm { * * @return string */ - private function createAnker($id) { - return $this->formSpec[FE_NAME] . '_' . $id; + private function createAnker($id, $recordId) { + return $this->formSpec[FE_NAME] . '_' . $id . '_' . $recordId; } /** * @return string */ private function getTabId() { - return 'qfqTabs'; + return 'qfqTabs-' . $this->store->getVar(TYPO3_TT_CONTENT_UID, STORE_TYPO3) . '-' + . $this->formSpec[F_ID] . '-' . $this->store->getVar(CLIENT_RECORD_ID, STORE_TYPO3 . STORE_SIP . STORE_RECORD . STORE_ZERO); } /** @@ -606,7 +608,7 @@ class BuildFormBootstrap extends AbstractBuildForm { $attribute = $this->getFormTagAttributes(); - $attribute['class'] = 'form-horizontal'; + $attribute['class'] = 'form-horizontal qfq-form'; $attribute['data-toggle'] = 'validator'; $formModeGlobal = Support::getFormModeGlobal($this->formSpec[F_MODE_GLOBAL] ?? ''); @@ -622,6 +624,27 @@ class BuildFormBootstrap extends AbstractBuildForm { $honeypot = $this->getHoneypotVars(); $md5 = $this->buildInputRecordHashMd5(); + $actionUpload = FILE_ACTION . '=' . FILE_ACTION_UPLOAD; + $actionDelete = FILE_ACTION . '=' . FILE_ACTION_DELETE; + + # Replacing the attributes set via tail / javascript + $attribute["data-form-id"] = $this->getFormId(); + $attribute["data-tabs-id"] = $this->getTabId(); + if (0 < ($recordId = $this->store->getVar(SIP_RECORD_ID, STORE_SIP))) { + $attribute["data-delete-url"] = $this->createDeleteUrl($this->formSpec[F_FINAL_DELETE_FORM], $recordId); + } + if ($this->formSpec[F_DIRTY_MODE] != DIRTY_MODE_NONE) { + $attribute["data-dirty-url"] = Path::urlApi(API_DIRTY_PHP); + } + $attribute["data-submit-to"] = Path::urlApi(API_SAVE_PHP); + $attribute["data-type-ahead-url"] = Path::urlApi(API_TYPEAHEAD_PHP); + $attribute["data-refresh-url"] = Path::urlApi(API_LOAD_PHP); + $attribute["data-file-upload-to"] = Path::urlApi(API_FILE_PHP) . '?' . $actionUpload; + $attribute["data-file-delete-url"] = Path::urlApi(API_FILE_PHP) . '?' . $actionDelete; + $attribute["data-log-level"] = 0; # Not sure if raos did implement this fully, but it was hardcoded 0 before. + $attribute["data-api-delete-url"] = Path::urlApi(API_DELETE_PHP); + #$attribute["data-sip"] = + return '<form ' . OnArray::toString($attribute, '=', ' ', "'") . '>' . $honeypot . $md5; } @@ -634,58 +657,10 @@ class BuildFormBootstrap extends AbstractBuildForm { public function tail() { $html = ''; - $deleteUrl = ''; - - $formId = $this->getFormId(); $html .= '</div> <!--class="tab-content" -->'; // <div class="tab-content"> // $html .= '<input type="submit" value="Submit">'; - $formId = $this->getFormId(); - $tabId = $this->getTabId(); - - if (0 < ($recordId = $this->store->getVar(SIP_RECORD_ID, STORE_SIP))) { - $deleteUrl = $this->createDeleteUrl($this->formSpec[F_FINAL_DELETE_FORM], $recordId); - } - - $actionUpload = FILE_ACTION . '=' . FILE_ACTION_UPLOAD; - $actionDelete = FILE_ACTION . '=' . FILE_ACTION_DELETE; - - $apiDeletePhp = Path::urlApi(API_DELETE_PHP); - - $dirtyAction = ($this->formSpec[F_DIRTY_MODE] == DIRTY_MODE_NONE) ? '' : "dirtyUrl: '" . Path::urlApi(API_DIRTY_PHP) . "',"; - - $submitTo = Path::urlApi(API_SAVE_PHP); - $typeAheadUrl = Path::urlApi(API_TYPEAHEAD_PHP); - $refreshUrl = Path::urlApi(API_LOAD_PHP); - $fileUploadTo = Path::urlApi(API_FILE_PHP) . '?' . $actionUpload; - $fileDeleteUrl = Path::urlApi(API_FILE_PHP) . '?' . $actionDelete; - - $html .= '</form>'; // <form class="form-horizontal" ... - $html .= <<<EOF - <script type="text/javascript"> - $(function () { - 'use strict'; - QfqNS.Log.level = 0; - - var qfqPage = new QfqNS.QfqPage({ - tabsId: '$tabId', - formId: '$formId', - submitTo: '$submitTo', - $dirtyAction - deleteUrl: '$deleteUrl', - typeAheadUrl: '$typeAheadUrl', - refreshUrl: '$refreshUrl', - fileUploadTo: '$fileUploadTo', - fileDeleteUrl: '$fileDeleteUrl' - }); - - - var qfqRecordList = new QfqNS.QfqRecordList('$apiDeletePhp'); - }) - </script> -EOF; - // Button Save at bottom of form - only if there is a button text given. if ($this->formSpec[F_SUBMIT_BUTTON_TEXT] !== '') { @@ -710,9 +685,9 @@ EOF; $html .= $this->wrapItem(WRAP_SETUP_INPUT, $htmlElement); $html .= $this->wrapItem(WRAP_SETUP_NOTE, ''); $html .= $lastDiv; - } + $html .= '</form>'; $html .= '</div>'; // <div class="container-fluid"> === main <div class=...> around everything @@ -892,8 +867,9 @@ EOF; $class = $this->isFirstPill ? 'active ' : ''; $this->isFirstPill = false; } + $recordId = $this->store->getVar(COLUMN_ID, STORE_RECORD . STORE_ZERO); - $html = Support::wrapTag('<div role="tabpanel" class="tab-pane ' . $class . '" id="' . $this->createAnker($formElement['id']) . '">', $html); + $html = Support::wrapTag('<div role="tabpanel" class="tab-pane ' . $class . '" id="' . $this->createAnker($formElement['id'], $recordId) . '">', $html); return $html; diff --git a/extension/Classes/Core/Constants.php b/extension/Classes/Core/Constants.php index 96b2a617020473918dbcea85306a33f2d99442aa..65b3d2cba3d6b0fde94ac54b000eb3309787cad3 100644 --- a/extension/Classes/Core/Constants.php +++ b/extension/Classes/Core/Constants.php @@ -30,6 +30,7 @@ const SESSION_FE_USER_GROUP = 'feUserGroup'; const SESSION_BE_USER = 'beUser'; const SESSION_PAGE_LANGUAGE = 'pageLanguage'; const SESSION_PAGE_LANGUAGE_PATH = 'pageLanguagePath'; +const SESSION_PAGE_ID = 'pageId'; const TABLE_NAME_FORM = 'Form'; const TABLE_NAME_FORM_ELEMENT = 'FormElement'; const TABLE_NAME_SPLIT = 'Split'; @@ -113,6 +114,7 @@ const ERROR_MESSAGE_TO_DEVELOPER = 'support'; // Message to help the developer t const ERROR_MESSAGE_TO_DEVELOPER_SANITIZE = 'support_sanitize'; // Typically 'true' or missing. If 'false' then content of 'support' won't be html encoded. const ERROR_MESSAGE_OS = 'os'; // Error message from the OS - like 'file not found' or specific SQL problem const ERROR_MESSAGE_HTTP_STATUS = 'httpStatus'; // HTTP Status Code to report +const ERROR_MESSAGE_LOG_ERROR = 'logError'; // Indicates if the error should be logged (boolean) // QFQ Error Codes const ERROR_UNKNOW_SANITIZE_CLASS = 1001; @@ -205,6 +207,8 @@ const ERROR_QFQ_UPDATE_API = 1088; const ERROR_UNPROTECTED_FOLDER = 1089; const ERROR_INVALID_WGET_CMD = 1090; const ERROR_MISSING_KEY_VALUE = 1091; +const ERROR_MISSING_ALERT = 1092; +const ERROR_DOUBLE_USAGE_ALERT_AND_MESSAGE_FAIL = 1093; // Subrecord @@ -254,6 +258,7 @@ const ERROR_INVALID_SAVE_PDF_FILENAME = 1410; const ERROR_TWIG_COLUMN_NOT_UNIQUE = 1411; const ERROR_DOUBLE_DEFINITION = 1412; const ERROR_INVALID_SAVE_ZIP_FILENAME = 1413; +const ERROR_NUMERIC_ALIAS = 1414; // Upload const ERROR_UPLOAD = 1500; @@ -366,7 +371,6 @@ const ERROR_TABLESORTER_NAME_TOO_LONG = 3102; const ERROR_SETTING_RECORD_TOO_MUCH = 3103; const ERROR_SETTING_SYSTEM = 3104; -// Author: Enis Nuredini // Encryption const ERROR_MISSING_ENCRYPTION_KEY = 3105; const ERROR_INVALID_DATABASE_FIELD_TYPE = 3106; @@ -375,7 +379,8 @@ const ERROR_INVALID_ENCRYPTION_METHOD = 3108; const ERROR_VARIABLE_INVALID_ENCRYPTION_METHOD = 3109; const ERROR_NO_STORE_FOUND = 3110; const ERROR_ENCRYPT_CLASS = 3111; -// End author + +const ERROR_MSG_TOO_BIG = "** Data removed: too big **"; // // Store Names: Identifier @@ -384,7 +389,7 @@ const STORE_ADDITIONAL_FORM_ELEMENTS = "A"; // Internal Store to collect FormEle const STORE_BEFORE = "B"; // selected record from primary table before any modifcations. const STORE_CLIENT = "C"; // Client: POST variable, if not found: GET variable const STORE_TABLE_DEFAULT = "D"; // definition of primary table. -const STORE_EMPTY = "E"; // value: '', might helpfull if variable is not defined and should result in an empty string instead of {{...}} (cause not replaced) +const STORE_EMPTY = "E"; // value: '', might helpful if variable is not defined and should result in an empty string instead of {{...}} (cause not replaced) const STORE_FORM = "F"; // form, still not saved in database const STORE_LDAP = "L"; const STORE_TABLE_COLUMN_TYPES = "M"; // column types of primary table. @@ -397,7 +402,7 @@ const STORE_VAR = "V"; // Generic Vars #const STORE_WIPE_SIP = "W"; // Like SIP, but will remove the entry after reading from STORE_SIP as well as from SESSION-SIP table. const STORE_EXTRA = "X"; // Persistent Store: contains arrays! Used by QFQ system - not used by user. const STORE_SYSTEM = "Y"; // various system values like db connection credentials -const STORE_ZERO = "0"; // value: 0, might helpfull if variable is empty but used in an SQL statement, which might produce a SQL error otherwise if substituted with an empty string +const STORE_ZERO = "0"; // value: 0, might helpful if variable is empty but used in an SQL statement, which might produce a SQL error otherwise if substituted with an empty string const STORE_USE_DEFAULT = "FSRVD"; @@ -482,6 +487,7 @@ const CLIENT_SERVER_NAME = 'SERVER_NAME'; const CLIENT_SERVER_ADDRESS = 'SERVER_ADDR'; const CLIENT_SERVER_PORT = 'SERVER_PORT'; const CLIENT_REMOTE_ADDRESS = 'REMOTE_ADDR'; +const CLIENT_HTTP_X_REAL_IP = 'HTTP_X_REAL_IP'; const CLIENT_REQUEST_SCHEME = 'REQUEST_SCHEME'; const CLIENT_REQUEST_METHOD = 'REQUEST_METHOD'; const CLIENT_SCRIPT_FILENAME = 'SCRIPT_FILENAME'; @@ -522,6 +528,7 @@ const TYPO3_PAGE_DESCRIPTION = 'pageDescription'; const TYPO3_PAGE_KEYWORDS = 'pageKeywords'; const TYPO3_PAGE_NAV_TITLE = 'pageNavTitle'; const TYPO3_VERSION = 't3Version'; +const TYPO3_TOKEN_REPORT_LINE = 'parsed'; const TYPO3_PAGE_LANGUAGE = SESSION_PAGE_LANGUAGE; const TYPO3_PAGE_LANGUAGE_PATH = SESSION_PAGE_LANGUAGE_PATH; @@ -569,6 +576,7 @@ const FORCE_RUN_PAGE_SLUG_MIGRATION_CHECK = 'FORCE_RUN_PAGE_SLUG_MIGRATION_CHECK const SYSTEM_FORM_SUBMIT_LOG_MODE = 'formSubmitLogMode'; const FORM_SUBMIT_LOG_MODE_ALL = 'all'; const FORM_SUBMIT_LOG_MODE_NONE = 'none'; +const FORM_SUBMIT_LOG_MODE_MODIFY = 'modify'; const SYSTEM_DATE_FORMAT = 'dateFormat'; const SYSTEM_DATE_TIME_PICKER_TYPE = 'dateTimePickerType'; @@ -767,12 +775,14 @@ const SYSTEM_REPORT_COLUMN_VALUE = 'reportColumnValue'; // Value of SQL-column p const SYSTEM_REPORT_FULL_LEVEL = 'reportFullLevel'; // Full level of current report row. E.g.: 10.20.1. Used for error reports. const SYSTEM_MESSAGE_DEBUG = 'messageDebug'; const SYSTEM_DOWNLOAD_POPUP = 'hasDownloadPopup'; // Marker which is set to 'true' if there is at least one Download Link rendered +const SYSTEM_REPORT_LINE = 'reportLine'; const DOWNLOAD_POPUP_REQUEST = 'true'; const DOWNLOAD_POPUP_REPLACE_TEXT = '#downloadPopupReplaceText#'; const DOWNLOAD_POPUP_REPLACE_TITLE = '#downloadPopupReplaceTitle#'; const SYSTEM_DRAG_AND_DROP_JS = 'hasDragAndDropJS'; const SYSTEM_SQL_DIRECT_DOWNLOAD = 'sqlDirect'; // becomes sqlDirectdownload.php, sqlDirectdl.php, sqlDirectdl2.php, sqlDirectdl3.php const SYSTEM_EDIT_INLINE_REPORTS = 'editInlineReports'; +const SYSTEM_EDIT_INLINE_REPORT_DARK_THEME = 'editInlineReportDarkTheme'; const SYSTEM_UNIT_TEST_FORM_CONTENT = 'unitTestFormContent'; const SYSTEM_PARAMETER_LANGUAGE_FIELD_NAME = 'parameterLanguageFieldName'; @@ -790,6 +800,7 @@ const SYSTEM_PROTECTED_FOLDER_CHECK = 'protectedFolderCheck'; const EXTRA_ENABLE_SWITCH = 'enableSwitch'; const EXTRA_COLUMN_NAME_AlIAS_SLUG = 'columnNameAliasSlug'; +const EXTRA_FORM_SUBMIT_LOG_ID = 'formSubmitLogId'; const MODE_HTML = 'html'; const MODE_JSON = 'json'; @@ -819,7 +830,6 @@ const SIP_EXCLUDE_XDEBUG_SESSION_START = 'XDEBUG_SESSION_START'; // FURTHER: all extracted params from 'urlparam const ACTION_KEYWORD_SLAVE_ID = 'slaveId'; - const VAR_RANDOM = 'random'; const VAR_FILE_DESTINATION = 'fileDestination'; const VAR_SLAVE_ID = ACTION_KEYWORD_SLAVE_ID; @@ -940,6 +950,7 @@ const API_ELEMENT_CONTENT = 'content'; const API_SUBMIT_REASON = 'submit_reason'; const API_SUBMIT_REASON_SAVE = 'save'; const API_SUBMIT_REASON_SAVE_CLOSE = 'save,close'; +const API_SUBMIT_REASON_SAVE_FORCE = 'save,force'; const API_LOCK_ACTION_LOCK = 'lock'; const API_LOCK_ACTION_EXTEND = 'extend'; @@ -1000,6 +1011,8 @@ const QUESTION_DELETE = 'Do you really want to delete the record?'; const SUBRECORD_COLUMN_DEFAULT_MAX_LENGTH = 20; const SUBRECORD_COLUMN_TITLE_EDIT = 'subrecordColumnTitleEdit'; const SUBRECORD_COLUMN_TITLE_DELETE = 'subrecordColumnTitleDelete'; +const SUBRECORD_EMPTY_TEXT = 'subrecordEmptyText'; +const SUBRECORD_DEFAULT_EMPTY_TEXT = 'Currently no records exist.'; const FORM_ELEMENTS_NATIVE = 'native'; const FORM_ELEMENTS_SUBRECORD = 'subrecord'; const FORM_ELEMENTS_NATIVE_SUBRECORD = 'native_subrecord'; @@ -1370,7 +1383,25 @@ const FE_HIGHLIGHT_MATLAB = 'matlab'; const FE_SQL_VALIDATE = 'sqlValidate'; // Action: Query to validate form load const FE_EXPECT_RECORDS = 'expectRecords'; // Action: expected number of rows of FE_SQL_VALIDATE const FE_MESSAGE_FAIL = 'messageFail'; // Action: Message to display, if FE_SQL_VALIDATE fails. -const FE_REQUIRED_LIST = 'requiredList'; // Optional list of FormElements which have to be non empty to make this 'action'-FormElement active. +const FE_REQUIRED_LIST = 'requiredList'; // Optional list of FormElements which have to be non-empty to make this 'action'-FormElement active. +const FE_QFQ_LOG = 'qfqLog'; +const FE_ALERT = 'alert'; // Action: Replacement of messageFail with additional functionality +const FE_ALERT_INDEX_TEXT = 0; +const FE_ALERT_INDEX_LEVEL = 1; +const FE_ALERT_INDEX_BUTTON_OK = 2; +const FE_ALERT_INDEX_BUTTON_FORCE = 3; +const FE_ALERT_INDEX_TIMEOUT = 4; +const FE_ALERT_INDEX_FLAG_MODAL = 5; +const FE_ALERT_TEXT = 'text'; +const FE_ALERT_LEVEL = 'level'; +const FE_ALERT_BUTTON_OK = 'ok'; +const FE_ALERT_BUTTON_FORCE = 'force'; +const FE_ALERT_TIMEOUT = 'timeout'; +const FE_ALERT_FLAG_MODAL = 'flagModal'; +const DEFAULT_ALERT_LEVEL = 'info'; +const DEFAULT_ALERT_BUTTON_OK = 'Ok'; +const DEFAULT_ALERT_TIMEOUT = '0'; +const DEFAULT_ALERT_FLAG_MODAL = '1'; const FE_SLAVE_ID = 'slaveId'; // Action; Value or Query to compute id of slave record. const FE_SQL_AFTER = 'sqlAfter'; // Action: Always fired const FE_SQL_BEFORE = 'sqlBefore'; // Action: Always fired @@ -1625,9 +1656,31 @@ const FILES_FLAG_DELETE = 'flagDelete'; const UPLOAD_CACHED = '.cached'; const FILE_ACTION = 'action'; const FILE_ACTION_UPLOAD = 'upload'; +const FILE_ACTION_UPLOAD_2 = 'upload2'; const FILE_ACTION_DELETE = 'delete'; const FILE_ACTION_IMAGE_UPLOAD = 'imageUpload'; - +const FILE_ACTION_DOWNLOAD = 'download'; +const UPLOAD_ID = 'uploadId'; +const UPLOAD_TEXT = 'text'; +const UPLOAD_MIME_TYPE_ACCEPT = 'accept'; +const UPLOAD_MULTI_UPLOAD = 'multiUpload'; +const UPLOAD_RECORD_DATA = 'recordData'; +const UPLOAD_MAX_FILES = 'maxFiles'; +const UPLOAD_MAX_FILE_SIZE = SYSTEM_FILE_MAX_FILE_SIZE; +const UPLOAD_ALLOW = 'allowUpload'; +const UPLOAD_DELETE_OPTION = 'deleteOption'; +const UPLOAD_GROUP_ID = 'groupId'; +const UPLOAD_SIP_DOWNLOAD_KEY = 'sipDownloadKey'; +const UPLOAD_DROP_BACKGROUND = 'dropBackground'; +const UPLOAD_DOWNLOAD_BUTTON = 'downloadButton'; +const UPLOAD_TYPE_FORM = 'form'; +const UPLOAD_FORM_ID = 'formId'; +const UPLOAD_PATH_FILE_NAME = 'pathFileName'; +const UPLOAD_IMAGE_EDITOR = 'imageEditor'; +const UPLOAD_PATH_DEFAULT = 'pathDefault'; +const UPLOAD_TYPE = 'uploadType'; +const UPLOAD_TYPE_V1 = 'v1'; +const UPLOAD_TYPE_V2 = 'v2'; const PATH_FILE_CONCAT = 'pathFileConcat'; const FILE_PRIORITY = 'filePriority'; @@ -1650,6 +1703,8 @@ const COLUMN_UID = 'uid'; const COLUMN_HEADER = 'header'; const COLUMN_TITLE = 'title'; const COLUMN_SUBHEADER = 'subheader'; +const COLUMN_EXPIRE = 'expire'; +const COLUMN_UPLOAD_ID = 'uploadId'; const INDEX_PHP = 'index.php'; @@ -1757,6 +1812,9 @@ const TOKEN_DB_INDEX = F_DB_INDEX; const TOKEN_DB_INDEX_LC = 'dbindex'; const TOKEN_CONTENT = 'content'; const TOKEN_REPORT_FILE = 'file'; +const TOKEN_ALIAS = 'alias'; +const TOKEN_NOTATION_NUMERIC = 'numeric'; +const TOKEN_NOTATION_ALIAS = 'alias'; const TOKEN_VALID_LIST = 'sql|function|twig|head|althead|altsql|tail|shead|stail|rbeg|rend|renr|rsep|fbeg|fend|fsep|fskipwrap|rbgd|debug|form|r|debugShowBodyText|dbIndex|sqlLog|sqlLogMode|content|render'; @@ -1777,9 +1835,10 @@ const LINE_ALT_INSERT_ID = 'altInsertId'; //Report: Column Token const COLUMN_LINK = 'link'; +const COLUMN_UPLOAD = 'upload'; const COLUMN_EXEC = 'exec'; const COLUMN_THUMBNAIL = 'thumbnail'; - +const COLUMN_FUNCTION = 'function'; const COLUMN_PPAGE = 'Page'; const COLUMN_PPAGEC = 'Pagec'; const COLUMN_PPAGED = 'Paged'; @@ -1844,6 +1903,8 @@ const COLUMN_STORE_USER = '='; const COLUMN_FORM_JSON = 'formJson'; +const COLUMN_JWT = 'jwt'; + // Author: Enis Nuredini const COLUMN_ENCRYPT = 'encrypt'; const COLUMN_DECRYPT = 'decrypt'; @@ -2079,6 +2140,16 @@ const TOKEN_L_CONTENT_FILE = 'contentFile'; const TOKEN_L_TIMEOUT = 'timeout'; const TOKEN_L_SSL = 'ssl'; +const TOKEN_UPLOAD_ID = UPLOAD_ID; +const TOKEN_UPLOAD_MIME_TYPE_ACCEPT = UPLOAD_MIME_TYPE_ACCEPT; +const TOKEN_SIP_TABLE = SIP_TABLE; +const TOKEN_UPLOAD_MULTI_UPLOAD = 'M'; +const TOKEN_UPLOAD_RECORD_DATA = UPLOAD_RECORD_DATA; +const TOKEN_UPLOAD_MAX_FILES = UPLOAD_MAX_FILES; +const TOKEN_UPLOAD_MAX_FILE_SIZE = UPLOAD_MAX_FILE_SIZE; +const TOKEN_UPLOAD_ALLOW = UPLOAD_ALLOW; +const TOKEN_UPLOAD_DELETE = TOKEN_ACTION_DELETE; +const TOKEN_UPLOAD_IMAGE_EDITOR = UPLOAD_IMAGE_EDITOR; const MONITOR_MODE_APPEND_0 = '0'; const MONITOR_MODE_APPEND_1 = '1'; const MONITOR_SESSION_FILE_SEEK = 'monitor-seek-file'; @@ -2185,6 +2256,7 @@ const EXCEPTION_REPORT_COLUMN_INDEX = 'Report column index'; // Keyname of SQL-c const EXCEPTION_REPORT_COLUMN_NAME = 'Report column name'; // Keyname of SQL-column processed at the moment. const EXCEPTION_REPORT_COLUMN_VALUE = 'Report column value'; // Keyname of SQL-column processed at the moment. const EXCEPTION_REPORT_FULL_LEVEL = 'Report level key'; +const EXCEPTION_REPORT_LINE = 'Report line'; const EXCEPTION_SIP = 'current sip'; const EXCEPTION_PAGE_ID = 'Page Id'; @@ -2364,4 +2436,7 @@ const WIKI_TOKEN_ACCESS_ON = 'on'; // Misc const BASE_URL_FAKE = 'http://i_am_set_in_constants_php/qfq/'; -const HTTP_EXAMPLE_COM = 'http://example.com/'; \ No newline at end of file +const HTTP_EXAMPLE_COM = 'http://example.com/'; + +// Max columnsize FormSubmitLog.formData +const LOG_MAX_FORMDATA = 65535; diff --git a/extension/Classes/Core/Database/Database.php b/extension/Classes/Core/Database/Database.php index 23a5b3add80e8289b5f6832162a62677a45a8af1..6a65c9d065b111d9f5c2401a1af9904f3d15ef04 100644 --- a/extension/Classes/Core/Database/Database.php +++ b/extension/Classes/Core/Database/Database.php @@ -74,7 +74,7 @@ class Database { * @throws \UserFormException * @throws \UserReportException */ - public function __construct($dbIndex = DB_INDEX_DEFAULT) { + public function __construct($dbIndex = DB_INDEX_DEFAULT, $config = array()) { if (empty($dbIndex)) { $dbIndex = DB_INDEX_DEFAULT; @@ -88,6 +88,11 @@ class Database { $this->sqlLogAbsolute = Path::absoluteSqlLogFile(); $dbInit = $storeSystem[SYSTEM_DB_INIT]; + // In case if typo3 database is asked, use given config for credentials instead of system store data. + if (count($config) > 1) { + $storeSystem = $config; + } + $config = $this->getConnectionDetails($dbIndex, $storeSystem); $this->dbName = $config[SYSTEM_DB_NAME]; @@ -397,15 +402,17 @@ class Database { // Author: Enis Nuredini // Get method from query and remove it from the sql variable to get a valid mysqli_stmt. Store given method in system store - $needle = 'AS _encrypt='; - if (mb_strpos($sql, $needle)) { - $preparedSql = explode('AS _encrypt=', $sql); - $sql = $preparedSql[0] . ' AS _encrypt'; - $encryptionMethod = $preparedSql[1]; - $this->store::setVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, $encryptionMethod, STORE_SYSTEM); - } else if ($this->store::getVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, STORE_SYSTEM, SANITIZE_ALLOW_ALL) !== null) { - $this->store::unsetVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, STORE_SYSTEM); - } + // Krzysztof Putyra: commented out, because it breaks SQL statements. + // If this feature is needed, a smart parser must be used +// $needle = 'AS _encrypt='; +// if (mb_strpos($sql, $needle)) { +// $preparedSql = explode('AS _encrypt=', $sql); +// $sql = $preparedSql[0] . ' AS _encrypt'; +// $encryptionMethod = $preparedSql[1]; +// $this->store::setVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, $encryptionMethod, STORE_SYSTEM); +// } else if ($this->store::getVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, STORE_SYSTEM, SANITIZE_ALLOW_ALL) !== null) { +// $this->store::unsetVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, STORE_SYSTEM); +// } // End from author if (false === ($this->mysqli_stmt = $this->mysqli->prepare($sql))) { @@ -609,6 +616,7 @@ class Database { ['tt', TYPO3_TT_CONTENT_UID, STORE_TYPO3], ['level', SYSTEM_REPORT_FULL_LEVEL, STORE_SYSTEM], ['form', SIP_FORM, STORE_SIP], + ['fslId', EXTRA_FORM_SUBMIT_LOG_ID, STORE_EXTRA], ]; $t3msg = ''; @@ -936,7 +944,7 @@ class Database { // Explode and Do $FormElement.parameter HelperFormElement::explodeParameterInArrayElements($feSpecNative, FE_PARAMETER); - // Check for retype FormElements which have to duplicated. + // Check for retype FormElements which have to be duplicated. $feSpecNative = HelperFormElement::duplicateRetypeElements($feSpecNative); // Copy Attributes to FormElements diff --git a/extension/Classes/Core/Evaluate.php b/extension/Classes/Core/Evaluate.php index e4f469630a19c9057e4b1f846b84467d6affe593..c82ba2f867dec33185f9746467b5a649851b4237 100644 --- a/extension/Classes/Core/Evaluate.php +++ b/extension/Classes/Core/Evaluate.php @@ -15,11 +15,11 @@ use IMATHUZH\Qfq\Core\Helper\OnString; use IMATHUZH\Qfq\Core\Helper\Path; use IMATHUZH\Qfq\Core\Helper\Support; use IMATHUZH\Qfq\Core\Report\Link; +use IMATHUZH\Qfq\Core\Report\Report; use IMATHUZH\Qfq\Core\Report\Tablesorter; use IMATHUZH\Qfq\Core\Store\Sip; use IMATHUZH\Qfq\Core\Store\Store; - const EVALUATE_DB_INDEX_DEFAULT = 0; /** * Class Evaluate @@ -52,9 +52,8 @@ class Evaluate { private $endDelimiter = ''; private $endDelimiterLength = 0; private $sqlKeywords = array('SELECT ', 'INSERT ', 'DELETE ', 'UPDATE ', 'SHOW ', 'REPLACE ', 'TRUNCATE ', 'DESCRIBE ', 'EXPLAIN ', 'SET '); - private $escapeTypeDefault = ''; - + private $report = null; // private $debugStack = array(); @@ -297,6 +296,34 @@ class Evaluate { return DND_DATA_DND_API . '="' . Path::urlApi(API_DRAG_AND_DROP_PHP) . '?s=' . $s . '"'; } + /** Execute qfqFunction and output value. Content is accessible in record store + * + * @param $arrToken + * @param $foundInStore + * @return string + * @throws \CodeException + * @throws \UserFormException + * @throws \UserReportException + * @throws \DbException + */ + private function inlineFunction($arrToken, &$foundInStore): string { + $output = ''; + $token = OnString::trimQuote(trim(implode(' ', $arrToken))); + if ($this->report === null) { + $this->report = new Report(array(), $this); + } + + $this->report->doQfqFunction($token); + $foundInStore = TOKEN_FOUND_AS_COLUMN; + + // Check for => in qfqFunction. If not given then output content. + if (!strpos($token, '=>')) { + $output = $this->store::getVar(COLUMN_FUNCTION_OUTPUT, STORE_RECORD); + } + + return $output; + } + /** * Tries to substitute $token. * Token might be: @@ -395,6 +422,10 @@ class Evaluate { $baseUrlAttribute = DATA_TABLESORTER_BASE_URL . "='" . $this->store->getVar(SYSTEM_BASE_URL, STORE_SYSTEM) . "'"; return ($this->tablesorter->inlineTablesorterView($arrToken[VAR_INDEX_VALUE], $foundInStore, $frCmd)) . $baseUrlAttribute; break; + + case COLUMN_FUNCTION: + return ($this->inlineFunction($arrToken, $foundInStore)); + break; default: break; } diff --git a/extension/Classes/Core/Exception/AbstractException.php b/extension/Classes/Core/Exception/AbstractException.php index 84e905b7000f19d9b6c8fdc3ced586d618b8621c..a028633a481ddd2dbd3b164652aedc08cf5f4dcd 100644 --- a/extension/Classes/Core/Exception/AbstractException.php +++ b/extension/Classes/Core/Exception/AbstractException.php @@ -179,7 +179,7 @@ class AbstractException extends \Exception { $arrMerged[ERROR_MESSAGE_TO_DEVELOPER] .= "<br>(inline report editor not available)"; } - $htmlDebug = OnArray::arrayToHtmlTable( + $htmlDebug = '<div class="qfq-debug-detail qfq-alert-hidden">' . OnArray::arrayToHtmlTable( array_merge($arrForm, $arrMerged), 'Debug', EXCEPTION_TABLE_CLASS); @@ -189,11 +189,11 @@ class AbstractException extends \Exception { $hidden = OnArray::arrayToHtmlTable($arrDebugHiddenClean, 'Details', EXCEPTION_TABLE_CLASS); // Show / hide with just CSS: http://jsfiddle.net/t5Nf8/1/ - $htmlDebug .= "<style>input[type=checkbox]:checked + label + table { display: none; }</style>" . - "<input type='checkbox' checked id='stacktrace'><label for='stacktrace'> Show/hide more details</label>$hidden"; + $htmlDebug .= "<hr>$hidden</div>"; } } + $qfqLog = Path::absoluteQfqLogFile(); $arrDebugHidden[EXCEPTION_STACKTRACE] = PHP_EOL . implode(PHP_EOL, $arrTrace); $arrLogAll = array_merge($arrMsg, $arrShow, $arrDebugShow, $arrDebugHidden); @@ -273,7 +273,7 @@ class AbstractException extends \Exception { */ private function formatMessageUser($arrShow) { - $html = '<p><em>' . $arrShow[EXCEPTION_TIMESTAMP] . ', Reference: ' . $arrShow[EXCEPTION_UNIQID] . '</em></p>'; + $html = '<span class="qfq-alert-timestamp">' . $arrShow[EXCEPTION_TIMESTAMP] . '</span><span class="qfq-alert-reference">' . $arrShow[EXCEPTION_UNIQID] . '</span>'; $html .= '<p>' . $arrShow[EXCEPTION_MESSAGE] . '</p>'; return $html; diff --git a/extension/Classes/Core/Exception/DbException.php b/extension/Classes/Core/Exception/DbException.php index 9ee26dec9f50df5b6515a4db06084356f6fe9d63..4eb1d7b8154b1631faada0d0ef4286a04af27fff 100644 --- a/extension/Classes/Core/Exception/DbException.php +++ b/extension/Classes/Core/Exception/DbException.php @@ -61,6 +61,7 @@ class DbException extends AbstractException { $this->messageArrayDebug[EXCEPTION_SQL_FINAL] = Store::getVar(SYSTEM_SQL_FINAL, STORE_SYSTEM); $this->messageArrayDebug[EXCEPTION_SQL_PARAM_ARRAY] = Store::getVar(SYSTEM_SQL_PARAM_ARRAY, STORE_SYSTEM); $this->messageArrayDebug[EXCEPTION_REPORT_FULL_LEVEL] = Store::getVar(SYSTEM_REPORT_FULL_LEVEL, STORE_SYSTEM); + $this->messageArrayDebug[EXCEPTION_REPORT_LINE] = Store::getVar(SYSTEM_REPORT_LINE, STORE_SYSTEM); return parent::formatException(); } diff --git a/extension/Classes/Core/Exception/InfoException.php b/extension/Classes/Core/Exception/InfoException.php new file mode 100644 index 0000000000000000000000000000000000000000..68317a1eda4d770530e903f81816f024d614bc29 --- /dev/null +++ b/extension/Classes/Core/Exception/InfoException.php @@ -0,0 +1,56 @@ +<?php + +/** + * Created by PhpStorm. + * User: jhaller + * Date: 09.11.2023 + * Time: 12:47 PM + */ + +use IMATHUZH\Qfq\Core\Exception\AbstractException; + +/** + * Class \InfoException + * + * Thrown by FormElement on User errors + * + * Throw with ONE message + * + * Throw new \InfoException('Failed: sqlValidate()...'); + * + * Throw with custom message for User. + * + * Call + * + * @package Exception + */ +class InfoException extends AbstractException { + + /** + * $this->getMessage() returns a simple string. + * + * There is only one message: shown in the client to the user - no details (timestamp, reference) here!!! + * + * @return string + * @throws \InfoException + */ + public function formatMessage() { + + return $this->formatException(); + } + + public function formatException() { + + // Get exception message and if JSON, decode it. + $msg = $this->getMessage(); + + return $this->formatMessageUser($msg); + } + + private function formatMessageUser($msg) { + + $html = '<p>' . $msg . '</p>'; + + return $html; + } +} \ No newline at end of file diff --git a/extension/Classes/Core/File.php b/extension/Classes/Core/File.php index 947aad7de715acf91172c45d4ed49e314476e2b4..e20684a1459c3c88ef4c336be2f8cd40f4d5050b 100644 --- a/extension/Classes/Core/File.php +++ b/extension/Classes/Core/File.php @@ -9,11 +9,13 @@ namespace IMATHUZH\Qfq\Core; +use IMATHUZH\Qfq\Core\Database\Database; use IMATHUZH\Qfq\Core\Helper\HelperFile; use IMATHUZH\Qfq\Core\Helper\Logger; use IMATHUZH\Qfq\Core\Helper\Path; use IMATHUZH\Qfq\Core\Helper\Sanitize; use IMATHUZH\Qfq\Core\Helper\Support; +use IMATHUZH\Qfq\Core\Report\Link; use IMATHUZH\Qfq\Core\Store\Session; use IMATHUZH\Qfq\Core\Store\Store; @@ -25,6 +27,11 @@ class File { private $uploadErrMsg = array(); public $imageUploadFilePath = null; + public $uniqueFileId = null; + public $groupId = null; + public $sipTmp = null; + public $newFileName = null; + private $db = null; /** * @var Store @@ -52,6 +59,19 @@ class File { $this->store = Store::getInstance('', $phpUnit); $this->qfqLogPathFilenameAbsolute = Path::absoluteQfqLogFile(); + $this->dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM); + $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM); + + // Default is qfq index + $this->dbIndexFilePond = $this->dbIndexQfq; + $dbIndexSip = $this->store->getVar(TOKEN_DB_INDEX, STORE_SIP . STORE_EMPTY); + if (in_array($dbIndexSip, [$this->dbIndexData, $this->dbIndexQfq])) { + $this->dbIndexFilePond = $dbIndexSip; + } + $this->dbArray[$this->dbIndexFilePond] = new Database($this->dbIndexFilePond); + + $this->sip = $this->store->getSipInstance(); + $this->uploadErrMsg = [ UPLOAD_ERR_INI_SIZE => "The uploaded file exceeds the upload_max_filesize directive in php.ini", UPLOAD_ERR_FORM_SIZE => "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form", @@ -69,7 +89,7 @@ class File { */ public function process() { - $action = $this->store->getVar(FILE_ACTION, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX); + $action = $this->store->getVar(FILE_ACTION, STORE_CLIENT . STORE_SIP, SANITIZE_ALLOW_ALNUMX); $sipUpload = $this->store->getVar(SIP_SIP, STORE_SIP); if ($sipUpload === false && $action != FILE_ACTION_IMAGE_UPLOAD) { @@ -91,10 +111,12 @@ class File { if ($statusUpload === false) { $statusUpload = array(); } + $statusUpload[UPLOAD_SIP_DOWNLOAD_KEY] = $this->store->getVar(UPLOAD_SIP_DOWNLOAD_KEY, STORE_SIP); switch ($action) { case FILE_ACTION_UPLOAD: + case FILE_ACTION_UPLOAD_2: $this->doUpload($sipUpload, $statusUpload); break; case FILE_ACTION_DELETE: @@ -121,7 +143,7 @@ class File { // Checked and set during form build. $maxFileSize = $this->store->getVar(FE_FILE_MAX_FILE_SIZE, STORE_SIP . STORE_ZERO); - if ($size >= $maxFileSize) { + if ($size >= $maxFileSize && $maxFileSize != 0) { throw new \UserFormException('File too big. Max allowed size: ' . $maxFileSize . ' Bytes', ERROR_UPLOAD_TOO_BIG); } } @@ -137,6 +159,9 @@ class File { */ private function doUpload($sipUpload, array $statusUpload) { + // Upload client + $action = $this->store->getVar(FILE_ACTION, STORE_SIP, SANITIZE_ALLOW_ALNUMX); + // New upload $newArr = reset($_FILES); // Merge new upload date to existing status information @@ -151,9 +176,11 @@ class File { Logger::logMessageWithPrefix(UPLOAD_LOG_PREFIX . ': File under ' . $statusUpload['tmp_name'], $this->qfqLogPathFilenameAbsolute); - $this->checkMaxFileSize($statusUpload['size']); + if (!empty($sipUpload)) { + $this->checkMaxFileSize($statusUpload[FILES_SIZE]); + } - $accept = $this->store->getVar(FE_FILE_MIME_TYPE_ACCEPT, STORE_SIP); + $accept = $this->store->getVar(FE_FILE_MIME_TYPE_ACCEPT, STORE_SIP . STORE_EMPTY); if ($accept != '' && !HelperFile::checkFileType($statusUpload['tmp_name'], $statusUpload['name'], $accept)) { throw new \UserFormException('Filetype not allowed. Allowed: ' . $accept, ERROR_UPLOAD_FILE_TYPE); } @@ -163,11 +190,87 @@ class File { // Remember: PHP['upload_tmp_dir']='' means '/tmp' AND upload process is CHROOT to /tmp/systemd-private-...-apache2.service-../ error_clear_last(); - if (!move_uploaded_file($newArr[FILES_TMP_NAME], $filenameCached)) { - $msg = error_get_last(); - throw new \UserFormException( - json_encode([ERROR_MESSAGE_TO_USER => 'Upload: Error', ERROR_MESSAGE_TO_DEVELOPER => $msg]), - ERROR_UPLOAD_FILE_TYPE); + if ($action === FILE_ACTION_UPLOAD_2) { + // Generate a unique file ID + $this->uniqueFileId = $_POST[UPLOAD_ID]; + $fullPath = $_POST[UPLOAD_PATH_FILE_NAME]; + $pathDefault = $_POST[UPLOAD_PATH_DEFAULT]; + $groupId = $_POST['groupId']; + $recordData = $this->store->getVar(UPLOAD_RECORD_DATA, STORE_SIP); + $table = $this->store->getVar(SIP_TABLE, STORE_SIP); + $filename = $statusUpload[FILES_NAME]; + + if ($pathDefault === 'undefined') { + $filenameDummy = basename($fullPath); + $fileParts = pathinfo($filenameDummy); + + if (isset($fileParts['extension'])) { + $filename = $filenameDummy; + $fullPath = dirname($fullPath) . '/'; + } else if (substr($fullPath, -1) !== '/'){ + $fullPath .= '/'; + } + } + + if ($this->uniqueFileId == $groupId) { + $this->uniqueFileId = 0; + } + $filename = Sanitize::safeFilename($filename); + + $fullPath = Path::absoluteApp($fullPath); + $changedFileName = HelperFile::getUniqueFileName($fullPath, $filename); + $fullPath = $fullPath . $changedFileName; + + HelperFile::mkDirParent($fullPath); + if (!move_uploaded_file($newArr[FILES_TMP_NAME], $fullPath)) { + $msg = error_get_last(); + throw new \UserFormException( + json_encode([ERROR_MESSAGE_TO_USER => 'Upload: Error', ERROR_MESSAGE_TO_DEVELOPER => $msg]), + ERROR_UPLOAD_FILE_TYPE); + } + + if ($this->uniqueFileId == 0) { + $insertColumns = ''; + $insertValues = []; + $prepareQuestions = ''; + if ($recordData != '') { + $recordData = json_decode($recordData); + foreach ($recordData as $key => $value) { + $insertColumns .= ',' . $key; + $insertValues[] = $value; + $prepareQuestions .= ',?'; + } + } + $params = array_merge([$fullPath, $groupId, $statusUpload[FILES_SIZE], $statusUpload[FILES_TYPE], 0], $insertValues); + // Do database insert with unique file ID and pathFileName + $dbName = $this->store->getVar(SYSTEM_DB_NAME_QFQ, STORE_SYSTEM); + $sql = "INSERT INTO `$dbName`.`$table` (pathFileName, uploadId, size, type, ord $insertColumns) + VALUES (?,?,?,?,? $prepareQuestions)"; + $this->uniqueFileId = $this->dbArray[$this->dbIndexFilePond]->sql($sql, ROW_REGULAR, $params, "Creating FileUpload failed."); + + if ($groupId == 0) { + $groupId = $this->uniqueFileId; + $sqlUpdate = "UPDATE `$dbName`.`$table` SET uploadId = ? WHERE id = ?"; + $this->dbArray[$this->dbIndexFilePond]->sql($sqlUpdate, ROW_REGULAR, [$this->uniqueFileId, $this->uniqueFileId], "Updating FileUpload failed."); + } + } + $this->groupId = $groupId; + $this->logUpload($this->uniqueFileId); + } else { + if (!move_uploaded_file($newArr[FILES_TMP_NAME], $filenameCached)) { + $msg = error_get_last(); + throw new \UserFormException( + json_encode([ERROR_MESSAGE_TO_USER => 'Upload: Error', ERROR_MESSAGE_TO_DEVELOPER => $msg]), + ERROR_UPLOAD_FILE_TYPE); + } + + // Make currently uploaded file downloadable + $link = new Link($this->sip, $this->dbIndexData); + $sipDownload = $link->renderLink('', 's|d:output|M:file|r:8|F:' . $filenameCached); + $this->sipTmp = $sipDownload; + + // Reset sipDownloadKey after fresh upload + $this->store->setVar($statusUpload[UPLOAD_SIP_DOWNLOAD_KEY], array(), STORE_EXTRA); } $this->store->setVar($sipUpload, $statusUpload, STORE_EXTRA); @@ -182,17 +285,52 @@ class File { * @internal param string $keyStoreExtra */ private function doDelete($sipUpload, $statusUpload) { + $action = $this->store::getVar(FILE_ACTION, STORE_SIP . STORE_EMPTY); + + // Handle FilePond delete + if ($action == FILE_ACTION_DELETE) { + $uploadId = $_POST[UPLOAD_ID]; + $table = $this->store::getVar(SIP_TABLE, STORE_SIP . STORE_EMPTY);; + $allowDelete = $this->store::getVar('allowDelete', STORE_SIP . STORE_EMPTY); + $preloadedFileList = $this->store::getVar('preloadedFileIds', STORE_SIP . STORE_EMPTY); + $preloadedFileArray = explode(',', $preloadedFileList); + $pathFileName = ''; + + // Check if uploadId exists in preloaded files and if it is allowed to delete + if ($allowDelete == '0' && in_array($uploadId, $preloadedFileArray)) { + return; + } - if (isset($statusUpload[FILES_TMP_NAME]) && $statusUpload[FILES_TMP_NAME] != '') { - $file = Support::extendFilename($statusUpload[FILES_TMP_NAME], UPLOAD_CACHED); - if (file_exists($file)) { - HelperFile::unlink($file, $this->qfqLogPathFilenameAbsolute); + // Get path from database + $sqlPath = "SELECT pathFileName FROM `$table` WHERE id = ?"; + $result = $this->dbArray[$this->dbIndexFilePond]->sql($sqlPath, ROW_EXPECT_1, [$uploadId],"File not found in database."); + + if (!empty($result[UPLOAD_PATH_FILE_NAME])) { + $pathFileName = $result[UPLOAD_PATH_FILE_NAME]; } - $statusUpload[FILES_TMP_NAME] = ''; - } - $statusUpload[FILES_FLAG_DELETE] = '1'; - $this->store->setVar($sipUpload, $statusUpload, STORE_EXTRA); + // Delete file from database with unique file id + $sqlPath = "DELETE FROM `$table` WHERE id = ?"; + $this->dbArray[$this->dbIndexFilePond]->sql($sqlPath, ROW_EXPECT_1, [$uploadId],"File not deleted from database."); + + // Delete file from server + HelperFile::unlink($pathFileName); + + $this->logUpload($uploadId, true); + + $this->uniqueFileId = 0; + } else { + if (isset($statusUpload[FILES_TMP_NAME]) && $statusUpload[FILES_TMP_NAME] != '') { + $file = Support::extendFilename($statusUpload[FILES_TMP_NAME], UPLOAD_CACHED); + if (file_exists($file)) { + HelperFile::unlink($file, $this->qfqLogPathFilenameAbsolute); + } + $statusUpload[FILES_TMP_NAME] = ''; + } + + $statusUpload[FILES_FLAG_DELETE] = '1'; + $this->store->setVar($sipUpload, $statusUpload, STORE_EXTRA); + } } /** @@ -274,4 +412,31 @@ class File { // Respond to the successful upload with JSON. return $baseUrl . $imageUploadDir . $neededSlash . $changedFileName; } + + private function logUpload($uploadId, $deleteFlag = false, $uploadType = 'report'): void { + $formName = '_uploadInReport'; + if ($uploadType === 'form') { + $formName = '_uploadInForm'; + } + + $recordId = $uploadId; + $pageId = $this->store->getVar(TYPO3_PAGE_ID, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX); + $sessionId = session_id(); + $feUser = $this->store->getVar(TYPO3_FE_USER, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX); + $clientIp = $this->store->getVar(CLIENT_REMOTE_ADDRESS, STORE_CLIENT . STORE_EMPTY); + $userAgent = $this->store->getVar(CLIENT_HTTP_USER_AGENT, STORE_CLIENT . STORE_EMPTY); + $sipData = json_encode($this->store->getStore(STORE_SIP), JSON_UNESCAPED_UNICODE); + $formData = $deleteFlag ? 'deleted' : 'uploaded'; + $formId = 0; + + $sql = "INSERT INTO `FormSubmitLog` (`formData`, `sipData`, `clientIp`, `feUser`, `userAgent`, `formId`, `formName`, `recordId`, `pageId`, `sessionId`, `created`)" . + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())"; + + $params = [$formData, $sipData, $clientIp, $feUser, $userAgent, $formId, $formName, $recordId, $pageId, $sessionId]; + + $this->dbArray[$this->dbIndexFilePond]->sql($sql, ROW_REGULAR, $params); + $formSubmitLogId = $this->dbArray[$this->dbIndexFilePond]->getLastInsertId(); + $this->store::setVar(EXTRA_FORM_SUBMIT_LOG_ID, $formSubmitLogId, STORE_EXTRA); + + } } \ No newline at end of file diff --git a/extension/Classes/Core/Form/Dirty.php b/extension/Classes/Core/Form/Dirty.php index 457190674aee58a4df82eb765fe74724fee7d4da..6c4c42dc6f0eaeac9720e34da74dda66873ea60e 100644 --- a/extension/Classes/Core/Form/Dirty.php +++ b/extension/Classes/Core/Form/Dirty.php @@ -10,6 +10,7 @@ namespace IMATHUZH\Qfq\Core\Form; use IMATHUZH\Qfq\Core\Database\Database; use IMATHUZH\Qfq\Core\Helper\OnArray; +use IMATHUZH\Qfq\Core\Helper\Support; use IMATHUZH\Qfq\Core\Store\Client; use IMATHUZH\Qfq\Core\Store\Session; use IMATHUZH\Qfq\Core\Store\Sip; @@ -67,7 +68,7 @@ class Dirty { $this->client[DIRTY_RECORD_HASH_MD5] = ''; } $this->doDbArray($dbIndexData, $dbIndexQfq); - + $this->store = Store::getInstance(); } /** @@ -119,7 +120,6 @@ class Dirty { return [API_STATUS => 'success', API_MESSAGE => '']; } - $this->store = Store::getInstance(); $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM); $this->dbIndexData = empty($sipVars[PARAM_DB_INDEX_DATA]) ? $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM) : $sipVars[PARAM_DB_INDEX_DATA]; @@ -180,13 +180,15 @@ class Dirty { if ($formDirtyMode == DIRTY_MODE_NONE) { $answer = [API_STATUS => 'success', API_MESSAGE => '']; } else { - // No dirty record found. + // No dirty record found: create lock $answer = $this->writeDirty($this->client[SIP_SIP], $recordId, $tableVars, $feUser, $rcMd5, $tabUniqId); } } else { if ($tabUniqId == $recordDirty[TAB_UNIQ_ID]) { + // In case it's the same tab (page reload): OK $answer = [API_STATUS => 'success', API_MESSAGE => '']; } else { + // Here is probably a conflict. $answer = $this->conflict($recordDirty, $formDirtyMode, $primaryKey); } } @@ -221,6 +223,7 @@ class Dirty { } /** + * Aquire lock conflict detected * * @param array $recordDirty * @param string $currentFormDirtyMode @@ -233,17 +236,20 @@ class Dirty { */ private function conflict(array $recordDirty, $currentFormDirtyMode, $primaryKey) { $status = API_ANSWER_STATUS_CONFLICT; - $at = "at " . $recordDirty[COLUMN_CREATED] . " from " . $recordDirty[DIRTY_REMOTE_ADDRESS]; + $until = "until " . date_format(date_create($recordDirty[COLUMN_EXPIRE]), "d.m.Y H:i:s"); - // Compare modified timestamp + // Compare modified timestamp: in case there is a lock conflict and current form is based on outdated data: force reload. if ($this->isRecordModified($recordDirty[DIRTY_TABLE_NAME], $primaryKey, $recordDirty[DIRTY_RECORD_ID], $recordDirty[DIRTY_RECORD_HASH_MD5], $dummy)) { - return [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => 'The record has been modified in the meantime. Please reload the form, edit and save again. [2]']; + return [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => 'The record has been modified in the meantime (your changes are lost). Please reload the form, edit and save again.']; } - if ($this->client[CLIENT_COOKIE_QFQ] == $recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE]) { - $msg = "The record has already been locked by you (maybe in another browser tab) $at!"; - $status = ($recordDirty[F_DIRTY_MODE] == DIRTY_MODE_EXCLUSIVE) ? API_ANSWER_STATUS_CONFLICT : API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE; + // Conflict for same user / same QFQ Session: the user can force aquire lock. + // Hint: after this, the form with the first lock still thinks it has the lock - that one will get a 'record modified in the meantime' on save. + $userMatch = ($recordDirty[DIRTY_FE_USER] != '' && $recordDirty[DIRTY_FE_USER] == $this->store->getVar(TYPO3_FE_USER, STORE_TYPO3)); + if ($userMatch || $this->client[CLIENT_COOKIE_QFQ] == $recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE]) { + $msg = "Record already locked (by you)"; + $status = API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE; } else { if (empty($recordDirty[DIRTY_FE_USER])) { @@ -252,7 +258,7 @@ class Dirty { $msgUser = "user '" . $recordDirty[DIRTY_FE_USER] . "'"; } - $msg = "The record has already been locked by $msgUser at $at."; + $msg = "Record already locked by $msgUser $until."; // Mandatory lock on Record or current Form? if ($recordDirty[F_DIRTY_MODE] == DIRTY_MODE_EXCLUSIVE || $currentFormDirtyMode == DIRTY_MODE_EXCLUSIVE) { @@ -292,6 +298,8 @@ class Dirty { # Dirty workaround: setting the 'expired timestamp' minus 1 second guarantees that the client ask for relock always if the timeout is expired. $expire = date('Y-m-d H:i:s', strtotime("+" . $tableVars[F_RECORD_LOCK_TIMEOUT_SECONDS] - 1 . " seconds")); // Write 'dirty' record + + $userAgent = $this->store->getVar(CLIENT_HTTP_USER_AGENT, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX); $this->dbArray[$this->dbIndexQfq]->sql("INSERT INTO `Dirty` (`sip`, `tableName`, `recordId`, `expire`, `recordHashMd5`, `tabUniqId`, `feUser`, `qfqUserSessionCookie`, `dirtyMode`, `remoteAddress`, `created`) " . "VALUES ( ?,?,?,?,?,?,?,?,?,?,? )", ROW_REGULAR, [$s, $tableName, $recordId, $expire, $recordHashMd5, $tabUniqId, $feUser, $this->client[CLIENT_COOKIE_QFQ], $formDirtyMode, @@ -366,20 +374,18 @@ class Dirty { return LOCK_NOT_FOUND; } - if ($recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE] == $this->client[CLIENT_COOKIE_QFQ]) { + $msgUser = (empty($recordDirty[DIRTY_FE_USER])) ? "another user" : "user '" . $recordDirty[DIRTY_FE_USER] . "'"; + $rc = LOCK_FOUND_CONFLICT; + + $userMatch = ($recordDirty[DIRTY_FE_USER] != '' && $recordDirty[DIRTY_FE_USER] == $this->store->getVar(TYPO3_FE_USER, STORE_TYPO3)); + if ($userMatch || $recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE] == $this->client[CLIENT_COOKIE_QFQ]) { $msgUser = "you"; - } else { - $msgUser = (empty($recordDirty[DIRTY_FE_USER])) ? "another user" : "user '" . $recordDirty[DIRTY_FE_USER] . "'"; + $rc = LOCK_FOUND_OWNER; } - $msgAt = "at " . $recordDirty[COLUMN_CREATED] . " from " . $recordDirty[DIRTY_REMOTE_ADDRESS]; - $msg = "The record has been locked by $msgUser $msgAt"; + $until = "until " . date_format(date_create($recordDirty[COLUMN_EXPIRE]), "d.m.Y H:i:s"); + $msg = "The record has been locked by $msgUser $until."; - // Is the dirtyRecord mine? - if ($recordDirty[DIRTY_QFQ_USER_SESSION_COOKIE] == $this->client[CLIENT_COOKIE_QFQ]) { - return LOCK_FOUND_OWNER; - } else { - return LOCK_FOUND_CONFLICT; - } + return $rc; } /** diff --git a/extension/Classes/Core/Form/FormAction.php b/extension/Classes/Core/Form/FormAction.php index 353844d372e4f7106062c4f214807294c1bbf6ad..7fafa677f3baaade12646c314344989843282199 100644 --- a/extension/Classes/Core/Form/FormAction.php +++ b/extension/Classes/Core/Form/FormAction.php @@ -123,7 +123,6 @@ class FormAction { * @throws \DbException * @throws \DownloadException * @throws \UserFormException - * @throws \UserReportException * @throws \PhpOffice\PhpSpreadsheet\Exception * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception @@ -212,7 +211,10 @@ class FormAction { $this->store->setStore($arr, STORE_LDAP, true); } - HelperFormElement::sqlValidate($this->evaluate, $fe); + // sqlValidate should not be checked if submit_reason = "save,force" + if (API_SUBMIT_REASON_SAVE_FORCE !== $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX )) { + HelperFormElement::sqlValidate($this->evaluate, $fe); + } if ($fe[FE_TYPE] === FE_TYPE_SENDMAIL) { $this->doSendMail($fe); diff --git a/extension/Classes/Core/Form/TypeAhead.php b/extension/Classes/Core/Form/TypeAhead.php index afec74b071e3ae3f0c2be5f128693f79fd70642b..a4020472294a82367f898112a6145e4702f75c99 100644 --- a/extension/Classes/Core/Form/TypeAhead.php +++ b/extension/Classes/Core/Form/TypeAhead.php @@ -70,13 +70,14 @@ class TypeAhead { $sipVars = $sipClass->getVarsFromSip($this->vars[TYPEAHEAD_API_SIP]); // Check for an optional given dbIndex: '[<int>]SELECT ...' - $sql = $sipVars[FE_TYPEAHEAD_SQL]; - if ($sql[0] === '[') { + $sql = $sipVars[FE_TYPEAHEAD_SQL] ?? ''; + if (($sql[0] ?? '') === '[') { $pos = strpos($sql, ']'); $dbIndex = substr($sql, 1, $pos - 1); $sipVars[FE_TYPEAHEAD_SQL] = substr($sql, $pos + 1); } + $this->db = new Database($dbIndex); if (isset($sipVars[FE_TYPEAHEAD_SQL])) { diff --git a/extension/Classes/Core/Helper/HelperFile.php b/extension/Classes/Core/Helper/HelperFile.php index b709f90ea2a7d26cf7574bff9da92811720add6d..2c27f5da010f883d254f1efae787edca0f4eb7b3 100644 --- a/extension/Classes/Core/Helper/HelperFile.php +++ b/extension/Classes/Core/Helper/HelperFile.php @@ -589,9 +589,9 @@ class HelperFile { * @param $content * @throws \UserFormException */ - public static function file_put_contents($pathFileName, $content) // : void + public static function file_put_contents($pathFileName, $content, $flag = 0) // : void { - $success = file_put_contents($pathFileName, $content); + $success = file_put_contents($pathFileName, $content, $flag); if ($success === false) { throw new \UserFormException(json_encode([ ERROR_MESSAGE_TO_USER => "Writing file failed.", diff --git a/extension/Classes/Core/Helper/HelperFormElement.php b/extension/Classes/Core/Helper/HelperFormElement.php index e2a485b63559a18842c492cb1fc40f092dd5044a..bdd88c9ee2015bd350449e71bfcf46b740379b06 100644 --- a/extension/Classes/Core/Helper/HelperFormElement.php +++ b/extension/Classes/Core/Helper/HelperFormElement.php @@ -11,7 +11,6 @@ namespace IMATHUZH\Qfq\Core\Helper; use IMATHUZH\Qfq\Core\Evaluate; use IMATHUZH\Qfq\Core\Store\Store; - /** * Class HelperFormElement * @package qfq @@ -86,6 +85,11 @@ class HelperFormElement { // Something to explode? if (isset($element[$keyName]) && $element[$keyName] !== '') { + + self::$store = Store::getInstance(); + self::$store->setVar(SYSTEM_FORM_ELEMENT_ID, $element[FE_ID], STORE_SYSTEM); + self::$store->setVar(FORM_NAME_FORM_ELEMENT, $element[FE_ID] . ' / ' . $element[FE_NAME] . ' / ', STORE_SYSTEM); + // Explode $arr = KeyValueStringParser::parse($element[$keyName], "=", "\n"); @@ -97,7 +101,7 @@ class HelperFormElement { self::$store = Store::getInstance(); self::$store->setVar(SYSTEM_FORM_ELEMENT, Logger::formatFormElementName($element), STORE_SYSTEM); self::$store->setVar(SYSTEM_FORM_ELEMENT_COLUMN, $keyName, STORE_SYSTEM); - throw new \UserFormException("Found reserved keyname '$checkKey'", ERROR_RESERVED_KEY_NAME); + throw new \UserFormException("Found reserved key name '$checkKey'", ERROR_RESERVED_KEY_NAME); } } } @@ -167,7 +171,7 @@ class HelperFormElement { /** * Build the FE id: <$formId>-<$formElementId>-<$formElementCopy> - * Attention: Radio's get's an additional index count as fourth parameter (not here). + * Attention: Radio's gets an additional index count as fourth parameter (not here). * * @param $formId * @param $formElementId @@ -208,7 +212,7 @@ class HelperFormElement { } /** - * Checkboxen, belonging to one element, grouped together by name: <fe>_<field>_<index> + * Checkboxes, belonging to one element, grouped together by name: <fe>_<field>_<index> * * @param string $field * @param string $index @@ -263,7 +267,7 @@ class HelperFormElement { unset($fe[FE_RETYPE_NOTE]); } - $fe[FE_TG_INDEX] = 1; // Not sure if this is helpfull in case of dynamic update - but it will make the element uniqe. + $fe[FE_TG_INDEX] = 1; // Not sure if this is helpful in case of dynamic update - but it will make the element unique. unset($fe[FE_RETYPE]); $arr[] = $fe; @@ -315,7 +319,7 @@ class HelperFormElement { public static function initActionFormElement(array $fe) { $list = [FE_TYPE, FE_SQL_VALIDATE, FE_SLAVE_ID, FE_SQL_BEFORE, FE_SQL_INSERT, FE_SQL_UPDATE, FE_SQL_DELETE, - FE_SQL_AFTER, FE_EXPECT_RECORDS, FE_REQUIRED_LIST, FE_MESSAGE_FAIL, FE_SENDMAIL_TO, FE_SENDMAIL_CC, + FE_SQL_AFTER, FE_EXPECT_RECORDS, FE_REQUIRED_LIST, FE_ALERT, FE_QFQ_LOG, FE_MESSAGE_FAIL, FE_SENDMAIL_TO, FE_SENDMAIL_CC, FE_SENDMAIL_BCC, FE_SENDMAIL_FROM, FE_SENDMAIL_SUBJECT, FE_SENDMAIL_REPLY_TO, FE_SENDMAIL_FLAG_AUTO_SUBMIT, FE_SENDMAIL_GR_ID, FE_SENDMAIL_X_ID, FE_SENDMAIL_X_ID2, FE_SENDMAIL_X_ID3, FE_SENDMAIL_BODY_MODE, FE_SENDMAIL_BODY_HTML_ENTITY, FE_SENDMAIL_SUBJECT_HTML_ENTITY]; @@ -552,7 +556,7 @@ EOF; $classArr[FE_NOTE] = CSS_REQUIRED_RIGHT; break; default: - throw new \UserFormException('Unkown value for ' . F_FE_REQUIRED_POSITION . ': ' . $requiredPosition, ERROR_INVALID_VALUE); + throw new \UserFormException('Unknown value for ' . F_FE_REQUIRED_POSITION . ': ' . $requiredPosition, ERROR_INVALID_VALUE); } return $classArr; @@ -601,7 +605,7 @@ EOF; self::$store = Store::getInstance(); - // Call getItemsForEnumOrSet() only if there a corresponding column really exist. + // Call getItemsForEnumOrSet() only if a corresponding column really exists. if (false !== self::$store->getVar($formElement[FE_NAME], STORE_TABLE_COLUMN_TYPES)) { $itemValue = self::getItemsForEnumOrSet($formElement[FE_NAME], $fieldType); } @@ -884,6 +888,7 @@ EOF; * @throws \DbException * @throws \UserFormException * @throws \UserReportException + * @throws \InfoException */ public static function sqlValidate(Evaluate $evaluate, array $fe) { @@ -897,10 +902,28 @@ EOF; } $expect = $evaluate->parse($fe[FE_EXPECT_RECORDS]); - if ($fe[FE_MESSAGE_FAIL] === '') { - throw new \UserFormException("Missing parameter '" . FE_MESSAGE_FAIL . "'", ERROR_MISSING_MESSAGE_FAIL); + // ToDo: how to go about the change from messageFail to alert? messageFail is still supported at the moment. + if ($fe[FE_ALERT] === '' && $fe[FE_MESSAGE_FAIL] === '') { + throw new \UserFormException("Missing parameter '" . FE_ALERT . "'", ERROR_MISSING_ALERT); + } else if ($fe[FE_ALERT] !== '' && $fe[FE_MESSAGE_FAIL] !== '') { + throw new \UserFormException("Simultaneous use of parameter '" . FE_ALERT . "' and '" . FE_MESSAGE_FAIL . "'. It is recommended to use '" . FE_ALERT . "', '" . FE_MESSAGE_FAIL . "' will no longer be maintained.", ERROR_DOUBLE_USAGE_ALERT_AND_MESSAGE_FAIL); } + // Replace possible dynamic parts + $alert = $evaluate->parse($fe[FE_ALERT]); + + $arr = OnArray::explodeWithoutEscaped(':', $alert); + $arr = array_merge($arr, ['', '', '', '', '', '']); + + $text = ($arr[FE_ALERT_INDEX_TEXT] === '') ? $fe[FE_MESSAGE_FAIL] : $arr[FE_ALERT_INDEX_TEXT]; + $level = ($arr[FE_ALERT_INDEX_LEVEL] === '') ? DEFAULT_ALERT_LEVEL : $arr[FE_ALERT_INDEX_LEVEL]; + $ok = ($arr[FE_ALERT_INDEX_BUTTON_OK] === '') ? DEFAULT_ALERT_BUTTON_OK : $arr[FE_ALERT_INDEX_BUTTON_OK]; + $force = $arr[FE_ALERT_INDEX_BUTTON_FORCE]; + $timeout = ($arr[FE_ALERT_INDEX_TIMEOUT] === '') ? DEFAULT_ALERT_TIMEOUT : $arr[FE_ALERT_INDEX_TIMEOUT] * 1000; + $flagModalStatus = ($arr[FE_ALERT_INDEX_FLAG_MODAL] === '') ? DEFAULT_ALERT_FLAG_MODAL : $arr[FE_ALERT_INDEX_FLAG_MODAL]; + $flagModal = $flagModalStatus === '1'; + $qfqLog = $fe[FE_QFQ_LOG] !== '0'; + // Do the check $result = $evaluate->parse($fe[FE_SQL_VALIDATE], ROW_REGULAR); if (!is_array($result)) { @@ -915,12 +938,28 @@ EOF; } } - $msg = $evaluate->parse($fe[FE_MESSAGE_FAIL]); // Replace possible dynamic parts + $msg = $evaluate->parse($text); // Replace possible dynamic parts in case messageFail is used + + self::$store = Store::getInstance(); + self::$store->setVar(FE_ALERT_TEXT, $text, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_LEVEL, $level, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_BUTTON_OK, $ok, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_BUTTON_FORCE, $force, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_TIMEOUT, $timeout, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_FLAG_MODAL, $flagModal, STORE_SYSTEM); + self::$store->setVar(SYSTEM_SHOW_DEBUG_INFO, SYSTEM_SHOW_DEBUG_INFO_AUTO, STORE_SYSTEM); // No debug info // Throw user error message - throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => $msg - , ERROR_MESSAGE_TO_DEVELOPER => "validate() failed.\nSQL Raw: " . $fe[FE_SQL_VALIDATE]]) - , ERROR_REPORT_FAILED_ACTION); + if ($qfqLog) { + // Error including timestamp and reference (logged) + throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => $msg + , ERROR_MESSAGE_TO_DEVELOPER => "validate() failed.\nSQL Raw: " . $fe[FE_SQL_VALIDATE]]) + , ERROR_REPORT_FAILED_ACTION); + } else { + + // Plain message (not logged) + throw new \InfoException($msg); + } } } \ No newline at end of file diff --git a/extension/Classes/Core/Helper/OnArray.php b/extension/Classes/Core/Helper/OnArray.php index 9191ff6b8b4f82012b67fdd4c4a9d4201ff7856d..1625ffdd0fb9e95c1682fcd3dbda0be349abf237 100644 --- a/extension/Classes/Core/Helper/OnArray.php +++ b/extension/Classes/Core/Helper/OnArray.php @@ -138,7 +138,9 @@ class OnArray { */ public static function htmlentitiesOnArray(array $arr) { foreach ($arr as $key => $value) { - $arr[$key] = htmlentities($arr[$key], ENT_QUOTES); + + // Error message stays the same + if ($key !== EXCEPTION_MESSAGE) $arr[$key] = htmlentities($arr[$key], ENT_QUOTES); } return $arr; diff --git a/extension/Classes/Core/Helper/OnString.php b/extension/Classes/Core/Helper/OnString.php index a56742fde221b12e14be8f39b9fa83d7b0717a63..87c099be40a5747bcaa64576a117903e7b74ba97 100644 --- a/extension/Classes/Core/Helper/OnString.php +++ b/extension/Classes/Core/Helper/OnString.php @@ -53,9 +53,8 @@ class OnString { * @param $content * @return array|string|string[]|null */ - public static function strReplaceFirst($from, $to, $content) - { - $from = '/'.preg_quote($from, '/').'/'; + public static function strReplaceFirst($from, $to, $content) { + $from = '/' . preg_quote($from, '/') . '/'; return preg_replace($from, $to, $content, 1); } @@ -504,8 +503,8 @@ class OnString { break; // Author: Enis Nuredini case TOKEN_ESCAPE_ENCRYPT: - if(isset($encryptionToken) && $encryptionToken !== '') { - if(EncryptDecrypt::checkForValidEncryptMethod($encryptionToken)) { + if (isset($encryptionToken) && $encryptionToken !== '') { + if (EncryptDecrypt::checkForValidEncryptMethod($encryptionToken)) { $encryptionMethod = $encryptionToken; } else { throw new \UserReportException("Invalid encryption method used in variable", ERROR_VARIABLE_INVALID_ENCRYPTION_METHOD); @@ -791,5 +790,80 @@ class OnString { return $params; } + /** + * Very specific function: converts $arr to JSON and checks if it exceeds $max. + * If it exceeds, search for the biggest element, replace the content by ERROR_MSG_TOO_BIG. + * Repeat that, until it's below $max + * + * @param array $arr + * @param $max + * @return array|mixed + */ + + public static function limitSizeJsonEncode(array $arr, $currentLength, $max) { + $maxValue = 0; + $maxKey = ''; + $origArrSize = 0; + $replaceLength = strlen(ERROR_MSG_TOO_BIG); + + // Stop searching + if ($max == 0 || empty($arr)) { + return array(); + } + + // Search the biggest element. + foreach ($arr as $key => $value) { + + if (is_array($value)) { + // Radios might be delivered as Array: pseudo flatten by misusing json_encode() + $value = json_encode($value); + } + + $len = strlen($value); +// $origArrSize+=$len; + if ($len > $maxValue) { + $maxValue = strlen($value); + $maxKey = $key; + } + } + + // Found a biggest element + if ($maxKey == '') { + throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => "No biggest element found", + ERROR_MESSAGE_TO_DEVELOPER => "Strange: element is to big ($origArrSize) but found no 'biggest' element."]), ERROR_MISSING_OPEN_DELIMITER); + } else { + // Copy all elements, replace the biggest + foreach ($arr as $key => $value) { + if ($key == $maxKey) { + + // Check that the payload is bigger than ERROR_MSG_TOO_BIG + if (!is_array($arr[$key]) && (strlen($arr[$key]) < $replaceLength)) { + throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => "Can't shrink array", + ERROR_MESSAGE_TO_DEVELOPER => "Makes no sense: the replacement[$replaceLength] is bigger than the payload[" . strlen($arr[$key]) . ']']), ERROR_MISSING_OPEN_DELIMITER); + } + $arrNew[$key] = ERROR_MSG_TOO_BIG; + } else { + $arrNew[$key] = $value; + } + } + + $newLen = strlen(json_encode($arrNew, JSON_UNESCAPED_UNICODE)); + + // Final Size still too big? + if ($newLen > $maxValue) { + + // Detect infinite loop. + if ($newLen >= $currentLength) { + throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => "Can't shrink array", + ERROR_MESSAGE_TO_DEVELOPER => "max: $max, current: " . $currentLength . ", new reduced: " . $newLen]), ERROR_MISSING_OPEN_DELIMITER); + } else { + // Dive deeper to replace the next biggest element. + $arrNew = self::limitSizeJsonEncode($arrNew, $newLen, $maxValue); + } + } + } + + return $arrNew; + } } diff --git a/extension/Classes/Core/Helper/Support.php b/extension/Classes/Core/Helper/Support.php index 3495cd3b8634bcd6ff3baa934aeab8b5711550e8..4dbcbc2f731b6f795ba2eef7f436bb0b12ff699d 100644 --- a/extension/Classes/Core/Helper/Support.php +++ b/extension/Classes/Core/Helper/Support.php @@ -152,7 +152,7 @@ class Support { public static function arrayToQueryString(array $queryArray) { $items = array(); - // CR/Workaround for broken behaviour: LINK() class expects 'id' always as first paramter + // CR/Workaround for broken behaviour: LINK() class expects 'id' always as first parameter // Take care that Parameter 'id' is the first one in the array: if (isset($queryArray[CLIENT_PAGE_ID])) { $id = $queryArray[CLIENT_PAGE_ID]; @@ -290,7 +290,7 @@ class Support { switch (strtolower($type)) { case 'size': case 'maxlength': - // empty or '0' for attributes of type 'size' or 'maxlength' result in unsuable input elements: skip this. + // empty or '0' for attributes of type 'size' or 'maxlength' result in unusable input elements: skip this. if ($value === '' || $value == 0) { return ''; } @@ -378,7 +378,7 @@ class Support { } /** - * Search for the parameter $needle in $haystack. The arguments has to be separated by ','. + * Search for the parameter $needle in $haystack. The arguments have to be separated by ','. * * Returns false if not found, or index (starting with 0) of found place. Be careful: use unary operator to compare for 'false' * @@ -630,7 +630,7 @@ class Support { } /** - * Returns a representation of 0 in a choosen variant. + * Returns a representation of 0 in a chosen variant. * * @param string $dateFormat FORMAT_DATE_INTERNATIONAL | FORMAT_DATE_GERMAN * @param string $showZero @@ -718,7 +718,7 @@ class Support { $placeholder = $timePattern; break; default: - throw new \UserFormException("Unexpected Formelement type: '" . $formElement[FE_TYPE] . "'", ERROR_FORMELEMENT_TYPE); + throw new \UserFormException("Unexpected form element type: '" . $formElement[FE_TYPE] . "'", ERROR_FORMELEMENT_TYPE); } return $placeholder; @@ -726,7 +726,7 @@ class Support { /** - * Encrypt curly braces by an uncommon string. Helps preventing unwished action on curly braces. + * Encrypt curly braces by an uncommon string. Helps to prevent unwanted actions on curly braces. * * @param string $text * @@ -740,7 +740,7 @@ class Support { } /** - * Decrypt curly braces by an uncommon string. Helps preventing unwished action on curly braces + * Decrypt curly braces by an uncommon string. Helps to prevent unwanted actions on curly braces * * @param string $text * @@ -760,7 +760,7 @@ class Support { /** * Creates a random string, starting with uniq microseconds timestamp. - * After a discussion of ME & CR, the uniqid() should be sufficient to guarantee uniqness. + * After a discussion of ME & CR, the uniqid() should be sufficient to guarantee uniqueness. * * @param int $length Length of the required hash string * @@ -782,7 +782,7 @@ class Support { } /** - * Concatenate URL and Parameter. Depending of if there is a '?' in URL or not, append the param with '?' or '&'.. + * Concatenate URL and Parameter. Depending on if there is a '?' in URL or not, append the param with '?' or '&'.. * * @param string $url * @param string|array $param @@ -1316,7 +1316,7 @@ class Support { /** * Check $arr, if there is an element $index. If not, set it to $value. - * If $overwriteThis!=false, replace the the original value with $value, if $arr[$index]==$overwriteThis. + * If $overwriteThis!=false, replace the original value with $value, if $arr[$index]==$overwriteThis. * * @param array $arr * @param string $index diff --git a/extension/Classes/Core/Parser/KVPairListParser.php b/extension/Classes/Core/Parser/KVPairListParser.php new file mode 100644 index 0000000000000000000000000000000000000000..88a5eb8c57016c82f59a5b5b758306607940d609 --- /dev/null +++ b/extension/Classes/Core/Parser/KVPairListParser.php @@ -0,0 +1,92 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * Class KVPairListParser + * + * A parser for lists of key-value pairs with simple values. + * + * @package qfq + */ +class KVPairListParser extends SimpleParser { + + /** @var string the separator for different pairs */ + private string $listsep; + + /** @var string the character separating a key from its value */ + private string $kvsep; + + + public function __construct(string $listsep, string $kvsep, array $options = []) { + parent::__construct("$listsep$kvsep", $options); + $this->listsep = $listsep; + $this->kvsep = $kvsep; + } + + /** + * Iterates over the string and returns keys with values. Keys are always + * treated as strings and only values are processed and, when necessary, + * converted to numbers or special values. + * + * Usage: + * + * foreach($parser->iterate($input) as $key => $value) { + * ... + * } + * + * Examples + * pair separator: | + * key-value separator: = + * + * a=43|b=15 --> 'a'=>43 'b'=>15 + * a='x|y' | 13=87 --> 'a'=>'x|y' '13'=>87 + * + * @param string $data + * @return \Generator + */ + public function iterate(string $data): \Generator { + // Iterate over token provided by the base tokenizer class + $tokens = $this->tokenized($data); + while ($tokens->valid()) { + // Get the key first + list($keyToken, $delimiter) = $tokens->current(); + $key = strval($keyToken); + $tokens->next(); + if ($delimiter == $this->kvsep) { + // The key-value separator is found - find the corresponding value + if ($tokens->valid()) { + list($valueToken, $delimiter) = $tokens->current(); + $tokens->next(); + $empty = $valueToken->empty(); + $value = $this->process($valueToken); + } else { + // The end of the string - the value is empty + $delimiter = null; + $empty = true; + } + // Replace an empty token with the empty value + yield $key => $empty ? ( + $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE] + ) : $value; + } elseif ($key) { + // When no key-value separator, then do nothing it the key is empty + // In other words: ignore trailing commas + yield $key => $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE]; + } + // Check if the current delimiter is a correct one + if ($delimiter && $delimiter != $this->listsep) { + $this->raiseUnexpectedDelimiter( + $delimiter, + $this->offset(), + $this->listsep + ); + } + }; + } +} \ No newline at end of file diff --git a/extension/Classes/Core/Parser/MixedTypeParser.php b/extension/Classes/Core/Parser/MixedTypeParser.php new file mode 100644 index 0000000000000000000000000000000000000000..fa72870883ae16a9c297f6e85720b1ab83edc131 --- /dev/null +++ b/extension/Classes/Core/Parser/MixedTypeParser.php @@ -0,0 +1,239 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + + +/** + * Class MixedTypeParser + * + * A parser for lists and dictionaries that can be nested. + * Requires a string of six delimiters provided in this order: + * - separator for list items + * - key-value separator + * - two delimiters (begin and end) for lists + * - two delimiters (begin and end) for dictionaries + * The parser can be restricted to only nested lists or only + * nested dictionaries by providing a space instead of the + * corresponding delimiters. + * + * @package qfq + */ +class MixedTypeParser extends SimpleParser { + + // Internally used constants + const SEP = 0; + const KVSEP = 1; + const LIST_START = 2; + const LIST_END = 3; + const DICT_START = 4; + const DICT_END = 5; + + /** @var string delimiters used by this parser */ + private string $delimiters = ',:[]{}'; + + + public function __construct(?string $delimiters = null, array $options = []) { + if ($delimiters) { + $this->delimiters = str_pad($delimiters, 6); + $delimiters = str_replace(' ', '', $delimiters); + } else { + $delimiters = $this->delimiters; + } + parent::__construct($delimiters, $options); + } + + /** + * Parses the provided string into a literal value, a non-associative array, + * or an associative array. The structures can be nested. + * + * @param string $data + * @return mixed + */ + public function parse(string $data) { + $tokens = $this->tokenized($data); + if ($tokens->valid()) { + list($data, $empty, $delimiter) = $this->parseImpl($tokens); + if ($delimiter) $this->raiseUnexpectedDelimiter($delimiter, $this->offset()); + return $data; + } else { + return $this->options[self::OPTION_EMPTY_VALUE]; + } + } + + /** + * Assumes the provided string is a list of items and parses + * it into a non-associative array (possibly empty or with only + * one element). + * + * @param string $data + * @return array + */ + public function parseList(string $data): array { + $tokens = $this->tokenized($data); + if ($tokens->valid()) { + return $this->parseListImpl($tokens, null)[0]; + } else { + return []; + } + } + + /** + * Assumes that the provided string is a dictionary (i.e. a list + * of key-value pairs) and parses it into an associative array. + * + * @param string $data + * @return array + */ + public function parseDictionary(string $data): array { + $tokens = $this->tokenized($data); + if ($tokens->valid()) { + return $this->parseDictionaryImpl($tokens, null)[0]; + } else { + return []; + } + } + + + /** + * The main method of the parser. It looks on the first token + * and decides on the following action based on the delimiter + * and the value of the token. + * + * @param \Generator $tokens + * @return array + */ + protected function parseImpl(\Generator $tokens): array { + // Get a token and the bounding delimiter + $tokenData = $tokens->current(); + $tokens->next(); + list($token, $delimiter) = $tokenData; + $empty = $token->empty(); + switch ($delimiter) { + case $this->delimiters[self::DICT_START]: + // The opening delimiter of a dictionary cannot be preceded by a nonempty token + $empty or $this->raiseUnexpectedToken($this->offset(), $delimiter, null); + // Start parsing the string as a dictionary + return $this->parseDictionaryImpl($tokens, $this->delimiters[self::DICT_END]); + case $this->delimiters[self::LIST_START]: + // The opening delimiter of a list cannot be preceded by a nonempty token + $empty or $this->raiseUnexpectedToken($this->offset(), $delimiter, null); + // Start parsing the string as a list + return $this->parseListImpl($tokens, $this->delimiters[self::LIST_END]); + default: + // Otherwise process the obtained token + return [$this->process($token), $empty, $delimiter]; + } + } + + /** + * A helper function that checks if a list of a dictionary is followed + * directly by another delimiter (or the end of the string) + * + * @param \Generator $tokens + * @param string|null $current + * @return string|null the next delimiter or null if none + */ + private function checkNextDelimiter(\Generator $tokens, ?string $current): ?string { + if ($current && $tokens->valid()) { + list($token, $next) = $tokens->current(); + $token->empty() or $this->raiseUnexpectedToken($this->offset(), $current, $next); + $tokens->next(); + return $next; + } else { + return null; + } + } + + /** + * Processes the tokens into a list + * + * @param \Generator $tokens + * @param string|null $endDelimiter + * @return array + */ + protected function parseListImpl(\Generator $tokens, ?string $endDelimiter): array { + $result = []; + do { + // Get a value to add to the list + list($value, $empty, $delimiter) = $this->parseImpl($tokens); + switch ($delimiter) { + case $this->delimiters[self::SEP]: + $result[] = $value; + break; + case $endDelimiter: + // Add an empty element only if there was a comma + if (!$empty || $result) { + $result[] = $value; + } + // The end of the list - check if not followed by a non-empty token + $delimiter = $this->checkNextDelimiter($tokens, $delimiter); + return [$result, false, $delimiter]; + default: + // Only list item separators are allowed here + $this->raiseUnexpectedDelimiter( + $delimiter, + $this->offset(), + $this->delimiters[self::SEP] . $endDelimiter + ); + } + } while ($tokens->valid()); + // The string ended with a comma - append an empty string unless + // the list is expected to end with a delimiter + if ($endDelimiter) $this->raiseUnexpectedEnd($endDelimiter); + $result[] = $this->options['empty']; + return [$result, false, null]; + } + + /** + * Processes the tokens into a dictionary + * + * @param \Generator $tokens + * @param string|null $endDelimiter + * @return array + */ + protected function parseDictionaryImpl(\Generator $tokens, ?string $endDelimiter): array { + $result = []; + do { + // Get the key + list($key, $delimiter) = $tokens->current(); + $key = strval($key); + $tokens->next(); + if ($delimiter == $this->delimiters[self::KVSEP]) { + // The key-value separator is found - find the corresponding value + if ($tokens->valid()) { + list($value, $empty, $delimiter) = $this->parseImpl($tokens); + } else { + // The end of the string - the value is empty + $delimiter = null; + $empty = true; + } + // Replace an empty token with the empty value + $result[$key] = $empty ? ( + $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE] + ) : $value; + } elseif ($key) { + // When no key-value separator, then do nothing it the key is empty + // In other words: ignore trailing commas + $result[$key] = $this->options[self::OPTION_KEY_IS_VALUE] ? $key : $this->options[self::OPTION_EMPTY_VALUE]; + } + // Check if the current delimiter is a correct one + if ($delimiter == $endDelimiter) { + $delimiter = $this->checkNextDelimiter($tokens, $delimiter); + return [$result, false, $delimiter]; + } elseif ($delimiter != $this->delimiters[self::SEP]) { + $this->raiseUnexpectedDelimiter( + $delimiter, + $this->offset(), + $this->delimiters[self::SEP] . $endDelimiter + ); + } + } while ($tokens->valid()); + // Trailing commas are ok for objects + return [$result, false, null]; + } +} diff --git a/extension/Classes/Core/Parser/SimpleParser.php b/extension/Classes/Core/Parser/SimpleParser.php new file mode 100644 index 0000000000000000000000000000000000000000..f92e6128a664ec9853a5d6e03aca3483edca027d --- /dev/null +++ b/extension/Classes/Core/Parser/SimpleParser.php @@ -0,0 +1,168 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * Represents a number prefixed with a sign. This class is used + * to treat such values differently from absolute numbers. + * This class implements `JsonSerializable`, so that it is + * treated nicely by `json_encode()`. + * @package qfq + */ +class SignedNumber implements \JsonSerializable { + + /** @var int|float the value of the number */ + public $value; + + public function __construct($value) { + $this->value = $value; + } + + public function __toString(): string { + return $this->value > 0 ? "+$this->value" : "$this->value"; + } + + public function jsonSerialize() { + return $this->value; + } +} + +/** + * Class SimpleParser + * + * A basic parser that splits the provided string at unescaped + * and unquoted delimiters. Token processing recognizes numbers + * and specials values. + * + * @package qfq + */ +class SimpleParser extends StringTokenizer { + + /** @var string Option key: replace numeric strings with numbers */ + const OPTION_PARSE_NUMBERS = 'parse-numbers'; + + /** @var string Option key: convert +num and -num to an instance of SignedNumber */ + const OPTION_KEEP_SIGN = 'keep-sign'; + + /** @var string Option key: empty keys will be assigned their names are values */ + const OPTION_KEY_IS_VALUE = 'key-is-value'; + + /** @var string Option key: the value assigned to empty tokens */ + const OPTION_EMPTY_VALUE = 'empty'; + + /** @var array a configuration of the parser */ + public array $options = [ + self::OPTION_KEEP_SIGN => false, // if true, tokens "+number" and "-number" are converted to instances of SignedNumber + self::OPTION_KEY_IS_VALUE => false, // if true, a key with no value is assigned its name + self::OPTION_EMPTY_VALUE => null, // the value used for empty tokens + self::OPTION_PARSE_NUMBERS => true // if true, tokens are replaced with numbers if possible + ]; + + /** + * @var array A dictionary for special values of tokens. These values are + * used only for tokens for which the property `isString` is false. + */ + public array $specialValues = [ + 'null' => null, + 'true' => true, + 'false' => false, + 'yes' => true, + 'no' => false + ]; + + + public function __construct(string $delimiters, array $options = []) { + parent::__construct($delimiters); + $this->options = array_merge($this->options, $options); + } + + /** + * Processes a token into a string, a number, or a special value. + * @return mixed + */ + protected function process(Token $token) { + $asString = strval($token); + if ($token->isString) { + return $asString; + } elseif ($asString === '') { + return $this->options[self::OPTION_EMPTY_VALUE]; + } elseif ($this->options[self::OPTION_PARSE_NUMBERS] && is_numeric($asString)) { + if (preg_match("/^[+-]?\d+$/", $asString)) { + $value = intval($asString); + } else { + $value = floatval($asString); + } + return ($this->options[self::OPTION_KEEP_SIGN] && ($asString[0] === '+' || $asString[0] === '-')) + ? new SignedNumber($value) : $value; + } elseif (array_key_exists($asString, $this->specialValues)) { + // isset() does not work, because the array has `null` as one of values + return $this->specialValues[$asString]; + } else { + return $asString; + } + } + + /** + * A helper method that throws an exception + * @param $delimiter + * @param $position + * @param $expected + */ + protected static function raiseUnexpectedDelimiter($delimiter, $position, $expected = null): void { + $msg = "An unexpected '$delimiter' at position $position"; + if ($expected) { + $msg .= " while expecting " . implode(' or ', str_split($expected)); + } + throw new \RuntimeException($msg); + } + + /** + * A helper method that throws an exception + * @param $expected + */ + protected static function raiseUnexpectedEnd($expected): void { + throw new \RuntimeException("An unexpected end while searching for '$expected'"); + } + + /** + * A helper method that throws an exception + * @param $position + * @param $before + * @param $after + */ + protected static function raiseUnexpectedToken($position, $before, $after): void { + $msg = "An unexpected token at $position"; + $extra = []; + if ($before) $extra[] = " before '$before'"; + if ($after) $extra[] = " after '$after'"; + $msg .= implode('and ', $extra); + throw new \RuntimeException($msg); + } + + /** + * Parses the provided string into a list of values separated by delimiters. + * + * @param string $data + * @return mixed + */ + public function parse(string $data) { + return iterator_to_array($this->iterate($data)); + } + + /** + * Iterates over pieces of a string separated by unescaped delimiters. + * + * @param string $data + * @return \Generator + */ + public function iterate(string $data): \Generator { + foreach ($this->tokenized($data) as $token) { + yield $this->process($token[0]); + } + } +} diff --git a/extension/Classes/Core/Parser/StringTokenizer.php b/extension/Classes/Core/Parser/StringTokenizer.php new file mode 100644 index 0000000000000000000000000000000000000000..157cfad3532730b551f482c042b6cb5da608f040 --- /dev/null +++ b/extension/Classes/Core/Parser/StringTokenizer.php @@ -0,0 +1,162 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * Class StringTokenizer + * + * This class is used to parse a string into a sequence of tokens + * bound by provided delimiters. While parsing the string, pieces + * of a token are generated each time a delimiter, a quote or + * a backslash is found. They are joined together and yielded + * once an unescaped delimiter is encountered or the end of + * the string is reached. + * + * @project qfq + */ +class StringTokenizer { + + /** @var string a regexp pattern to match delimiters */ + private string $delimitersPattern; + + /** @var int the offset for the current token piece */ + private int $currentOffset = 0; + + /** @var TokenBuilder the object for building a token */ + protected TokenBuilder $tokenBuilder; + + + public function __construct(string $delimiters) { + $escapedDelimiters = str_replace( + ['[', ']', '/', '.'], + ['\\[', '\\]', '\\/', '\\.'], + $delimiters + ); + $this->delimitersPattern = "/[$escapedDelimiters\\\\\"']/"; + $this->tokenBuilder = new TokenBuilder(); + } + + /** + * The offset of the last found delimiter or -1 otherwise + * @return int + */ + public function offset(): int { + return $this->currentOffset - 1; + } + + /** + * Iterates over unescaped delimiters and quotes from a provided string. + * At each iteration returns a delimiter or null when the end of the + * string is reached. + * + * Examples + * delimiters: ,:| + * ab:cd, ef|gh --> : , | (null) (token pieces: 'ab' 'cd' 'ef' 'gh') + * ab\:c d,"e:f" --> , " : " (null) (token pieces: 'ab:c d' '' 'e' 'f' '') + * ab\\:cd,' ' --> : , (null) (token pieces: 'ab\' 'cd' ' ') + * + * @param string $data the searched string + * @return \Generator delimiters + */ + protected function unescapedDelimitersAndQuotes(string $data): \Generator { + // Reset the token builder and offset + $this->tokenBuilder->reset(); + $this->currentOffset = 0; + // Match all delimiters, including escaped and inside quotes + if (preg_match_all($this->delimitersPattern, $data, $delimiters, PREG_OFFSET_CAPTURE)) { + // If non-empty, $delimiters is an array with one element: + // a list of pairs [delimiter, offset] + $delimiters = $delimiters[0]; + $tokenData = current($delimiters); + while ($tokenData) { + list($delimiter, $offset) = $tokenData; + if ($delimiter == '\\') { + // The next character is escaped. If it is a delimiter, + // then we will ignore it and continue the search. + $tokenData = next($delimiters); + if (!$tokenData) { + // No more delimiters - we have reached the end of the string. + // We return the remaining part of the string outside the loop. + break; + } elseif ($tokenData[1] == $offset + 1) { + // This delimiter or quote is escaped by the backslash + if ($tokenData[0] != '\\') { + $this->tokenBuilder->append(substr($data, $this->currentOffset, $offset - $this->currentOffset)); + $this->currentOffset = $tokenData[1]; + } + $tokenData = next($delimiters); + continue; + } + } + // An unescaped delimiter has been found + $this->tokenBuilder->append(substr($data, $this->currentOffset, $offset - $this->currentOffset)); + $this->currentOffset = $offset + 1; + yield $delimiter; + $tokenData = next($delimiters); + } + } + } + + /** + * Iterates over unescaped and unquoted delimiters from a provided string. + * Unescaped quotes must match and are not included in the resulting token. + * At each iteration returns a pair consisting of + * - the token generated from the substring bound by the previous + * and the current delimiter + * - the delimiter + * Note that the offset of the current delimiter is given by the offset() + * method. The delimiter is null when the end of the string is reached. + * + * Examples + * delimiters: ,:| + * ab:cd, ef|gh --> ['ab', ':'] ['cd', ','] ['ef', '|'] ['gh', (null)] + * ab\:c d,"e:f" --> ['ab:c d', ','] ['e f', (null)] + * ab\\:cd,' ' --> ['ab\', ':'] ['cd', ','] [' ', (null)] + * + * @param string $data the string to search for delimiters + * @return \Generator pairs [token, delimiter] + */ + public function tokenized(string $data): \Generator { + // Iterate over all unescaped delimiters + $delimitersAndQuotes = $this->unescapedDelimitersAndQuotes($data); + while ($delimitersAndQuotes->valid()) { + $delimiter = $delimitersAndQuotes->current(); + if ($delimiter === '"' || $delimiter === "'") { + // We will search for the matching quote and put everything + // in between to the token + $quote = $delimiter; + $this->tokenBuilder->markQuote(); + while (true) { + // Get next delimiter and check if it is a matching quote + $delimitersAndQuotes->next(); + if (!$delimitersAndQuotes->valid()) { + throw new \RuntimeException("An unexpected end while searching for '$delimiter'"); + } + $delimiter = $delimitersAndQuotes->current(); + if ($delimiter === $quote) { + // We have found a quote - break this loop and continue + // searching for delimiters + $this->tokenBuilder->markQuote(); + break; + } + // An quoted delimiter is a part of the token + $this->tokenBuilder->pieces[] = $delimiter; + } + } else { + // An unescaped delimiter: return the current token + // and start building the next one + yield [$this->tokenBuilder->process(), $delimiter]; + } + $delimitersAndQuotes->next(); + } + // No more delimiters: add the rest of the string and process the token + $this->tokenBuilder->pieces[] = substr($data, $this->currentOffset); + $token = $this->tokenBuilder->process(); + $token->empty() or yield [$token, null]; + } +} diff --git a/extension/Classes/Core/Parser/Token.php b/extension/Classes/Core/Parser/Token.php new file mode 100644 index 0000000000000000000000000000000000000000..5e94c180a685ebd27bc4002de4e45ce353153ad1 --- /dev/null +++ b/extension/Classes/Core/Parser/Token.php @@ -0,0 +1,45 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * A token returned by StringTokenizer when parsing a string. + * It represents a part of the input string except that unescaped quotes + * and backslashes before delimiters and quotes are removed. + * @package qfq + */ +class Token { + /** + * @var string The string wrapped by the token. + */ + public string $value; + + /** + * @var bool True if the token must be treated as a string + * (for instance it was surrounded with quotes) + */ + public bool $isString; + + public function __construct(string $value, bool $isString) { + $this->value = $value; + $this->isString = $isString; + } + + /** + * Returns true when the token is empty. In particular, this means + * that there were no quotes in the token. + * @return bool + */ + public function empty(): bool { + return $this->value === '' && !$this->isString; + } + + public function __toString(): string { + return $this->value; + } +} diff --git a/extension/Classes/Core/Parser/TokenBuilder.php b/extension/Classes/Core/Parser/TokenBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..b33c71509fc6ffd6fb1a90713a165a5082e22d49 --- /dev/null +++ b/extension/Classes/Core/Parser/TokenBuilder.php @@ -0,0 +1,100 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace IMATHUZH\Qfq\Core\Parser; + +/** + * A helper class used by StringTokenizer to build a token. It contains extra information + * that are necessary to properly process the token into a string. + * @package qfq + */ +class TokenBuilder { + + /** + * @var array A list of substrings that form the token. Splits occur at escaped + * delimiters and unescaped quotes so that they do not appear in a final string. + */ + public array $pieces = []; + + /** + * @var int The current total length of the token pieces + */ + public int $length = 0; + + /** + * @var int the offset of the first unescaped quote + */ + public int $firstQuoteOffset = -1; + + /** + * @var int the offset of the last unescaped quote + */ + public int $lastQuoteOffset = -1; + + /** + * Returns true when an unescaped quote has been found + * while creating the token. + * @return bool + */ + public function hasQuotes(): bool { + return $this->firstQuoteOffset >= 0; + } + + /** + * Resets the builder to its initial state + * @return void + */ + public function reset() { + $this->pieces = []; + $this->length = 0; + $this->firstQuoteOffset = -1; + $this->lastQuoteOffset = -1; + } + + /** + * Processes the data to a token and resets the builder + * @return Token + */ + public function process(): Token { + // Combine all the pieces and trim the resulting string, + // but keep whitespaces that were surrounded by quotes + $value = implode('', $this->pieces); + if ($this->hasQuotes()) { + $value = ltrim(substr($value, 0, $this->firstQuoteOffset)) . + substr($value, $this->firstQuoteOffset, $this->lastQuoteOffset - $this->firstQuoteOffset) . + rtrim(substr($value, $this->lastQuoteOffset)); + } else { + $value = trim($value); + } + $token = new Token($value, $this->hasQuotes()); + $this->reset(); + return $token; + } + + /** + * Adds a piece of a token and updates the builder status + * @param string $data + * @return void + */ + public function append(string $data) { + $this->pieces[] = $data; + $this->length += strlen($data); + } + + /** + * Notifies the builder that a quote has been encountered. + * The builder updates offsets of quotes accoringly. + * @return void + */ + public function markQuote() { + if ($this->firstQuoteOffset < 0) { + $this->firstQuoteOffset = $this->length; + } else { + $this->lastQuoteOffset = $this->length; + } + } +} diff --git a/extension/Classes/Core/QuickFormQuery.php b/extension/Classes/Core/QuickFormQuery.php index 3bbd5b2c4fea112fa44bb05e25fea35fa6424052..1de00b21869073d166571ee8a021093ebbb6e039 100644 --- a/extension/Classes/Core/QuickFormQuery.php +++ b/extension/Classes/Core/QuickFormQuery.php @@ -172,9 +172,35 @@ class QuickFormQuery { $this->store->setVar(TYPO3_TT_CONTENT_UID, $t3data[T3DATA_UID], STORE_TYPO3); $this->store->setVar(TYPO3_TT_CONTENT_SUBHEADER, $t3data[T3DATA_SUBHEADER], STORE_TYPO3); + // Adds line numbers together with level to TYPO3 store + // E.g. [parsed.1 => 2, parsed.1.2 => 4, ...] + foreach ($btp->reportLines as $key => $value) { + $this->store->setVar(TYPO3_TOKEN_REPORT_LINE . '.' . $key, $value, STORE_TYPO3); + } + + // Check if aliases were used + if (isset($btp->aliases)) { + // Adds aliases together with level to TYPO3 store + // E.g. [alias.1 => "myAlias", alias.1.2 => "mySecondAlias", ...] + foreach ($btp->aliases as $key => $value) { + $this->store->setVar(TOKEN_ALIAS . '.' . $key, $value, STORE_TYPO3); + } + } $this->dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM); $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM); + $this->dbIndexT3 = $this->dbIndexQfq; + + if (!defined('PHPUNIT_QFQ')) { + $t3DbConfig = T3Handler::getTypo3DbConfig($this->dbIndexData, $this->dbIndexQfq, $this->dbIndexT3); + } + // Create Typo3 db object if config information exist. In case of api, it doesn't exist. + if (count($t3DbConfig) > 1 && $this->dbIndexT3 !== 0) { + $this->dbArray[$this->dbIndexT3] = new Database($this->dbIndexT3, $t3DbConfig); + } else { + // Fallback to qfq user credentials. These are used usually for typo3 db. + $this->dbArray[$this->dbIndexT3] = new Database($this->dbIndexQfq); + } $this->dbArray[$this->dbIndexData] = new Database($this->dbIndexData); @@ -188,7 +214,7 @@ class QuickFormQuery { \UserReportException::$report_bodytext = $t3data[T3DATA_BODYTEXT]; \UserReportException::$report_header = $t3data[T3DATA_HEADER]; \UserReportException::$report_pathFileName = $reportPathFileNameFull; - \UserReportException::$report_db = $this->dbArray[$this->dbIndexData]; + \UserReportException::$report_db = $this->dbArray[$this->dbIndexT3]; $this->evaluate = new Evaluate($this->store, $this->dbArray[$this->dbIndexData]); @@ -210,7 +236,7 @@ class QuickFormQuery { // Create report file if file keyword not found (and auto export is enabled in qfq settings) if ($reportPathFileNameFull === null && $t3data[T3DATA_UID] !== 0 && strtolower($this->store->getVar(SYSTEM_REPORT_AS_FILE_AUTO_EXPORT, STORE_SYSTEM)) === 'yes') { - $reportPathFileNameFull = ReportAsFile::create_file_from_ttContent($t3data[T3DATA_UID], $this->dbArray[$this->dbIndexData]); + $reportPathFileNameFull = ReportAsFile::create_file_from_ttContent($t3data[T3DATA_UID], $this->dbArray[$this->dbIndexT3]); } // Save pathFileName for use in inline editor @@ -494,7 +520,7 @@ class QuickFormQuery { } } - // Check 'session expire' happens quite late, cause it can be configured per form. + // Check 'session expire' happens quite late, because it can be configured per form. if ($formName !== false) { Session::checkSessionExpired($this->formSpec[F_SESSION_TIMEOUT_SECONDS]); } @@ -591,7 +617,10 @@ class QuickFormQuery { $dirty = new Dirty(false, $this->dbIndexData, $this->dbIndexQfq); $recordDirty = array(); $rcLockFound = $dirty->getCheckDirty($this->formSpec[F_TABLE_NAME], $recordId, $recordDirty, $msg); - if (($rcLockFound == LOCK_FOUND_CONFLICT || $rcLockFound == LOCK_FOUND_OWNER) && $recordDirty[F_DIRTY_MODE] == DIRTY_MODE_EXCLUSIVE) { + + // Switch to READONLY + if (($rcLockFound == LOCK_FOUND_CONFLICT || $rcLockFound == LOCK_FOUND_OWNER) + && $recordDirty[F_DIRTY_MODE] == DIRTY_MODE_EXCLUSIVE) { $this->formSpec[F_MODE_GLOBAL] = F_MODE_READONLY; } } @@ -669,15 +698,17 @@ class QuickFormQuery { break; case FORM_SAVE: - $this->logFormSubmitRequest(); - + $formSubmitLogId = $this->logFormSubmitRequest(); $recordId = $this->store->getVar(SIP_RECORD_ID, STORE_SIP . STORE_TYPO3); // SAVE $save = new Save($this->formSpec, $this->feSpecAction, $this->feSpecNative, $this->feSpecNativeRaw); - $rc = $save->process($recordId); + if ($recordId == 0 && $formSubmitLogId != 0) { + // Update recordId from formSubmitLog + $this->updateLogFormSubmitRequest($formSubmitLogId, $rc); + } if ($formMode == FORM_REST) { $data = $this->doRestPostPut($rc); $flagApiStructureReGroup = false; @@ -692,7 +723,7 @@ class QuickFormQuery { $getJson = true; if (0 == $this->store->getVar(SIP_RECORD_ID, STORE_SIP) && (($this->formSpec[F_FORWARD_MODE] == F_FORWARD_MODE_AUTO - && API_SUBMIT_REASON_SAVE == $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX) + && (API_SUBMIT_REASON_SAVE == $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX) || API_SUBMIT_REASON_SAVE_FORCE == $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX)) ) || $this->formSpec[F_FORWARD_MODE] == F_FORWARD_MODE_NO)) { $this->formSpec = $this->buildNSetReloadUrl($this->formSpec, $rc); $getJson = false; @@ -890,17 +921,28 @@ class QuickFormQuery { } /** + * Logs the form submit to table FormSubmitLog. + * * @throws \CodeException * @throws \DbException * @throws \UserFormException */ private function logFormSubmitRequest() { + $tableName = $this->formSpec[F_TABLE_NAME]; $formSubmitLogMode = $this->formSpec[F_FORM_SUBMIT_LOG_MODE] ?? $this->store->getVar(SYSTEM_FORM_SUBMIT_LOG_MODE, STORE_SYSTEM, SANITIZE_ALLOW_ALNUMX); if ($formSubmitLogMode === FORM_SUBMIT_LOG_MODE_NONE) { - return; + return 0; + } + + // Log is ignored in some special cases while mode LOG_MODIFY + if ($formSubmitLogMode === FORM_SUBMIT_LOG_MODE_MODIFY) { + // If table FormSubmitLog and given statement INSERT/UPDATE and table Dirty and given statement INSERT/UPDATE/DELETE + if ($tableName === 'FormSubmitLog' || $tableName === 'Dirty') { + return 0; + } } $formData = $_POST; @@ -930,13 +972,20 @@ class QuickFormQuery { } } - $formData = json_encode($formData, JSON_UNESCAPED_UNICODE); + $formDataJson = json_encode($formData, JSON_UNESCAPED_UNICODE); + $currentLength = strlen($formDataJson); + // FormSubmitLog.formData (TEXT) = 65535 + if ($currentLength > LOG_MAX_FORMDATA) { + // Oops, FormSubmitLog can only store LOG_MAX_FORMDATA. + // JSON will be shrinked: remove elements as long as it is too big, return rest. + $formDataJson = json_encode(OnString::limitSizeJsonEncode($formData, $currentLength, LOG_MAX_FORMDATA), JSON_UNESCAPED_UNICODE); + } $sql = "INSERT INTO `FormSubmitLog` (`formData`, `sipData`, `clientIp`, `feUser`, `userAgent`, `formId`, `formName`, `recordId`, `pageId`, `sessionId`, `created`)" . "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())"; - $clientIp = $_SERVER[CLIENT_REMOTE_ADDRESS] ?? ''; - $userAgent = $_SERVER[CLIENT_HTTP_USER_AGENT] ?? ''; + $clientIp = $this->store->getVar(CLIENT_REMOTE_ADDRESS, STORE_CLIENT . STORE_EMPTY); + $userAgent = $this->store->getVar(CLIENT_HTTP_USER_AGENT, STORE_CLIENT . STORE_EMPTY); $sipData = json_encode($this->store->getStore(STORE_SIP), JSON_UNESCAPED_UNICODE); $formId = $this->formSpec[F_ID]; $formName = $this->formSpec[F_NAME]; @@ -944,7 +993,25 @@ class QuickFormQuery { $pageId = $this->store->getVar(TYPO3_PAGE_ID, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX); $sessionId = session_id(); - $params = [$formData, $sipData, $clientIp, $feUser, $userAgent, $formId, $formName, $recordId, $pageId, $sessionId]; + $params = [$formDataJson, $sipData, $clientIp, $feUser, $userAgent, $formId, $formName, $recordId, $pageId, $sessionId]; + + $this->dbArray[$this->dbIndexQfq]->sql($sql, ROW_REGULAR, $params); + $formSubmitLogId = $this->dbArray[$this->dbIndexQfq]->getLastInsertId(); + $this->store::setVar(EXTRA_FORM_SUBMIT_LOG_ID, $formSubmitLogId, STORE_EXTRA); + + return $formSubmitLogId; + } + + /** Update last FormSubmitLog record with new inserted recordId + * @param $formSubmitLogId + * @param $recordId + * @throws \CodeException + * @throws \DbException + * @throws \UserFormException + */ + private function updateLogFormSubmitRequest($formSubmitLogId, $recordId) { + $sql = "UPDATE `FormSubmitLog` SET `recordId` = ? WHERE `id` = ?"; + $params = [$recordId, $formSubmitLogId]; $this->dbArray[$this->dbIndexQfq]->sql($sql, ROW_REGULAR, $params); } @@ -1321,7 +1388,7 @@ class QuickFormQuery { // Explode and Do $FormElement.parameter HelperFormElement::explodeParameterInArrayElements($feSpecNative, FE_PARAMETER); - // Check for retype FormElements which have to duplicated. + // Check for retype FormElements which have to be duplicated. $feSpecNative = HelperFormElement::duplicateRetypeElements($feSpecNative); // Check for templateGroup Elements to explode them @@ -1528,7 +1595,6 @@ class QuickFormQuery { F_DO_NOT_LOG_COLUMN, FE_FILE_MAX_FILE_SIZE, - F_FE_DATA_PATTERN_ERROR_SYSTEM, // Not a classical element to overwrite by form definition, but should be copied to detect changes per custom setting. ]; @@ -1823,9 +1889,14 @@ class QuickFormQuery { } $tooltip = 'tt-content: uid=' . $this->t3data['uid'] . ', header=' . $this->t3data['header']; + + # Define inline editor theme + $systemInlineReportDarkTheme = $this->store->getVar(SYSTEM_EDIT_INLINE_REPORT_DARK_THEME, STORE_SYSTEM, SANITIZE_ALLOW_ALLBUT); + $editorTheme = $systemInlineReportDarkTheme ? 'monokai' : 'default'; + $html .= $this->buildInlineReport($this->t3data[T3DATA_UID] ?? null, - $this->t3data[T3DATA_REPORT_PATH_FILENAME] ?? null, $this->dbArray[$this->dbIndexData], - $bodytext ?? null, null, null, $tooltip); + $this->t3data[T3DATA_REPORT_PATH_FILENAME] ?? null, $this->dbArray[$this->dbIndexT3], + $bodytext ?? null, null, null, $tooltip, $editorTheme); } $html .= $report->process($this->t3data[T3DATA_BODYTEXT]); @@ -1843,15 +1914,16 @@ class QuickFormQuery { * @throws \CodeException * @throws \UserFormException */ - public static function buildInlineReport(?int $uid, ?string $reportPathFileNameFull, Database $db, ?string $bodytext = '', ?string $btnClass = 'btn-xs btn-default', ?string $buttonText = '', ?string $btnTooltip = ''): string { + public static function buildInlineReport(?int $uid, ?string $reportPathFileNameFull, Database $db, ?string $bodytext = '', ?string $btnClass = 'btn-xs btn-default', ?string $buttonText = '', ?string $btnTooltip = '', string $editorTheme = 'default'): string { if ($uid === null) { return ''; } $btnClass = $btnClass ?? 'btn-xs btn-default'; $t3vars = T3Info::getVars(); + $baseUrl = Store::getVar(SYSTEM_BASE_URL, STORE_SYSTEM); $icon = Support::renderGlyphIcon('glyphicon-edit'); $showFormJs = '$("#tt-content-edit-' . $uid . '").toggleClass("hidden")'; - $toggleBtn = Support::wrapTag("<a class='targetEditReport $btnClass' onclick='$showFormJs' style='float:right;' title='" + $toggleBtn = Support::wrapTag("<a class='targetEditReport $btnClass' data-base-url='$baseUrl' onclick='$showFormJs' style='float:right;' title='" . htmlentities($btnTooltip, ENT_QUOTES) . "'>", $icon . ' ' . $buttonText); $ttContentParam = $db->getBodyText($uid, false); @@ -1891,7 +1963,7 @@ class QuickFormQuery { } $ttContentCode = Support::htmlEntityEncodeDecode(MODE_ENCODE, $bodytext); - $json = json_encode(array('mode' => 'text/x-sql', 'lineNumbers' => true, 'lineWrapping' => true), JSON_UNESCAPED_SLASHES); + $json = json_encode(array('mode' => 'text/x-sql', 'lineNumbers' => true, 'lineWrapping' => true, 'theme' => $editorTheme), JSON_UNESCAPED_SLASHES); $codeBoxAttributes = Support::doAttribute('style', "width:100%;") . Support::doAttribute('id', "tt-content-code-$uid") . Support::doAttribute('name', REPORT_INLINE_BODYTEXT) . @@ -1938,9 +2010,9 @@ class QuickFormQuery { // $bodytextNew = Support::htmlEntityEncodeDecode(MODE_DECODE, $_POST[REPORT_INLINE_BODYTEXT]); if (intval($isFile) === 1) { - ReportAsFile::write_file_uid($uid, $bodytextNew, $this->dbArray[$this->dbIndexData], $headerNew, $subheaderNew); + ReportAsFile::write_file_uid($uid, $bodytextNew, $this->dbArray[$this->dbIndexT3], $headerNew, $subheaderNew); } else { - ReportAsFile::write_tt_content($uid, $this->dbArray[$this->dbIndexData], $bodytextNew, $headerNew, $subheaderNew); + ReportAsFile::write_tt_content($uid, $this->dbArray[$this->dbIndexT3], $bodytextNew, $headerNew, $subheaderNew); } $this->formSpec[F_FORWARD_MODE] = 'auto'; } @@ -2467,7 +2539,7 @@ EOF; $uid = $this->store::getVar(T3DATA_UID, STORE_SIP); $beUser = $this->store::getVar(TYPO3_BE_USER, STORE_SIP); $beUserUid = $this->store::getVar(TYPO3_BE_USER_UID, STORE_SIP); - $ttContentOld = $this->dbArray[$this->dbIndexData]->getBodyText($uid, false); + $ttContentOld = $this->dbArray[$this->dbIndexT3]->getBodyText($uid, false); $isFile = $this->store->getVar(REPORT_SAVE_FILE, STORE_SIP . STORE_ZERO, SANITIZE_ALLOW_DIGIT); // If some of the following values are not given. @@ -2508,7 +2580,7 @@ EOF; // Only save history if changes are given if (!empty($payload)) { - $this->dbArray[$this->dbIndexData]->setHistoryRecord($dataHistory); + $this->dbArray[$this->dbIndexT3]->setHistoryRecord($dataHistory); } } diff --git a/extension/Classes/Core/Report/Download.php b/extension/Classes/Core/Report/Download.php index cdc56ca88f73c81a1b6caf7f34b8d8c9ee802185..3337fa71df95b2f7cba152c205d502c12c2f2f5c 100644 --- a/extension/Classes/Core/Report/Download.php +++ b/extension/Classes/Core/Report/Download.php @@ -90,6 +90,16 @@ class Download { $this->db = new Database(); $this->html2pdf = new Html2Pdf($this->store->getStore(STORE_SYSTEM), $phpUnit); + // Filepond uses per default qfq index db or a custom one. + $this->dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM); + $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM); + $this->dbIndexFilePond = $this->dbIndexQfq; + $dbIndexSip = $this->store->getVar(TOKEN_DB_INDEX, STORE_SIP . STORE_EMPTY); + if (in_array($dbIndexSip, [$this->dbIndexData, $this->dbIndexQfq])) { + $this->dbIndexFilePond = $dbIndexSip; + } + $this->dbArray[$this->dbIndexFilePond] = new Database($this->dbIndexFilePond); + if (Support::findInSet(SYSTEM_SHOW_DEBUG_INFO_DOWNLOAD, $this->store->getVar(SYSTEM_SHOW_DEBUG_INFO, STORE_SYSTEM))) { $this->downloadDebugLogAbsolute = Path::absoluteSqlLogFile(); } @@ -704,14 +714,24 @@ class Download { * @throws \UserReportException */ private function doElements(array $vars, $outputMode) { - + $action = $this->store::getVar(FILE_ACTION, STORE_SIP); $srcFiles = array(); $filesCleanLater = array(); $workDir = Path::absoluteApp(); - HelperFile::chdir($workDir); - $downloadMode = $vars[DOWNLOAD_MODE]; + if ($action == FILE_ACTION_DOWNLOAD) { + $downloadMode = DOWNLOAD_MODE_FILE; + $table = $this->store::getVar(SIP_TABLE, STORE_SIP); + $uploadId = $_GET[UPLOAD_ID]; + // Get pathFileName from database + $sql = "SELECT pathFileName FROM `$table` WHERE id = ?"; + $result = $this->dbArray[$this->dbIndexFilePond]->sql($sql, ROW_EXPECT_1, [$uploadId]); + $vars[SIP_DOWNLOAD_PARAMETER] = 'F:' . $result[UPLOAD_PATH_FILE_NAME]; + } else { + $downloadMode = $vars[DOWNLOAD_MODE]; + HelperFile::chdir($workDir); + } if ($downloadMode == DOWNLOAD_MODE_MONITOR) { $monitor = new Monitor(); @@ -843,7 +863,7 @@ class Download { break; default: - throw new \CodeException('Unkown mode: ' . $outputMode, ERROR_UNKNOWN_MODE); + throw new \CodeException('Unknown mode: ' . $outputMode, ERROR_UNKNOWN_MODE); } HelperFile::cleanTempFiles($filesCleanLater); @@ -962,6 +982,14 @@ class Download { $vars = $this->store->getStore(STORE_SIP); + // Check if something exists in store extra for new upload path after save form + $sipDownloadKey = $this->store->getVar(UPLOAD_SIP_DOWNLOAD_KEY, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX); + $storeExtra = $this->store->getVar($sipDownloadKey, STORE_EXTRA); + if (isset($storeExtra[SIP_DOWNLOAD_PARAMETER])) { + $vars[SIP_DOWNLOAD_PARAMETER] = $storeExtra[SIP_DOWNLOAD_PARAMETER]; + $vars[DOWNLOAD_EXPORT_FILENAME] = ''; + } + if ($vars === array()) { // No SIP >> this seems to be a DirectDownloadMode $vars = $this->getDirectDownloadModeDetails(); diff --git a/extension/Classes/Core/Report/Link.php b/extension/Classes/Core/Report/Link.php index f791e2e2ce811fc72373023109c4a34848f58f3f..ae43e505a8165552dfeaf19aa0d30261f4fecab8 100644 --- a/extension/Classes/Core/Report/Link.php +++ b/extension/Classes/Core/Report/Link.php @@ -397,8 +397,12 @@ class Link { throw new \UserReportException("Missing or invalid uid value: $uid ", ERROR_MISSING_VALUE); } + # Define inline editor theme + $systemInlineReportDarkTheme = $this->store->getVar(SYSTEM_EDIT_INLINE_REPORT_DARK_THEME, STORE_SYSTEM, SANITIZE_ALLOW_ALLBUT); + $editorTheme = $systemInlineReportDarkTheme ? 'monokai' : 'default'; + // Build inline report editing - return QuickFormQuery::buildInlineReport($uid, null, $this->dbArray[$this->dbIndexQfq], null, $param[TOKEN_BOOTSTRAP_BUTTON] ?? '', $param[TOKEN_TEXT] ?? '', $param[TOKEN_TOOL_TIP] ?? ''); + return QuickFormQuery::buildInlineReport($uid, null, $this->dbArray[$this->dbIndexQfq], null, $param[TOKEN_BOOTSTRAP_BUTTON] ?? '', $param[TOKEN_TEXT] ?? '', $param[TOKEN_TOOL_TIP] ?? '', $editorTheme); } /** diff --git a/extension/Classes/Core/Report/Report.php b/extension/Classes/Core/Report/Report.php index f49a37ac434bf6ab41b2d438b6df4be7aeae8c4a..60c1f9ac3c8cbe2337dcad58668dfa12fc7f8a45 100644 --- a/extension/Classes/Core/Report/Report.php +++ b/extension/Classes/Core/Report/Report.php @@ -34,8 +34,14 @@ use IMATHUZH\Qfq\Core\Helper\OnString; use IMATHUZH\Qfq\Core\Helper\Path; use IMATHUZH\Qfq\Core\Helper\Sanitize; use IMATHUZH\Qfq\Core\Helper\Support; +use IMATHUZH\Qfq\Core\Parser\MixedTypeParser; +use IMATHUZH\Qfq\Core\Parser\SignedNumber; +use IMATHUZH\Qfq\Core\Parser\SimpleParser; use IMATHUZH\Qfq\Core\Store\Sip; use IMATHUZH\Qfq\Core\Store\Store; +use IMATHUZH\Qfq\Core\Typo3\T3Handler; + +use Firebase\JWT\JWT; const DEFAULT_QUESTION = 'question'; const DEFAULT_ICON = 'icon'; @@ -44,6 +50,7 @@ const DEFAULT_BOOTSTRAP_BUTTON = 'bootstrapButton'; /** * Class Report * @package qfq + * */ class Report { @@ -170,11 +177,13 @@ class Report { // Default should already set in QuickFormQuery() Constructor $this->dbIndexData = $this->store->getVar(TOKEN_DB_INDEX, STORE_TYPO3); + $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM); if ($this->dbIndexData === false) { $this->dbIndexData = DB_INDEX_DEFAULT; } $this->dbArr[$this->dbIndexData] = new Database($this->dbIndexData); + $this->dbArr[$this->dbIndexQfq] = new Database($this->dbIndexQfq); $this->variables = new Variables($evaluate, $t3data["uid"]); $this->link = new Link($this->sip, $this->dbIndexData, $phpUnit); @@ -319,6 +328,27 @@ class Report { if (!empty($this->frArray[$index])) { throw new \UserReportException ("Double definition: $index is defined more than once.", ERROR_DOUBLE_DEFINITION); } + + $alias = TOKEN_ALIAS . "." . $level; + $alias = $this->store->getVar($alias, STORE_TYPO3); + + // Throw exception if alias is numeric + // E.g. 10, 10.20 + if (1 === preg_match('/^([0-9\.])+$/', $alias)) { + throw new \UserReportException ("Numeric alias detected: $alias cannot be used in report notation 'alias'", ERROR_NUMERIC_ALIAS); + } + + // Throw exception if this alias was already used + if (!empty($alias)) { + + // Checks if this alias was already used by a different level + if (!empty($this->aliases) && in_array($alias, $this->aliases) && array_search($alias, $this->aliases) != $level) { + throw new \UserReportException ("Double definition: $alias is defined more than once.", ERROR_DOUBLE_DEFINITION); + } else { + $this->aliases[$level] = $alias; + } + } + // store complete line reformatted in frArray $this->frArray[$index] = $value; @@ -421,7 +451,7 @@ class Report { * Return 'return values' in STORE_RECORD and QFQ function output in {{_output:R}}. * BTW: the QFQ function is cached and read only once. The evaluation is not cached. * - * @param $cmd # 'getFirstName(pId) => firstName, myLink' + * @param string $cmd # 'getFirstName(pId) => firstName, myLink' * @throws \CodeException * @throws \DbException * @throws \DownloadException @@ -434,7 +464,7 @@ class Report { * @throws \UserFormException * @throws \UserReportException */ - private function doQfqFunction($cmd) { + public function doQfqFunction(string $cmd): void { // QFQ function cache static $functionCache = array(); @@ -458,11 +488,18 @@ class Report { $bodytextArr = $functionCache[$rcFunctionName]; } else { // Multi DB setup: check for the correct DB - if (DB_INDEX_T3 != $this->dbArr[$this->dbIndexData]->getDbIndex()) { - // Current DB is wrong: get DB with DB_INDEX_T3 - $db = new Database(DB_INDEX_T3); + $this->dbIndexT3 = $this->dbIndexQfq; + + if (!defined('PHPUNIT_QFQ')) { + $t3DbConfig = T3Handler::getTypo3DbConfig($this->dbIndexData, $this->dbIndexQfq,$this->dbIndexT3); + } + + // Create Typo3 db object if config information exist. In case of api, it doesn't exist. + if (count($t3DbConfig) > 1 && $this->dbIndexT3 !== 0) { + $db = new Database($this->dbIndexT3, $t3DbConfig); } else { - $db = $this->dbArr[$this->dbIndexData]; + // Fallback to qfq user credentials. These are used usually for typo3 db. + $db = $this->dbArr[$this->dbIndexQfq]; } $bodytextArr = $db->getBodyText($rcFunctionName); @@ -564,9 +601,13 @@ class Report { // Set debug, if one is specified else keep the parent one. $lineDebug = $this->getValueParentDefault(TOKEN_DEBUG, $full_super_level, $fullLevel, $cur_level, 0); + // Get line number of current SQL statement from TYPO3 store + $reportLine = $this->store->getVar(TYPO3_TOKEN_REPORT_LINE . '.' . $fullLevel, STORE_TYPO3); + // Prepare Error reporting $this->store->setVar(SYSTEM_SQL_RAW, $this->frArray[$fullLevel . "." . TOKEN_SQL], STORE_SYSTEM); $this->store->setVar(SYSTEM_REPORT_FULL_LEVEL, $fullLevel, STORE_SYSTEM); + $this->store->setVar(SYSTEM_REPORT_LINE, $reportLine, STORE_SYSTEM); // Prepare SQL: replace variables. Actual 'line.total' or 'line.count' will recalculated: don't replace them now! unset($this->variables->resultArray[$fullLevel . ".line."][LINE_TOTAL]); @@ -812,10 +853,11 @@ class Report { // Author: Enis Nuredini // Split and get given encryption method from column name + // Fixed temporarily by Krzysztof Putyra $encryptionMethod = null; $key = $arr[0]; $keyArray = explode('=', $key, 2); - if (isset($keyArray[1]) && $keyArray[1] !== '' && $keyArray[0] === COLUMN_ENCRYPT) { + if (isset($keyArray[1]) && $keyArray[1] !== '' && $keyArray[0] === TOKEN_COLUMN_CTRL . COLUMN_ENCRYPT) { $encryptionMethod = $keyArray[1]; $arr[0] = $keyArray[0]; } @@ -1330,6 +1372,54 @@ class Report { $content .= Support::encryptDoubleCurlyBraces(FormAsFile::renderColumnFormJson($columnValue, $dbQfq)); break; + // Author: Krzysztof Putyra + case COLUMN_JWT: + /* Converts a string + * claim1:value, claim2:value, ... | key | alg + * into a json web token. Parameters: + * - alg the name of the signing algorithm (default: HS256) + * - key the secret key used by the signing algorithm + * Standard claims with an extended interpretation of values: + * - iss the issuer of the token (default: qfq) + * - iat the timestamp the token has been issued (default: current) + * - exp the expiration timestamp or the number of seconds till invalid (if prefixed with '+') + * - nbf the timestamp from when or (if prefixed with '+') the number of seconds after which the token is valid + */ + + // Split the column into |-separated sections + $parser = new SimpleParser('|', [ + SimpleParser::OPTION_EMPTY_VALUE => '' + ]); + $splitContent = $parser->parse($columnValue); + // Check that key is provided + if (count($splitContent) < 2) { + throw new \UserReportException("JWT requires a secret key, but it is missing"); + } + + // Parse the payload + $currentTime = time(); + $parser = new MixedTypeParser(null, [ + SimpleParser::OPTION_KEEP_SIGN => true + ]); + $claims = array_merge( + ['iss' => 'qfq', 'iat' => $currentTime], + $parser->parseDictionary($splitContent[0]) + ); + foreach (['exp', 'nbf', 'iat'] as $claim) { + $value = $claims[$claim] ?? 0; + if ($value instanceof SignedNumber) { + $claims[$claim] = $value->value + $currentTime; + } + } + + // Create the token + $content .= JWT::encode( + $claims, + $splitContent[1], + $splitContent[2] ?? 'HS256' + ); + break; + // Author: Enis Nuredini case COLUMN_ENCRYPT: $encryptionMethodColumn = $this->store->getVar(SYSTEM_ENCRYPTION_METHOD, STORE_SYSTEM, SANITIZE_ALLOW_ALL); @@ -1352,6 +1442,10 @@ class Report { } break; // End from author + + case COLUMN_UPLOAD: + $content .= $this->createInlineUpload($columnValue); + break; default : $flagOutput = false; @@ -1768,4 +1862,159 @@ class Report { return false; } + /** + * @param $columnValue + * + * @return string + */ + private function createInlineUpload($columnValue) { + $uploadId = 0; + $table = ''; + $recordData = ''; + $acceptType = ''; + $dbIndexParam = ''; + $dbIndex = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM); + $dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM); + $maxFileSize = 0; + $multiUpload = 0; + $defaultText = 'Drag & Drop or <span class="btn btn-default filepond--label-action"> Browse </span>'; + $sipValuePreloadedFiles = '&allowDelete=none'; + + $defaultPath = 'fileadmin/protected/upload/'. date("Y") . '/'; + + $baseUrl = $this->store::getVar(SYSTEM_BASE_URL, STORE_SYSTEM); + $apiUrls['upload'] = $baseUrl . 'typo3conf/ext/qfq/Classes/Api/file.php'; + $apiUrls['download'] = $baseUrl . 'typo3conf/ext/qfq/Classes/Api/download.php'; + $encodedApiUrls = htmlspecialchars(json_encode($apiUrls, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + + $jsonConfig = array(); + $preloadedFiles = ''; + $jsonConfig[UPLOAD_TEXT] = $defaultText; + + // Define defaults + $defaultValues = 'x:1|table:FileUpload|M:0|maxFileSize:null|accept:null|maxFiles:null|allowUpload:true'; + $assocDefault = KeyValueStringParser::explodeKvpSimple($defaultValues, PARAM_TOKEN_DELIMITER, PARAM_DELIMITER); + + // Return an assoc array like [ 'd' => 'file.pdf', 'p' => 'content', ... ] + $assocGiven = KeyValueStringParser::explodeKvpSimple($columnValue, PARAM_TOKEN_DELIMITER, PARAM_DELIMITER); + $finalArray = array_merge($assocDefault, $assocGiven); + + foreach ($finalArray as $token => $value) { + switch ($token) { + case TOKEN_UPLOAD_ID: + $uploadId = $value; + $jsonConfig[UPLOAD_ID] = $value; + break; + case TOKEN_FILE: + $jsonConfig[UPLOAD_PATH_FILE_NAME] = $value; + break; + case TOKEN_UPLOAD_MIME_TYPE_ACCEPT: + $jsonConfig[UPLOAD_MIME_TYPE_ACCEPT] = $value; + if ($value != 'null') { + $acceptType = '&accept=' . $value; + } + break; + case TOKEN_UPLOAD_DELETE: + if ($value == '1' || $value == '') { + $value = true; + } else { + $value = false; + } + $jsonConfig[UPLOAD_DELETE_OPTION] = $value; + break; + case TOKEN_SIP_TABLE: + $jsonConfig[SIP_TABLE] = $value; + $table = $value; + break; + case TOKEN_UPLOAD_RECORD_DATA: + $jsonConfig[UPLOAD_RECORD_DATA] = KeyValueStringParser::explodeKvpSimple(trim($value), PARAM_TOKEN_DELIMITER, ','); + $recordData = '&recordData=' . json_encode($jsonConfig[UPLOAD_RECORD_DATA]); + break; + case TOKEN_UPLOAD_MAX_FILE_SIZE: + $jsonConfig[UPLOAD_MAX_FILE_SIZE] = $value; + if ($value != '0') { + $maxFileSize = '&maxFileSize=' . $value; + } + break; + case TOKEN_UPLOAD_MULTI_UPLOAD: + if ($value == '' || $value == 1) { + $value = true; + $multiUpload = 1; + } + $jsonConfig[UPLOAD_MULTI_UPLOAD] = $value; + break; + // currently not used. Maybe useful for later implementation. + case TOKEN_UPLOAD_IMAGE_EDITOR: + if ($value == '' || $value == 1) { + $value = true; + } + $jsonConfig[UPLOAD_IMAGE_EDITOR] = $value; + break; + case TOKEN_UPLOAD_ALLOW: + if ($value == "0") { + $value = false; + } + $jsonConfig[UPLOAD_ALLOW] = $value; + break; + case TOKEN_TEXT: + $jsonConfig[UPLOAD_TEXT] = $value; + break; + case TOKEN_UPLOAD_MAX_FILES: + $jsonConfig[UPLOAD_MAX_FILES] = $value; + break; + case TOKEN_DB_INDEX: + $dbIndexParam = '&dbIndex=' . $value; + if (in_array($value, [$dbIndexData, $this->dbIndexQfq])) { + $dbIndex = $value; + } + break; + } + } + + if (!isset($jsonConfig[UPLOAD_PATH_FILE_NAME])) { + $jsonConfig[UPLOAD_PATH_FILE_NAME] = $defaultPath; + $jsonConfig[UPLOAD_PATH_DEFAULT] = 1; + } + + // Get all records from given $recordId and $table + if ($uploadId != 0) { + if ($multiUpload) { + $sqlPath = 'SELECT id, pathFileName, size, type FROM '. $table . ' WHERE uploadId = ? ORDER BY created DESC'; + $groupId = $uploadId; + } else { + $sqlPath = 'SELECT id, pathFileName, size, type FROM '. $table . ' WHERE id = ? ORDER BY created DESC'; + $sqlUploadId = 'SELECT uploadId FROM '. $table . ' WHERE id = ?'; + } + $result = $this->dbArr[$dbIndex]->sql($sqlPath, ROW_EXPECT_GE_1, [$uploadId],"File not found in database."); + + $idList = array_map(function ($item) { + return $item['id']; + }, $result); + $idListString = implode(',', $idList); + + // Save result in array notation like this {"id":"recordId", "pathFileName":"/path/dir/file.png"}, {"id":"recordId", "pathFileName":"/path/dir/file.png"} + $preloadedFiles = json_encode($result, JSON_UNESCAPED_SLASHES); + $sipValuePreloadedFiles = '&allowDelete=' . $jsonConfig[UPLOAD_DELETE_OPTION] . '&preloadedFileIds=' . $idListString; + } else { + $sqlUploadId = 'SELECT MAX(uploadId) AS uploadId FROM '. $table . ' WHERE id = ? LIMIT 1'; + } + + if ($uploadId == 0 || !$multiUpload) { + $resultUploadId = $this->dbArr[$dbIndex]->sql($sqlUploadId, ROW_EXPECT_GE_1, [$uploadId],"File not found in database."); + $groupId = $resultUploadId[0][COLUMN_UPLOAD_ID] ?? 0; + } + + $jsonConfig[UPLOAD_GROUP_ID] = $groupId ?? 0; + + // Create sip token + $sipValues['download'] = $this->sip->queryStringToSip('action=download&table=' . $table . $dbIndexParam, RETURN_SIP); + $sipValues['upload'] = $this->sip->queryStringToSip('action=upload2&table=' . $table . $acceptType . $maxFileSize . $recordData . $dbIndexParam, RETURN_SIP); + $sipValues['delete'] = $this->sip->queryStringToSip('action=delete&table=' . $table . $sipValuePreloadedFiles . $dbIndexParam, RETURN_SIP); + + $encodedSipValues = htmlspecialchars(json_encode($sipValues, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + $encodedJsonConfig = htmlspecialchars(json_encode($jsonConfig, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + $encodedPreloadFilesConfig = htmlspecialchars($preloadedFiles, ENT_QUOTES, 'UTF-8'); + + return '<input class="fileupload" data-api-urls="' . $encodedApiUrls . '" data-sips="' . $encodedSipValues . '" data-config="'. $encodedJsonConfig . '" data-preloadedFiles="' . $encodedPreloadFilesConfig . '" type="file" />'; + } } diff --git a/extension/Classes/Core/Report/RestClient.php b/extension/Classes/Core/Report/RestClient.php index 370de2a6927e5c9fe2221e6f912c8001c4ac53db..83641d650987548e88d092dd7565ff12683f975a 100644 --- a/extension/Classes/Core/Report/RestClient.php +++ b/extension/Classes/Core/Report/RestClient.php @@ -104,10 +104,10 @@ class RestClient { throw new \UserReportException("Missing RestClient target", ERROR_MISSING_VALUE); } - $param[TOKEN_L_CONTENT] = trim($param[TOKEN_L_CONTENT]) ?? ''; + $param[TOKEN_L_CONTENT] = trim($param[TOKEN_L_CONTENT] ?? ''); if (!empty($param[TOKEN_L_CONTENT_FILE])) { - $param[TOKEN_L_CONTENT_FILE] = trim($param[TOKEN_L_CONTENT_FILE]) ?? ''; + $param[TOKEN_L_CONTENT_FILE] = trim($param[TOKEN_L_CONTENT_FILE]); } if (!empty($param[TOKEN_L_HEADER])) { diff --git a/extension/Classes/Core/Report/Variables.php b/extension/Classes/Core/Report/Variables.php index 834614f3fc08d8e3a8ed1fd575ac74a18bbb2b36..a9ee655a8d5a7cc2a3e7900ea46ab0427b12a498 100644 --- a/extension/Classes/Core/Report/Variables.php +++ b/extension/Classes/Core/Report/Variables.php @@ -25,6 +25,7 @@ namespace IMATHUZH\Qfq\Core\Report; use IMATHUZH\Qfq\Core\Evaluate; use IMATHUZH\Qfq\Core\Helper\OnString; +use IMATHUZH\Qfq\Core\Store\Store; use IMATHUZH\Qfq\Core\Store\T3Info; @@ -78,7 +79,14 @@ class Variables { // Process all {{x[.x].name}} // $str = preg_replace_callback('/{{\s*(([0-9]+.)+[a-zA-Z0-9_.]+)\s*}}/', 'self::replaceVariables', $text); // $str = preg_replace_callback('/{{\s*(([0-9]+.)+[a-zA-Z0-9_.]+)(:.*)*\s*}}/', 'self::replaceVariables', $text); - $str = preg_replace_callback('/{{\s*(([0-9]+.)+[a-zA-Z0-9_.]+)(:[a-zA-Z-]*)*\s*}}/', 'self::replaceVariables', $text); + + // Report notation 'numeric' + // E.g. {{10.line.count}}, {{10.20.line.count}} + // $str = preg_replace_callback('/{{\s*(([0-9]+.)+[a-zA-Z0-9_.]+)(:[a-zA-Z-]*)*\s*}}/', 'self::replaceVariables', $text); + + // Report notation 'alias' + // E.g. {{myAlias.line.count}}, {{myAlias10.line.count}}, {{10myAlias.line.count}} + $str = preg_replace_callback('/{{\s*(([a-zA-Z0-9_-]*[0-9.]*.)[.][a-zA-Z0-9_.]+)+(:[a-zA-Z-]*)*\s*}}/', 'self::replaceVariables', $text); // Try the Stores return $this->eval->parse($str, null, null, $dummyStack, $dummyStore, $frCmd); @@ -96,12 +104,41 @@ class Variables { */ public function replaceVariables($matches): string { - // $matches[0]: {{10.20.<columnname>::u:}} - // $matches[1]: 10.20.<columnname> - // $matches[2]: 10.20 - // $matches[3]: ::u: + // Report notation 'numeric + // $matches[0]: {{10.20.<columnname>::u}} + // $matches[1]: 10.20.<columnname> + // $matches[2]: 10.20 + // $matches[3]: :u + + // Report notation 'alias' + // $matches[0]: {{myAlias.<columnname>::u}} + // $matches[1]: myAlias.<columnname> + // $matches[2]: myAlias + // $matches[3]: :u $data = $matches[0]; + // Isolate first token as possible alias + $alias = strtok($matches[2], "."); + + // No numeric value implies that an alias was used + if (!is_numeric($alias)) { + // Get typo3 store + // Aliases are saved like [alias.1 => "myAlias", alias.1.2 => "mySecondAlias", ...] + $storeT3 = Store::getStore(STORE_TYPO3); + // Check for matching value of $alias + $match = array_search($alias, $storeT3, true); + + // Replacement only if matching alias was found + if (!empty($match)) { + // Extract level from key + $level = substr($match, strpos($match, '.') + 1); + + $matches[0] = str_replace($alias, $level, $matches[0]); + $matches[1] = str_replace($alias, $level, $matches[1]); + $matches[2] = $level . '.'; + } + } + // index of last '.' $pos = strrpos($matches[1], "."); if ($pos !== false) { @@ -116,6 +153,15 @@ class Variables { $arr = explode(':', $matches[3]); $data = OnString::escape($arr[1] ?? '', $this->resultArray[$fullLevel][$varName], $rcFlagWipe); } + // This is for the specific case, that the variable references its own level + // E.g. myAlias { \n sql = SELECT '{{myAlias.line.count}}' \n } + // Note: This is only used for line.count and line.total, because non-existing variables must stay unchanged + // E.g. myAlias { \n sql = SELECT 1 \n } \n { \n sql = SELECT '{{myAlias.varName}}' \n } + // '{{myAlias.varName}}' will not be changed to '{{1.varName}}' + } elseif ($varName === LINE_COUNT || $varName === LINE_TOTAL) { + // myAlias needs to be replaced by the level + // E.g. {{1.2.line.count}} + $data = $matches[0]; } } diff --git a/extension/Classes/Core/Save.php b/extension/Classes/Core/Save.php index 10a70cab78aa10ff01aff6e8accb83c89748c5da..0d57b363ecd9690a3129d4fcf4c30eeef83f87bf 100644 --- a/extension/Classes/Core/Save.php +++ b/extension/Classes/Core/Save.php @@ -831,7 +831,7 @@ class Save { $statusUpload = $this->store->getVar($formValues[$column] ?? '', STORE_EXTRA); // Get file stats $vars = array(); - $vars[VAR_FILE_SIZE] = $statusUpload[FILES_SIZE] ?? ''; + $vars[VAR_FILE_SIZE] = $statusUpload[FILES_SIZE] ?? 0; $vars[VAR_FILE_MIME_TYPE] = $statusUpload[FILES_TYPE] ?? ''; // Check for 'unzip'. @@ -1081,13 +1081,29 @@ class Save { } } - if ($mode == FE_MODE_REQUIRED && empty($clientValues[$formElement[FE_NAME]])) { + // Required fieldset is skipped (only child elements are checked) + if ($mode == FE_MODE_REQUIRED && empty($clientValues[$formElement[FE_NAME]]) && $formElement[FE_TYPE] != FE_TYPE_FIELDSET) { $flagAllRequiredGiven = 0; if ($reportRequiredFailed) { $name = ($formElement[FE_LABEL] == '') ? $formElement[FE_NAME] : $formElement[FE_LABEL]; throw new \UserFormException("Missing required value: $name", ERROR_REQUIRED_VALUE_EMPTY); } + + // Check if mode = required was inherited + } else if (empty($clientValues[$formElement[FE_NAME]]) && $formElement[FE_TYPE] != FE_TYPE_FIELDSET) { + $name = ($formElement[FE_LABEL] == '') ? $formElement[FE_NAME] : $formElement[FE_LABEL]; + + // Check if FE is nested + $feParent = OnArray::filter($this->feSpecNativeRaw, FE_ID, $formElement[FE_ID_CONTAINER]); + + // Check if parent FE is required fieldset + // Only reached if JS-required-check is bypassed/skipped + if (!empty($feParent) && $feParent[0][FE_TYPE] == FE_TYPE_FIELDSET && $feParent[0][FE_MODE] == FE_MODE_REQUIRED) { + $parentName = $feParent[0][FE_NAME]; + + throw new \UserFormException("Missing required value: $name (mode inherited from $parentName)", ERROR_REQUIRED_VALUE_EMPTY); + } } if ($mode == FE_MODE_HIDDEN) { @@ -1237,6 +1253,11 @@ class Save { if (!isset($formElement[FE_IMPORT_TO_TABLE]) || isset($formElement[FE_FILE_DESTINATION])) { $pathFileName = $this->copyUploadFile($formElement, $statusUpload); + // Save final pathFileNames after form has been saved. Makes uploaded files downloadable without page reload. + $sipDownloadParams = $this->store::getVar($statusUpload[UPLOAD_SIP_DOWNLOAD_KEY], STORE_EXTRA); + $sipDownloadParams[FE_FILE_DESTINATION] = $pathFileName; + $this->store::setVar($statusUpload[UPLOAD_SIP_DOWNLOAD_KEY], $sipDownloadParams, STORE_EXTRA); + $msg = UPLOAD_LOG_PREFIX . ': '; $msg .= ($pathFileName == '') ? 'Remove old upload / no new upload' : 'File "' . $statusUpload[FILES_TMP_NAME] . '" >> "' . $pathFileName . '"'; Logger::logMessageWithPrefix($msg, $this->qfqLogFilename); diff --git a/extension/Classes/Core/Store/Client.php b/extension/Classes/Core/Store/Client.php index 0a5199d333c4218025a44e5eed40a03d929115e6..a0e56341cff1f2bf5352dc67d040fab00ee5aabf 100644 --- a/extension/Classes/Core/Store/Client.php +++ b/extension/Classes/Core/Store/Client.php @@ -52,6 +52,11 @@ class Client { $cookie[CLIENT_COOKIE_QFQ] = $_COOKIE[SESSION_NAME]; } + // In case we're bedind a proxy, use the real ip as remote_address + if (isset($_SERVER[CLIENT_HTTP_X_REAL_IP])) { + $_SERVER[CLIENT_REMOTE_ADDRESS] = $_SERVER[CLIENT_HTTP_X_REAL_IP]; + } + if (isset($_SERVER)) { $server = Sanitize::htmlentitiesArr($_SERVER); // $_SERVER values might be compromised. } diff --git a/extension/Classes/Core/Store/Config.php b/extension/Classes/Core/Store/Config.php index 9ec3e2d6aefa9c62f6a862ce75e70ba2f884e3c4..8c1156d0e9807e20be8af99d59676ef2867bf505 100644 --- a/extension/Classes/Core/Store/Config.php +++ b/extension/Classes/Core/Store/Config.php @@ -83,7 +83,7 @@ class Config { $absoluteConfigFilePath = $PhpUnitOverloadAbsoluteConfigFilePath === '' ? Path::absoluteConf(CONFIG_QFQ_JSON) : $PhpUnitOverloadAbsoluteConfigFilePath; if (!file_exists($absoluteConfigFilePath)) { HelperFile::createPathRecursive(Path::absoluteConf()); - HelperFile::file_put_contents(Path::absoluteConf(CONFIG_QFQ_JSON_EXAMPLE), json_encode(self::CONFIG_REQUIRED_TEMPLATE, JSON_PRETTY_PRINT)); + HelperFile::file_put_contents(Path::absoluteConf(CONFIG_QFQ_JSON_EXAMPLE), json_encode(self::CONFIG_REQUIRED_TEMPLATE, JSON_PRETTY_PRINT), LOCK_EX); Thrower::userFormException("Please create qfq config file '" . CONFIG_QFQ_JSON . "' in the conf directory which is inside the project directory. Example config file '" . CONFIG_QFQ_JSON_EXAMPLE . "' was created in conf directory.", "Project directory: " . Path::absoluteProject()); } $config = HelperFile::json_decode(HelperFile::file_get_contents($absoluteConfigFilePath)); @@ -202,7 +202,7 @@ class Config { private static function writeConfig(array $config) { $absoluteConf = Path::absoluteConf(); HelperFile::createPathRecursive($absoluteConf); - HelperFile::file_put_contents(Path::join($absoluteConf, CONFIG_QFQ_JSON), json_encode($config, JSON_PRETTY_PRINT)); + HelperFile::file_put_contents(Path::join($absoluteConf, CONFIG_QFQ_JSON), json_encode($config, JSON_PRETTY_PRINT), LOCK_EX); chmod(Path::join($absoluteConf, CONFIG_QFQ_JSON), 0640); } diff --git a/extension/Classes/Core/Store/Session.php b/extension/Classes/Core/Store/Session.php index b6f046b744d7979801bcb3360ba72be93435bbc4..ab4378c0465f514fde2e353c455ea9b8191a0acb 100644 --- a/extension/Classes/Core/Store/Session.php +++ b/extension/Classes/Core/Store/Session.php @@ -217,6 +217,7 @@ class Session { $beUser = $GLOBALS["BE_USER"]->user["username"] ?? false; $languageId = T3Info::getLanguageId() ?? false; $languagePath = T3Info::getLanguagePath($languageId) ?? false; + $pageId = $GLOBALS["TSFE"]->id ?? 0; // Cookie identifier $cookieFe = ($_COOKIE['fe_typo_user']) ?? false; @@ -241,6 +242,7 @@ class Session { // page language should be saved in session even fe user is not logged in. Session::set(SESSION_PAGE_LANGUAGE, $languageId); Session::set(SESSION_PAGE_LANGUAGE_PATH, $languagePath); + Session::set(SESSION_PAGE_ID, $pageId); } else { // If we are called through API there is no T3 environment. Assume nothing has changed, and fake the following check to always 'no change'. $feUidLoggedIn = $feUserUidSession; diff --git a/extension/Classes/Core/Store/Store.php b/extension/Classes/Core/Store/Store.php index 62a7ea83efa35d23903471fa416f32c54def0603..64a051647e1890e989994a81b5f9097d22c2df6d 100644 --- a/extension/Classes/Core/Store/Store.php +++ b/extension/Classes/Core/Store/Store.php @@ -124,6 +124,7 @@ class Store { CLIENT_SERVER_ADDRESS => SANITIZE_ALLOW_ALNUMX, CLIENT_SERVER_PORT => SANITIZE_ALLOW_DIGIT, CLIENT_REMOTE_ADDRESS => SANITIZE_ALLOW_ALNUMX, + CLIENT_HTTP_X_REAL_IP => SANITIZE_ALLOW_ALNUMX, CLIENT_REQUEST_SCHEME => SANITIZE_ALLOW_ALNUMX, CLIENT_REQUEST_METHOD => SANITIZE_ALLOW_ALNUMX, CLIENT_SCRIPT_FILENAME => SANITIZE_ALLOW_ALNUMX, @@ -382,7 +383,7 @@ class Store { } else { // No T3 environment (called by API): restore from SESSION - foreach ([SESSION_FE_USER, SESSION_FE_USER_UID, SESSION_FE_USER_GROUP, SESSION_BE_USER, SESSION_PAGE_LANGUAGE, SESSION_PAGE_LANGUAGE_PATH] as $key) { + foreach ([SESSION_FE_USER, SESSION_FE_USER_UID, SESSION_FE_USER_GROUP, SESSION_BE_USER, SESSION_PAGE_LANGUAGE, SESSION_PAGE_LANGUAGE_PATH, SESSION_PAGE_ID] as $key) { if (isset($_SESSION[SESSION_NAME][$key])) { $arr[$key] = $_SESSION[SESSION_NAME][$key]; } @@ -518,7 +519,9 @@ class Store { if ($storeName === STORE_USER && $key == TYPO3_FE_USER) { $qfqLogPathAbsolute = Path::absoluteQfqLogFile(); $feUserOld = isset($data[$key]) ? $data[$key] : self::getVar($key, STORE_TYPO3 . STORE_EMPTY); - Logger::logMessage(date('Y.m.d H:i:s ') . ": Switch feUser '$feUserOld' to '$value'", $qfqLogPathAbsolute); + if ($feUserOld !== $value) { + Logger::logMessage(date('Y.m.d H:i:s ') . ": Switch feUser '$feUserOld' to '$value'", $qfqLogPathAbsolute); + } } $data[$key] = $value; diff --git a/extension/Classes/Core/Typo3/T3Handler.php b/extension/Classes/Core/Typo3/T3Handler.php index a32f7e498f24039c621738c3cdcf6b30620dc6f9..e52578fa4f6cbd2125cbcc513bae7299b3aa74f9 100644 --- a/extension/Classes/Core/Typo3/T3Handler.php +++ b/extension/Classes/Core/Typo3/T3Handler.php @@ -323,4 +323,39 @@ class T3Handler { } return $cache; } + + /** + * Get typo3 database configuration. + * + * @param int $dbIndexData + * @param int $dbIndexQfq + * @param int $dbIndexT3 + * @return array + * @throws \UserFormException + */ + public static function getTypo3DbConfig(int $dbIndexData, int $dbIndexQfq, int &$dbIndexT3): array { + $config = array(); + for ($i = 1; $i <= 3; $i++) { + if ($dbIndexData !== $i && $dbIndexQfq !== $i) { + $dbIndexT3 = $i; + continue; + } + } + + self::t3AutoloadIfNotRunning(); + + $configurationManager = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance('TYPO3\\CMS\\Core\\Configuration\\ConfigurationManager'); + + // Same as $GLOBALS['TYPO3_CONF_VARS']['EXT']['extConf']['qfq'] + if (isset($GLOBALS['TYPO3_CONF_VARS']) && !defined('PHPUNIT_QFQ')) { + $configT3 = $configurationManager->getLocalConfiguration(); + $typo3DbCredentials = $configT3['DB']['Connections']['Default']; + $config['DB_' . $dbIndexT3 . '_USER'] = $typo3DbCredentials['user']; + $config['DB_' . $dbIndexT3 . '_PASSWORD'] = $typo3DbCredentials['password']; + $config['DB_' . $dbIndexT3 . '_SERVER'] = $typo3DbCredentials['host']; + $config['DB_' . $dbIndexT3 . '_NAME'] = $typo3DbCredentials['dbname']; + } + + return $config; + } } \ No newline at end of file diff --git a/extension/Classes/External/AutoCron.php b/extension/Classes/External/Auto-Cron.php similarity index 100% rename from extension/Classes/External/AutoCron.php rename to extension/Classes/External/Auto-Cron.php diff --git a/extension/Classes/Sql/function.sql b/extension/Classes/Sql/function.sql index d1e684026c7e1719d8c97a5f8252254e9ee17054..770de2080ccb637a572eece0a784c19127f760f0 100644 --- a/extension/Classes/Sql/function.sql +++ b/extension/Classes/Sql/function.sql @@ -414,4 +414,23 @@ BEGIN DECLARE matrikelNr VARCHAR(255); SET matrikelNr = CONCAT(SUBSTRING(manr,1,2), '-', SUBSTRING(manr,3,3), '-', SUBSTRING(manr,6,3)); RETURN matrikelNr; +END; + +### +# +# QIFPREPEND(separator, input) +# If 'input' is not empty|0, prepend separator to given input +# +DROP FUNCTION IF EXISTS QIFPREPEND; +CREATE FUNCTION QIFPREPEND(`separator` VARCHAR(128), `input` TEXT) + RETURNS TEXT + DETERMINISTIC + SQL SECURITY INVOKER +BEGIN + DECLARE output TEXT; + SET output = + IF(ISNULL(`input`) OR `input` = '' OR `input` = '0', + '', + CONCAT(`separator`, `input`)); + RETURN output; END; \ No newline at end of file diff --git a/extension/Classes/Sql/qfqDefaultTables.sql b/extension/Classes/Sql/qfqDefaultTables.sql index fa8c6cfe0e319a33fcae12d3b3f5a02c27bb2a44..db4412e290d3fda08857cdd0e5cb8fd34b4f3a40 100644 --- a/extension/Classes/Sql/qfqDefaultTables.sql +++ b/extension/Classes/Sql/qfqDefaultTables.sql @@ -326,4 +326,24 @@ CREATE TABLE IF NOT EXISTS `WikiAttachment` ) ENGINE=InnoDB DEFAULT CHARSET = utf8mb4 - AUTO_INCREMENT = 0; \ No newline at end of file + AUTO_INCREMENT = 0; + +# Used to save uploads per default. +CREATE TABLE IF NOT EXISTS `FileUpload` +( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `pathFileName` VARCHAR(512) NOT NULL, + `grId` INT(11) NOT NULL DEFAULT '0', + `xId` INT(11) NOT NULL DEFAULT '0', + `uploadId` INT(11) NOT NULL, + `size` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT 'Filesize in bytes', + `type` VARCHAR(64) NOT NULL DEFAULT '', + `ord` INT(11) NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `uploadId` (`uploadId`), + KEY `pathFileNameGrIdXidUploadId` (`pathFileName`, `grId`, `xId`, `uploadId`) USING BTREE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4; + diff --git a/extension/RELEASE.txt b/extension/RELEASE.txt index ecec19a501b581e0857e876d70e883429575bcd2..01282d98b4f2c4ce9a510762d6d7960bd3a668db 100644 --- a/extension/RELEASE.txt +++ b/extension/RELEASE.txt @@ -12,6 +12,7 @@ .. --------------------------------------------used to the update the records specified ------ .. Best Practice T3 reST: https://docs.typo3.org/m/typo3/docs-how-to-document/master/en-us/WritingReST/CheatSheet.html .. Reference: https://docs.typo3.org/m/typo3/docs-how-to-document/master/en-us/WritingReST/Index.html +.. .. Italic *italic* .. Bold **bold** .. Code ``text`` @@ -19,7 +20,6 @@ .. Internal Link: :ref:`downloadButton` (default url text) or :ref:`download Button<downloadButton>` (explicit url text) .. Add Images: .. image:: ./Images/a4.jpg .. -.. .. Admonitions .. .. note:: .. important:: .. tip:: .. warning:: .. Color: (blue) (orange) (green) (red) @@ -52,6 +52,60 @@ Features Bug Fixes ^^^^^^^^^ +Version 23.10.1 +--------------- + +Date: 22.10.2023 + +Notes +^^^^^ + +Features +^^^^^^^^ + +* #15682 / Subrecord hide please save record first if there is no table title. +* #15098 / Implemented qfqFunction in QFQ variable for usage in forms. +* Doc: Replace many places single back tick by double back tick. Add hint for 'Row size too large'. Add hint how to use + mysqldump to export one row per record. +* Index.rst: Add Enis & Jan as developer. + +Bug Fixes +^^^^^^^^^ + +* #17003 / inline edit - dark mode has wrong css path. +* #17075 / Fix broken '... AS _restClient'. +* #17091 / upload_Incorrect_integer_value_fileSize. +* RTD: Fix broken readthedocs rendering. + +Version 23.10.0 +--------------- + +Date: 05.10.2023 + +Features +^^^^^^^^ + +* #16350 / QFQ Table 'FormSubmiLog': update recordid after insert. +* #16350 / sql.log: reference to FormSubmitLog entry. All SQL statements generated by one HTTP Post (Form Submit) can + be identified. +* #16350 / Do not log Dirty. +* #16350 / If the T3 instance is behind a proxy, log HTTP_X_REAL_IP instead of REMOTE_ADDR in logfiles. +* #16584 / FormEditor Report: Default without statistics. +* #16589 / Implemented language configuration in backend for tt-content type qfq. +* #16798 / Report inline edit v2. Improved search inside inline edit report. Whole content will be searched. Added + ability to switch the editor to dark mode. +* Doc: Refactor description of {{random:V}}. +* Doc: Add config option 'protectedFolderCheck'. + +Bug Fixes +^^^^^^^^^ + +* #16573 / Fixed wrong built date and datetime string if default value was given. +* #16574 / Added multiple siteConfigurations compatibility for typo3 v10 and 11. +* #16616 / Fixed typeahead api query response problem if typeahead sql is not used. +* #16664 / Fix multi db user error. +* #16975 / Fix problem if a 'Form Submit' contains more than 64kB data. This can happen easily for 'fabric' elements. + Version 23.6.4 -------------- @@ -99,6 +153,7 @@ Notes Bug Fixes ^^^^^^^^^ +* #16372 / Upload Element 'Undefined index htmlDownloadButton' und 'unknown mode ID' * #16381 / Form title dynamic update broken. * #16392 / Reevaluate sanitize class for each store. * Fix db column enum dropdown 'data truncated' @@ -155,6 +210,7 @@ Bug Fixes * #16004 / Template Group Fields broken. * #16046 / Datepicker not initialized in Template Group. * #16051 / Dropdown menu: Download file broken with ZIP File. +* #16055 / Dropdown option with render mode 3 format problem. * #16064 / Dynamic Update Checkbox and Form Store in parameter. * #16073 / saveButtonActive is disabled after first save. * #16201 / Character Count Class Quotation Mark. diff --git a/extension/Resources/Private/Form/cron.json b/extension/Resources/Private/Form/cron.json index cad94e44502adf928c18e8836939dc8d4b332764..1cb4c268c4b8cb3858405450068e36b8815d2851 100644 --- a/extension/Resources/Private/Form/cron.json +++ b/extension/Resources/Private/Form/cron.json @@ -316,8 +316,8 @@ "type": "text", "subrecordOption": "", "encode": "none", - "checkType": "all", - "checkPattern": "", + "checkType": "pattern", + "checkPattern": "^(?!\\/fileadmin\\/)(?!.*\\/fileadmin\\/).*", "onChange": "", "ord": 80, "tabindex": 0, @@ -334,7 +334,7 @@ "placeholder": "", "value": "", "sql1": "", - "parameter": "", + "parameter": "data-pattern-error=Beginning slash before fileadmin is not allowed", "parameterLanguageA": "", "parameterLanguageB": "", "parameterLanguageC": "", diff --git a/extension/Resources/Public/scripts/weblinks_check.py b/extension/Resources/Public/scripts/weblinks_check.py new file mode 100644 index 0000000000000000000000000000000000000000..3abd43e3d409291379cecbac79b427b9f3eb2373 --- /dev/null +++ b/extension/Resources/Public/scripts/weblinks_check.py @@ -0,0 +1,395 @@ +import csv +import json +import os +import pandas as pd +import requests +import sys +import argparse +import datetime +import urllib3 +from selenium import webdriver +from urllib.parse import urlparse, urljoin +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.chrome.options import Options +# Initialize the Selenium webdriver +from selenium.webdriver.common.by import By +from bs4 import BeautifulSoup +# Configure Chrome options to clear cache and disable extensions +chrome_options = Options() +chrome_options.add_argument('--headless') +chrome_options.add_argument('--ignore-certificate-errors') +chrome_options.add_argument('--disable-web-security') +chrome_options.add_argument('--disable-extensions') +chrome_options.add_argument('--allow-running-insecure-content') + +# Set the download directory for PDFs +download_directory = "/home/a/zhoujl/Downloads/all_web_crawler_pdf" # Replace with the actual directory +prefs = {"download.default_directory": download_directory} +chrome_options.add_experimental_option("prefs", prefs) +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +successed_links = {} +visited_links = set() +broken_links = {} +denied_links = {} +broken_image = {} +redirect_to_home = {} +successed_image = set() +merged_broken_links = {} + +max_depth = 0 +input_url = '' +main_domain = '' +target_path = '' + +class SimpleLink: + href = "" + css_class = "" + link_text = "" + def __init__(self, href, css_class, link_text): + self.href = href + self.css_class = css_class + self.link_text = link_text +# Function to configure and retrieve WebDriver with authenticated session +def get_authenticated_driver(username, password, login_url): + driver = webdriver.Chrome(options=chrome_options) + + # Navigate to the login page + driver.get(login_url) + if username != '' or password != '': + # Fill in and submit the login form + username_field = driver.find_element(By.NAME, 'user') + password_field = driver.find_element(By.NAME, 'pass') + login_button = driver.find_element(By.XPATH, '//input[@type="submit"]') + + username_field.send_keys(username) + password_field.send_keys(password) + login_button.click() + + # Wait for the login to complete, you might need to adjust this + driver.implicitly_wait(2000) + + # Retrieve and return the cookies + cookies = driver.get_cookies() + return driver, cookies + +def check_webpage(url, target_string, session, cookies=None): + try: + response = session.get(url, cookies=cookies, verify=False, timeout=15) + response.raise_for_status() # Raise an exception for bad status codes + content = response.text + + if target_string in content: + return True, content + else: + return False, content + except requests.exceptions.RequestException as e: + return False, str(e) + +# Set the main domain +def is_internal_link(link): + parsed_link = urlparse(link) + + return parsed_link.netloc == main_domain and parsed_link.path.startswith(target_path) or parsed_link.netloc == "" + + +def find_broken_links(driver, url, current_depth, cookies, session, requests_cookies, target_string): + global successed_links + global visited_links + global broken_links + global denied_links + global broken_image + global successed_image + global redirect_to_home + simpleLinkList = [] + + + if current_depth == 0: + return "done" + + try: + driver.get(url) + except WebDriverException as e: + print(f"Error accessing URL {url}: {e}") + if url.endswith(".pdf") or url in broken_links or "type=1" in url or "download.php" in url: + driver.back() + else: + link_elements = driver.find_elements(By.TAG_NAME, "a") + images = [img.get_attribute("src") for img in driver.find_elements(By.TAG_NAME, "img")] + for link_element in link_elements: + simple_link = SimpleLink(link_element.get_attribute("href"), link_element.get_attribute("class"), link_element.text) + simpleLinkList.append(simple_link) + #links = [a for a in driver.find_elements(By.TAG_NAME, "a")] + for simple_link in simpleLinkList: + link = simple_link.href + cssClass = simple_link.css_class + link_text = simple_link.link_text + if link == 'https://www.math.uzh.ch/compmath/en/konferenzdetails0?L=1&key1=755': + + pass + try:#links = [a.get_attribute("href") for a in driver.find_elements(By.TAG_NAME, "a")] + # link = link_element.get_attribute("href") + # link_class = link_element.get_attribute("class") + #for link in links: + + checkBool, content = check_webpage(link, target_string, session, cookies=requests_cookies) + if checkBool and link not in visited_links: + visited_links.add(link) + response = requests.head(link, verify=False) + redirect_to_home.setdefault(link, [None, None, None, None, None])[0] = link + redirect_to_home[link][1] = response.status_code + redirect_to_home[link][2] = str(checkBool) + redirect_to_home[link][3] = url + redirect_to_home[link][4] = link_text + if link and is_internal_link(link) and not link.startswith("mailto:"): + full_link = urljoin(url, link) + if full_link not in visited_links: + visited_links.add(full_link) + response = requests.head(full_link) + #checkBool, content = check_webpage(full_link, target_string, session, cookies=requests_cookies) + if response.status_code == 404: + broken_links.setdefault(full_link, [None, None, None, None, None])[0] = full_link + broken_links[full_link][1] = response.status_code + broken_links[full_link][2] = str(checkBool) + broken_links[full_link][3] = url + broken_links[full_link][4] = link_text + # print(f"Broken link: {full_link}") + if response.status_code == 403: + denied_links.setdefault(full_link, [None, None, None, None, None])[0] = full_link + denied_links[full_link][1] = response.status_code + denied_links[full_link][2] = str(checkBool) + denied_links[full_link][3] = url + denied_links[full_link][4] = link_text + else: + find_broken_links(driver, full_link, current_depth -1, cookies, session, requests_cookies, target_string) + if full_link not in broken_links: + successed_links.setdefault(full_link, [None, None, None, None, None])[0] = full_link + successed_links[full_link][1] = response.status_code + successed_links[full_link][2] = str(checkBool) + successed_links[full_link][3] = url + # print(full_link)# Recursively check this link + # print("keine fehler") + # print('current_depth is:', current_depth) + #if checkBool == True: + # print('Path redirects to home') + # print("---------------------------------------") + except Exception as e: + print(f"Error checking link {full_link}: {e}") + + for image in images: + if image and is_internal_link(image): + full_image_link = urljoin(url, image) + if full_image_link not in visited_links: + visited_links.add(full_image_link) + try: + response = requests.head(full_image_link) + if response.status_code == 404: + broken_image.setdefault(full_image_link, [None, None, None, None, None])[0] = full_image_link + broken_image[full_image_link][1] = response.status_code + broken_image[full_image_link][2] = '' + broken_image[full_image_link][3] = url + broken_image[full_image_link][4] = "text" + #print(f"Broken image: {full_image_link}") + else: + successed_image.add(full_image_link) + except Exception as e: + print(f"Error checking image {full_image_link}: {e}") + pass + + + +def print_header(log_file, blacklists): + current_datetime = datetime.datetime.now() + if log_file.endswith("detail.log"): + with open(log_file, "a+") as file: + file.write(f"\nSkript : {__file__}") + file.write("\nDetails Log " + current_datetime.strftime("%Y-%m-%d %H:%M")) + file.write("\nInput URL:" + input_url) + file.write("\nDOMAIN:" + main_domain) + file.write("\n-------------------------------------\n") + file.write("\nBlacklist : \n") + for blacklist in blacklists: + file.write(f"\"{blacklist}\"\n") + file.write("-------------------------------------\n") + elif log_file.endswith("summary.log"): + with open(log_file, "a+") as file: + file.write(f"\nSkript : {__file__}") + file.write("\nSummary Log " + current_datetime.strftime("%Y-%m-%d %H:%M")) + file.write("\nInput URL:" + input_url) + file.write("\nDOMAIN:" + main_domain) + file.write("\n-------------------------------------\n") + else: + return print("No summary.log or detail.log are found") + pass + + + +def print_detail_log(links, title, detail_log_file): + with open(detail_log_file, "a") as file: + file.write("\n" + title + ":\n\n") + if len(links) == 0: + file.write("Nothing found.\n---------------------------------------\n") + for full_link, details in links.items(): + target_link = details[0] + response = details[1] # assuming the status_code is at index 0 + redirect = details[2] # assuming the redirect flag is at index 1 + url = details[3] + if title != 'Successed links': + link_text = details[4] + file.write("Page URL: " + url + "\n") + file.write("Target URL: " + target_link + "\n") + file.write("RESPONSE: " + str(response) + "\n") + if title != 'Successed links': + file.write("Link Text: " + link_text + "\n") + if title != 'Broken images' and redirect == True: + file.write("REDIRECT_TO_HOME: " + str(redirect) + "\n") + file.write("---------------------------------------\n") + pass + +def print_summary(redirect_to_home,checked_links,broken_links,checked_image,broken_image, summary_log_file, title): + with open(summary_log_file, "a") as file: + file.write(f"\n Redirect to Home: {len(redirect_to_home)}") + file.write(f"\n Checked links: {len(checked_links)+len(broken_links)}") + file.write(f"\n Broken links: {len(broken_links)}") + file.write(f"\n Checked image: {len(checked_image)+len(broken_image)}") + file.write(f"\n Broken image: {len(broken_image)}") + file.write("\n---------------------------------------") + if len(broken_links) > 0 or len(broken_image) > 0: + file.write(f"\nSome issue in \"{title}\"") + else: + file.write("\nEverything is ok\n\n") + pass + +def write_broken_links_to_csv(broken_links, csv_file): + header = ["Response Code", "PAGE URL", "Link Text", "Target URL"] + + with open(csv_file, "a", newline="", encoding="utf-8") as file: + # Check if the file is empty + + is_empty = file.tell() == 0 + writer = csv.writer(file) + + if is_empty: + writer.writerow(header) + + for link, details in broken_links.items(): + target_url = details[0] + response_code = details[1] + url = details[3] + link_text = details[4] + writer.writerow([response_code, url, link_text, target_url]) + +def clear_arrays(): + successed_links.clear() + visited_links.clear() + broken_links.clear() + denied_links.clear() + broken_image.clear() + successed_image.clear() + merged_broken_links.clear() + redirect_to_home.clear() + pass + +def process_site(selected_site): + global main_domain + global target_path + global visited_links + global input_url + + max_depth = selected_site.get("max_depth") + input_url = selected_site.get("startUrl") + main_domain = urlparse(input_url).netloc + target_path = selected_site.get("target_path") + + # Load the blacklist URLs from the JSON data + blacklist = selected_site.get("blacklist", []) + + # Initialize the visited_links set with the URLs from the blacklist + visited_links = set(blacklist) + + # Login credentials + login_url = selected_site.get("login_url") or input_url + username = selected_site.get("username") + password = selected_site.get("password") + target_string = selected_site.get("target_string") + + session = requests.Session() + # target_string = 'Login/out' + driver, cookies = get_authenticated_driver(username, password, login_url) + requests_cookies = {cookie['name']: cookie['value'] for cookie in cookies} + # current_datetime = datetime.datetime.now() + # formatted_datetime = current_datetime.strftime("%Y-%m-%d %H:%M") + + parsed_url = urlparse(input_url) + detail_file_name = "log_files/" + parsed_url.netloc + parsed_url.path.replace("/", "_") + "_detail.log" + detail_log_file = os.path.join(os.getcwd(), detail_file_name) + + summary_log_file = "log_files/" + parsed_url.netloc + parsed_url.path.replace("/", "_") + "_summary.log" + title_summary = parsed_url.netloc + parsed_url.path + + # print header in files + print_header(detail_log_file, blacklist) + print_header(summary_log_file, blacklist) + # function find_broken_links + find_broken_links(driver, input_url, max_depth, cookies, session, requests_cookies, target_string) + merged_broken_links = {**broken_links, **broken_image} + + # Clean up + driver.quit() + + print_summary(redirect_to_home, successed_links, broken_links, successed_image, broken_image, summary_log_file, + title_summary) + print_detail_log(redirect_to_home, 'Redirect to Home', detail_log_file) + print_detail_log(broken_links, 'Broken links', detail_log_file) + print_detail_log(broken_image, 'Broken images', detail_log_file) + print_detail_log(denied_links, 'Denied links', detail_log_file) + print_detail_log(successed_links, 'Successed links', detail_log_file) + file_name = parsed_url.netloc + parsed_url.path.replace("/", "_") + file_path = f"/home/a/zhoujl/Documents/broken_link_{file_name}.csv" + write_broken_links_to_csv(merged_broken_links, file_path) + print(f"Broken links found in {input_url}: {len(broken_links)}") + clear_arrays() + + +def main(): + # if len(sys.argv) < 3 or not sys.argv[1].strip(): + # print("Usage:python script.py conf.json <index or all>") + # sys.exit(1) + # + # parser = argparse.ArgumentParser(description='Web Crawling.') + # parser.add_argument('conf_file', type=str, help='Path to the conf.json file') + # #parser.add_argument('element_index', type=int, help='Index of the element to check in allSites') + # args = parser.parse_args() + + conf_file_path = "conf.json"#args.conf_file + + with open(conf_file_path, "r") as conf_file: + conf_data = json.load(conf_file) + + all_sites = conf_data.get("allSites", []) + selected_arg = "9" #sys.argv[3] # Get the third argument + + try: + if selected_arg == "all": + for selected_site in conf_data.get("allSites", []): + process_site(selected_site) + else: + selected_index = int(selected_arg) + if selected_index >= 0 and selected_index < len(conf_data.get("allSites", [])): + selected_site = conf_data["allSites"][selected_index] + process_site(selected_site) + else: + print("Invalid index.") + sys.exit(1) + except Exception as e: + print(e) + +if __name__ == "__main__": + main() + + + + + + diff --git a/extension/Tests/Unit/Core/BodytextParserTest.php b/extension/Tests/Unit/Core/BodytextParserTest.php index b069ba46657ff45d209644b50d5d7ef48c448d62..09487b62e073748079d053840bc378b20a88078c 100644 --- a/extension/Tests/Unit/Core/BodytextParserTest.php +++ b/extension/Tests/Unit/Core/BodytextParserTest.php @@ -27,12 +27,40 @@ final class BodytextParserTest extends TestCase { $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Simple row, nothing to remove + $given = "{\nsql = SELECT 'Hello World'\n}"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Simple row, nothing to remove + $given = "myAlias {\nsql = SELECT 'Hello World'\n}"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Several rows, remove all but one $given = "\n#some comments\n10.sql = SELECT 'Hello World'\n\n \n #more comment"; $expected = "10.sql = SELECT 'Hello World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Several rows, remove all but one + $given = "\n#some comments\n{\nsql = SELECT 'Hello World'\n\n \n #more comment\n}"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Several rows, remove all but one + $given = "\n#some comments\nmyAlias {\nsql = SELECT 'Hello World'\n\n \n #more comment\n}"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Several rows, all to remove $given = "\n#some comments\n\n\n \n #more comment"; $expected = ""; @@ -45,66 +73,220 @@ final class BodytextParserTest extends TestCase { $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Join a line + $given = "\n{\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}"; + $expected = "1.sql = SELECT 'Hello World', 'more content' WHERE help=1"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Join a line + $given = "\n myAlias {\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}"; + $expected = "1.sql = SELECT 'Hello World', 'more content' WHERE help=1"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Join several lines, incl. form $given = "\n10.sql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n 20.head = <table>\n 30.sql = SELECT\n col1,\n col2, \n col3\n # Query stops here\nform = Person\n"; $expected = "10.sql = SELECT 'Hello World', 'more content' WHERE help=1\n20.head = <table>\n30.sql = SELECT col1, col2, col3\nform = Person"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Join several lines, incl. form + $given = "\n{\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}\n{\nhead = <table>\n}\n{\nsql = SELECT\n col1,\n col2, \n col3\n # Query stops here\n}\nform = Person\n"; + $expected = "1.sql = SELECT 'Hello World', 'more content' WHERE help=1\n2.head = <table>\n3.sql = SELECT col1, col2, col3\nform = Person"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Join several lines, incl. form + $given = "\nmyAlias {\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}\n mySecondAlias {\nhead = <table>\n}\nmyThirdAlias {\nsql = SELECT\n col1,\n col2, \n col3\n # Query stops here\n}\nform = Person\n"; + $expected = "1.sql = SELECT 'Hello World', 'more content' WHERE help=1\n2.head = <table>\n3.sql = SELECT col1, col2, col3\nform = Person"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: one. $given = "10{\nsql = SELECT 'Hello World'\n}\n"; $expected = "10.sql = SELECT 'Hello World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: one. + $given = "{\nsql = SELECT 'Hello World'\n}\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: one. + $given = "myAlias {\nsql = SELECT 'Hello World'\n}\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: one. No LF at the end $given = "10{\nsql = SELECT 'Hello World'\n}"; $expected = "10.sql = SELECT 'Hello World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: one. No LF at the end + $given = "{\nsql = SELECT 'Hello World'\n}"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: one. No LF at the end + $given = "myAlias {\nsql = SELECT 'Hello World'\n}"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression, one, added some white spaces $given = "\n\n10 { \n \n sql = SELECT 'Hello World' \n\n }\n\n"; $expected = "10.sql = SELECT 'Hello World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression, one, added some white spaces + $given = "\n\n { \n \n sql = SELECT 'Hello World' \n\n }\n\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression, one, added some white spaces + $given = "\n\n myAlias { \n \n sql = SELECT 'Hello World' \n\n }\n\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: multiple, simple $given = "10.sql = SELECT 'Hello World'\n20 {\nsql='Hello world2'\n}\n30 {\nsql='Hello world3'\n}\n"; $expected = "10.sql = SELECT 'Hello World'\n20.sql='Hello world2'\n30.sql='Hello world3'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: multiple, simple + $given = "{\nsql = SELECT 'Hello World'\n}\n{\nsql='Hello world2'\n}\n {\nsql='Hello world3'\n}\n"; + $expected = "1.sql = SELECT 'Hello World'\n2.sql='Hello world2'\n3.sql='Hello world3'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: multiple, simple + $given = "myAlias {\nsql = SELECT 'Hello World'\n}\nmyAlias10{\nsql='Hello world2'\n}\n 10myAlias{\nsql='Hello world3'\n}\n"; + $expected = "1.sql = SELECT 'Hello World'\n2.sql='Hello world2'\n3.sql='Hello world3'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: complex $given = "10.sql = SELECT 'Hello World'\n20 {\nsql='Hello world2'\n30 { \n sql=SELECT 'Hello World3'\n40 { \n sql = SELECT 'Hello World4'\n } \n } \n } "; $expected = "10.sql = SELECT 'Hello World'\n20.sql='Hello world2'\n20.30.sql=SELECT 'Hello World3'\n20.30.40.sql = SELECT 'Hello World4'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: complex + $given = "{\nsql = SELECT 'Hello World'\n}\n {\nsql='Hello world2'\n { \n sql=SELECT 'Hello World3'\n { \n sql = SELECT 'Hello World4'\n } \n } \n } "; + $expected = "1.sql = SELECT 'Hello World'\n2.sql='Hello world2'\n2.3.sql=SELECT 'Hello World3'\n2.3.4.sql = SELECT 'Hello World4'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: complex + $given = "myAlias{\nsql = SELECT 'Hello World'\n}\n myAlias2{\nsql='Hello world2'\n myAlias3{ \n sql=SELECT 'Hello World3'\n myAlias4{ \n sql = SELECT 'Hello World4'\n } \n } \n } "; + $expected = "1.sql = SELECT 'Hello World'\n2.sql='Hello world2'\n2.3.sql=SELECT 'Hello World3'\n2.3.4.sql = SELECT 'Hello World4'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // form=...., {{ }} $given = "10.sql = SELECT 'Hello World'\nform = {{form:S}}\n20.sql = SELECT 'Hello World2'\n30 {\nsql=SELECT 'Hello World'\n}\n form=Person\n"; $expected = "10.sql = SELECT 'Hello World'\nform = {{form:S}}\n20.sql = SELECT 'Hello World2'\n30.sql=SELECT 'Hello World'\nform=Person"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // form=...., {{ }} + $given = "{\nsql = SELECT 'Hello World'\n}\nform = {{form:S}}\n{\nsql = SELECT 'Hello World2'\n}\n {\nsql=SELECT 'Hello World'\n}\n form=Person\n"; + $expected = "1.sql = SELECT 'Hello World'\nform = {{form:S}}\n2.sql = SELECT 'Hello World2'\n3.sql=SELECT 'Hello World'\nform=Person"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // form=...., {{ }} + $given = "myAlias{\nsql = SELECT 'Hello World'\n}\nform = {{form:S}}\nmyAlias2 {\nsql = SELECT 'Hello World2'\n}\n myAlias3{\nsql=SELECT 'Hello World'\n}\n form=Person\n"; + $expected = "1.sql = SELECT 'Hello World'\nform = {{form:S}}\n2.sql = SELECT 'Hello World2'\n3.sql=SELECT 'Hello World'\nform=Person"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested: open bracket alone $given = "10.sql = SELECT 'Hello World'\n20\n{\nhead=test\n}\n30.sql = SELECT 'Hello World'\n"; $expected = "10.sql = SELECT 'Hello World'\n20.head=test\n30.sql = SELECT 'Hello World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested: open bracket alone + $given = "{\nsql = SELECT 'Hello World'\n}\n\n{\nhead=test\n}\n{\nsql = SELECT 'Hello World'\n}"; + $expected = "1.sql = SELECT 'Hello World'\n2.head=test\n3.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested: open bracket alone + $given = "myAlias{\nsql = SELECT 'Hello World'\n}\n\nmySecondAlias{\nhead=test\n}\nmyThirdAlias{\nsql = SELECT 'Hello World'\n}"; + $expected = "1.sql = SELECT 'Hello World'\n2.head=test\n3.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Single open bracket inside a string. $given = "10.sql = SELECT 'Hello { World'"; $expected = "10.sql = SELECT 'Hello { World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Single open bracket inside a string. + $given = "{\nsql = SELECT 'Hello { World'\n}"; + $expected = "1.sql = SELECT 'Hello { World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Single open bracket inside a string. + $given = "myAlias{\nsql = SELECT 'Hello { World'\n}"; + $expected = "1.sql = SELECT 'Hello { World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Complex test $given = "10.sql = SELECT '[\*]{7} [0-9]{5}<br>'\n20 {\n 10 {\n 5 {\n sql = SELECT 'hello world<br>'\n }\n }\n}\n20.10.5.head = Terific\n20.sql = SELECT 20, '<br>'\n20.10.sql = SELECT '20.10<br>'"; $expected = "10.sql = SELECT '[\*]{7} [0-9]{5}<br>'\n20.10.5.sql = SELECT 'hello world<br>'\n20.10.5.head = Terific\n20.sql = SELECT 20, '<br>'\n20.10.sql = SELECT '20.10<br>'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Complex test + $given = "{\nsql = SELECT '[\*]{7} [0-9]{5}<br>'\n}\n{\n {\n {\n sql = SELECT 'hello world<br>'\n head = Terrific \n}\n }\n}\n{\nsql = SELECT 5, '<br>'\n}\n{\nsql = SELECT '6<br>'\n}"; + $expected = "1.sql = SELECT '[\*]{7} [0-9]{5}<br>'\n2.3.4.sql = SELECT 'hello world<br>'\n2.3.4.head = Terrific\n5.sql = SELECT 5, '<br>'\n6.sql = SELECT '6<br>'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Complex test + $given = "myAliasOne{\nsql = SELECT '[\*]{7} [0-9]{5}<br>'\n}\nmyAliasTwo{\n myAliasThree{\n myAliasFour{\n sql = SELECT 'hello world<br>'\n head = Terrific \n}\n }\n}\nmyAliasFive {\nsql = SELECT 5, '<br>'\n}\nmyLastAlias{\nsql = SELECT '6<br>'\n}"; + $expected = "1.sql = SELECT '[\*]{7} [0-9]{5}<br>'\n2.3.4.sql = SELECT 'hello world<br>'\n2.3.4.head = Terrific\n5.sql = SELECT 5, '<br>'\n6.sql = SELECT '6<br>'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + } public function testNestingToken() { @@ -116,48 +298,160 @@ final class BodytextParserTest extends TestCase { $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: one level curly + $given = "{ \n sql = SELECT 'Hello World' \n , 'next line', \n \n 'end' \n} \n"; + $expected = "1.sql = SELECT 'Hello World' , 'next line', 'end'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: one level curly + $given = "myAlias{ \n sql = SELECT 'Hello World' \n , 'next line', \n \n 'end' \n} \n"; + $expected = "1.sql = SELECT 'Hello World' , 'next line', 'end'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: one level angle $given = "#<\n10 < \n sql = SELECT 'Hello World'\n>\n"; $expected = "10.sql = SELECT 'Hello World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: one level angle + $given = "#<\n < \n sql = SELECT 'Hello World'\n>\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: one level angle + $given = "#<\n myAlias< \n sql = SELECT 'Hello World'\n>\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: one level angle and single curly $given = "#<\n10 < \n head = data { \n '1','2','3' \n }\n>\n"; $expected = "10.head = data { '1','2','3' }"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: one level angle and single curly + $given = "#<\n < \n head = data { \n '1','2','3' \n }\n>\n"; + $expected = "1.head = data { '1','2','3' }"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: one level angle and single curly + $given = "#<\n myAlias< \n head = data { \n '1','2','3' \n }\n>\n"; + $expected = "1.head = data { '1','2','3' }"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: one level angle and single curly $given = " # < \n 10 < \n sql = SELECT 'Hello World' \n>\n"; $expected = "10.sql = SELECT 'Hello World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: one level angle and single curly + $given = " # < \n < \n sql = SELECT 'Hello World' \n>\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: one level angle and single curly + $given = " # myAlias1< \n myAlias2< \n sql = SELECT 'Hello World' \n>\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: one level round bracket $given = " # ( \n 10 ( \n sql = SELECT 'Hello World' \n)\n"; $expected = "10.sql = SELECT 'Hello World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: one level round bracket + $given = " # ( \n ( \n sql = SELECT 'Hello World' \n)\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: one level round bracket + $given = " # ( \n myAlias( \n sql = SELECT 'Hello World' \n)\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: one level square bracket $given = " # [ \n 10 [ \n sql = SELECT 'Hello World' \n]\n"; $expected = "10.sql = SELECT 'Hello World'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: one level square bracket + $given = " # [ \n [ \n sql = SELECT 'Hello World' \n]\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: one level square bracket + $given = " # [ \n myAlias[ \n sql = SELECT 'Hello World' \n]\n"; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: one level angle - garbage $given = " # < \n 10 { \n sql = SELECT 'Hello World' \n}\n"; $expected = "10 {\nsql = SELECT 'Hello World' }"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: one level angle - garbage + $given = " # < \n { \n sql = SELECT 'Hello World' \n}\n"; + $expected = "{\nsql = SELECT 'Hello World' }"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: one level angle - garbage + $given = " # < \n myAlias{ \n sql = SELECT 'Hello World' \n}\n"; + $expected = "myAlias{\nsql = SELECT 'Hello World' }"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // Nested expression: '</script>' is allowed here - with bad implemented parsing, it would detect a nesting token end, which is not the meant. $given = " # < \n 10 < \n sql = SELECT 'Hello World' \n head = <script> \n>\n20.tail=</script>\n\n\n30.sql=SELECT 'something'"; $expected = "10.sql = SELECT 'Hello World'\n10.head = <script>\n20.tail=</script>\n30.sql=SELECT 'something'"; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // Nested expression: '</script>' is allowed here - with bad implemented parsing, it would detect a nesting token end, which is not the meant. + $given = " # < \n < \n sql = SELECT 'Hello World' \n head = <script> \n>\n<\ntail=</script>\n>\n<\nsql=SELECT 'something'\n>"; + $expected = "1.sql = SELECT 'Hello World'\n1.head = <script>\n2.tail=</script>\n3.sql=SELECT 'something'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Nested expression: '</script>' is allowed here - with bad implemented parsing, it would detect a nesting token end, which is not the meant. + $given = " # < \n myAlias < \n sql = SELECT 'Hello World' \n head = <script> \n>\n myAlias2 <\ntail=</script>\n>\nmyAlias3<\nsql=SELECT 'something'\n>"; + $expected = "1.sql = SELECT 'Hello World'\n1.head = <script>\n2.tail=</script>\n3.sql=SELECT 'something'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + $open = '<'; $close = '>'; // muliple nesting, unnested rows inbetween @@ -211,6 +505,125 @@ EOF; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // muliple nesting, unnested rows inbetween + $given = <<<EOF + # $open + + $open + head = <h1> + $close + $open + sql = SELECT ... + $close + $open + $open + sql = SELECT 3.4 + head = <script> + tail = </script> + $open + head = <div> + $close + $close + $open + sql = SELECT 3.6 + $close + $open + sql = SELECT 3.7 + $close + $open + $open + tail = } + } + (: + } + ] + sql = SELECT 3.8.9 + head = { + { + ) + { + ; + [ + $close + $open + sql = SELECT 3.8.10 + $close + $open + sql = SELECT 3.8.11 + $close + $close + $open + head = <table> + $close + $close + $open + sql = SELECT 13 + $close +EOF; + $expected = "1.head = <h1>\n2.sql = SELECT ...\n3.4.sql = SELECT 3.4\n3.4.head = <script>\n3.4.tail = </script>\n3.4.5.head = <div>\n3.6.sql = SELECT 3.6\n3.7.sql = SELECT 3.7\n3.8.9.tail = } } (: } ]\n3.8.9.sql = SELECT 3.8.9\n3.8.9.head = { { ) { ; [\n3.8.10.sql = SELECT 3.8.10\n3.8.11.sql = SELECT 3.8.11\n3.12.head = <table>\n13.sql = SELECT 13"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // muliple nesting, unnested rows inbetween + $given = <<<EOF + # $open + + myAlias1 $open + head = <h1> + $close + myAlias2 $open + sql = SELECT ... + $close + myAlias3 $open + myAlias4 $open + sql = SELECT 3.4 + head = <script> + tail = </script> + myAlias4 $open + head = <div> + $close + $close + myAlias5 $open + sql = SELECT 3.6 + $close + myAlias6 $open + sql = SELECT 3.7 + $close + myAlias7 $open + myAlias8 $open + tail = } + } + (: + } + ] + sql = SELECT 3.8.9 + head = { + { + ) + { + ; + [ + $close + myAlias9 $open + sql = SELECT 3.8.10 + $close + myAlias10$open + sql = SELECT 3.8.11 + $close + $close + myAlias11$open + head = <table> + $close + $close + myAlias12$open + sql = SELECT 13 + $close +EOF; + $expected = "1.head = <h1>\n2.sql = SELECT ...\n3.4.sql = SELECT 3.4\n3.4.head = <script>\n3.4.tail = </script>\n3.4.5.head = <div>\n3.6.sql = SELECT 3.6\n3.7.sql = SELECT 3.7\n3.8.9.tail = } } (: } ]\n3.8.9.sql = SELECT 3.8.9\n3.8.9.head = { { ) { ; [\n3.8.10.sql = SELECT 3.8.10\n3.8.11.sql = SELECT 3.8.11\n3.12.head = <table>\n13.sql = SELECT 13"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); } public function testVariousNestingToken() { @@ -232,6 +645,30 @@ EOF; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // level open + $given = <<<EOF + # $open + $open + sql = SELECT 'Hello World' + $close +EOF; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // level open + $given = <<<EOF + # $open + myAlias $open + sql = SELECT 'Hello World' + $close +EOF; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // level \n alone $given = <<<EOF # $open @@ -244,6 +681,32 @@ EOF; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // level \n alone + $given = <<<EOF + # $open + + $open + sql = SELECT 'Hello World' + $close +EOF; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // level \n alone + $given = <<<EOF + # $open + + myAlias$open + sql = SELECT 'Hello World' + $close +EOF; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // various linebreaks $given = <<<EOF # $open @@ -259,6 +722,38 @@ EOF; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // various linebreaks + $given = <<<EOF + # $open + + $open + + sql = SELECT 'Hello World' + + $close + +EOF; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // various linebreaks + $given = <<<EOF + # $open + + myAlias $open + + sql = SELECT 'Hello World' + + $close + +EOF; + $expected = "1.sql = SELECT 'Hello World'"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // multi line $given = <<<EOF # $open @@ -277,6 +772,42 @@ EOF; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // multi line + $given = <<<EOF + # $open + $open + sql = SELECT 'Hello World' + FROM Person + + ORDER BY id + + LIMIT 4 + head = <div> + $close +EOF; + $expected = "1.sql = SELECT 'Hello World' FROM Person ORDER BY id LIMIT 4\n1.head = <div>"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // multi line + $given = <<<EOF + # $open + myAlias $open + sql = SELECT 'Hello World' + FROM Person + + ORDER BY id + + LIMIT 4 + head = <div> + $close +EOF; + $expected = "1.sql = SELECT 'Hello World' FROM Person ORDER BY id LIMIT 4\n1.head = <div>"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // mulitple nesting $given = <<<EOF # $open @@ -300,6 +831,58 @@ EOF; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // mulitple nesting + $given = <<<EOF + # $open + $open + head = <h1> + $close + $open + sql = SELECT 'Hello World' + $open + sql = SELECT 'Hi' + head = <script> + tail = </script> + $open + head = <div> + $close + + $close + + $close + +EOF; + $expected = "1.head = <h1>\n2.sql = SELECT 'Hello World'\n2.3.sql = SELECT 'Hi'\n2.3.head = <script>\n2.3.tail = </script>\n2.3.4.head = <div>"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // mulitple nesting + $given = <<<EOF + # $open + myAlias$open + head = <h1> + $close + myAlias2$open + sql = SELECT 'Hello World' + myAlias3$open + sql = SELECT 'Hi' + head = <script> + tail = </script> + myAlias4$open + head = <div> + $close + + $close + + $close + +EOF; + $expected = "1.head = <h1>\n2.sql = SELECT 'Hello World'\n2.3.sql = SELECT 'Hi'\n2.3.head = <script>\n2.3.tail = </script>\n2.3.4.head = <div>"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + // muliple nesting, unnested rows inbetween $given = <<<EOF # $open @@ -325,6 +908,66 @@ EOF; $result = $btp->process($given); $this->assertEquals($expected, $result); + // Report notation 'alias' + // muliple nesting, unnested rows inbetween + $given = <<<EOF + # $open + $open + head = <h1> + $close + $open + sql = SELECT 'Hello World' + $open + sql = SELECT 'Hi' + head = <script> + tail = </script> + $open + head = <div> + $close + $close + $open + sql = SELECT 'After' + $close + $close + $open + sql = SELECT ... + $close + +EOF; + $expected = "1.head = <h1>\n2.sql = SELECT 'Hello World'\n2.3.sql = SELECT 'Hi'\n2.3.head = <script>\n2.3.tail = </script>\n2.3.4.head = <div>\n2.5.sql = SELECT 'After'\n6.sql = SELECT ..."; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // muliple nesting, unnested rows inbetween + $given = <<<EOF + # $open + myAlias$open + head = <h1> + $close + mySecondAlias$open + sql = SELECT 'Hello World' + myThirdAlias$open + sql = SELECT 'Hi' + head = <script> + tail = </script> + myFourthAlias$open + head = <div> + $close + $close + myFifthAlias$open + sql = SELECT 'After' + $close + $close + mySixthAlias$open + sql = SELECT ... + $close + +EOF; + $expected = "1.head = <h1>\n2.sql = SELECT 'Hello World'\n2.3.sql = SELECT 'Hi'\n2.3.head = <script>\n2.3.tail = </script>\n2.3.4.head = <div>\n2.5.sql = SELECT 'After'\n6.sql = SELECT ..."; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + } } @@ -347,6 +990,40 @@ EOF; $expected = "10.sql = SELECT 'Hello World', 'p:id=1&grId=2\n20.sql = SELECT ''"; $result = $btp->process($given); $this->assertEquals($expected, $result); + + // Report notation 'alias' + // Simple row, nothing to remove + $given = "{\nsql = SELECT 'Hello World', 'p:id=1&\\\ngrId=2\n}"; + $expected = "1.sql = SELECT 'Hello World', 'p:id=1&grId=2"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + $given = "{\nsql = SELECT 'Hello World', 'p:id=1& \\\n grId=2 \n}"; + $expected = "1.sql = SELECT 'Hello World', 'p:id=1&grId=2"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + $given = "{\nsql = SELECT 'Hello World', 'p:id=1& \\\n grId=2 \\\n }\n{\nsql = SELECT ''\n}"; + $expected = "1.sql = SELECT 'Hello World', 'p:id=1&grId=2\n2.sql = SELECT ''"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Simple row, nothing to remove + $given = "myAlias{\nsql = SELECT 'Hello World', 'p:id=1&\\\ngrId=2\n}"; + $expected = "1.sql = SELECT 'Hello World', 'p:id=1&grId=2"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + $given = "myAlias{\nsql = SELECT 'Hello World', 'p:id=1& \\\n grId=2 \n}"; + $expected = "1.sql = SELECT 'Hello World', 'p:id=1&grId=2"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); + + $given = "myAlias{\nsql = SELECT 'Hello World', 'p:id=1& \\\n grId=2 \\\n }\n{\nsql = SELECT ''\n}"; + $expected = "1.sql = SELECT 'Hello World', 'p:id=1&grId=2\n2.sql = SELECT ''"; + $result = $btp->process($given); + $this->assertEquals($expected, $result); } /** @@ -360,6 +1037,14 @@ EOF; // Nested: unmatched close bracket $btp->process("10.sql = SELECT 'Hello World'\n } \n30.sql = SELECT 'Hello World'\n"); + // Report notation 'alias' + // Nested: unmatched close bracket + $btp->process("sql = SELECT 'Hello World'\n } \n{\nsql = SELECT 'Hello World'\n}"); + + // Report notation 'alias' with alias + // Nested: unmatched close bracket + $btp->process("sql = SELECT 'Hello World'\n } \nmyAlias{\nsql = SELECT 'Hello World'\n}"); + } /** @@ -372,6 +1057,237 @@ EOF; // Nested: unmatched open bracket $btp->process("10.sql = SELECT 'Hello World'\n20 { \n30.sql = SELECT 'Hello World'\n"); + // Report notation 'alias' + // Nested: unmatched open bracket + $btp->process("{\nsql = SELECT 'Hello World'\n}\n { \n30.sql = SELECT 'Hello World'\n"); + + // Report notation 'alias' with alias + // Nested: unmatched open bracket + $btp->process("myAlias{\nsql = SELECT 'Hello World'\n}\n myAlias2{ \n30.sql = SELECT 'Hello World'\n"); + } -} + /** + * + */ + public function testReportLines() { + $btp = new BodytextParser(); + + // Simple statement + $given = "10.sql = SELECT 'Hello World'"; + $expected = [10 => 1]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statement, nested + $given = "10 {\nsql = SELECT 'Hello World'\n}"; + $expected = [10 => 2]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' + // Simple statement, nested + $given = "{\nsql = SELECT 'Hello World'\n}"; + $expected = [1 => 2]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Simple statement, nested + $given = "myAlias{\nsql = SELECT 'Hello World'\n}"; + $expected = [1 => 2]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statement, multiple unnecessary lines + $given = "\n#some comments\n10.sql = SELECT 'Hello World'\n\n \n #more comment"; + $expected = [10 => 3]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statement, multiple unnecessary lines, nested + $given = "\n#some comments\n10\n{\nsql = SELECT 'Hello World'\n\n}\n \n #more comment"; + $expected = [10 => 5]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' + // Simple statement, multiple unnecessary lines, nested + $given = "\n#some comments\n\n{\nsql = SELECT 'Hello World'\n\n}\n \n #more comment"; + $expected = [1 => 5]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Simple statement, multiple unnecessary lines, nested + $given = "\n#some comments\n\nmyAlias{\nsql = SELECT 'Hello World'\n\n}\n \n #more comment"; + $expected = [1 => 5]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // No statement + $given = "\n#some comments\n\n\n \n #more comment"; + $expected = []; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statement on multiple lines + $given = "\n10.sql = SELECT 'Hello World',\n'more content'\n WHERE help=1"; + $expected = [10 => 2]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statement on multiple lines, nested + $given = "\n10 {\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}"; + $expected = [10 => 3]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' + // Simple statement on multiple lines, nested + $given = "\n {\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}"; + $expected = [1 => 3]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Simple statement on multiple lines, nested + $given = "\n myAlias{\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}"; + $expected = [1 => 3]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statements + $given = "10.sql = SELECT 'Hello World'\n20.sql = SELECT 'Hello World'\n30.sql = SELECT 'Hello World'"; + $expected = [10 => 1, 20 => 2, 30 => 3]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statements, nested + $given = "10 {\nsql = SELECT 'Hello World'\n}\n20 {\nsql = SELECT 'Hello World'\n}\n30 {\nsql = SELECT 'Hello World'\n}"; + $expected = [10 => 2, 20 => 5, 30 => 8]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' + // Simple statements, nested + $given = "{\nsql = SELECT 'Hello World'\n}\n{\nsql = SELECT 'Hello World'\n}\n{\nsql = SELECT 'Hello World'\n}"; + $expected = [1 => 2, 2 => 5, 3 => 8]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Simple statements, nested + $given = "myAlias{\nsql = SELECT 'Hello World'\n}\nmyAlias2{\nsql = SELECT 'Hello World'\n}\nmyAlias3{\nsql = SELECT 'Hello World'\n}"; + $expected = [1 => 2, 2 => 5, 3 => 8]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statements, multiple unnecessary lines + $given = "\n#some comments\n10.sql = SELECT 'Hello World'\n\n \n #more comment \n 20.sql = SELECT 'Hello World'\n #more comment\n\n\n 30.sql=SELECT 'Hello World'"; + $expected = [10 => 3, 20 => 7, 30 => 11]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statements, multiple unnecessary lines, nested + $given = "\n#some comments\n10\n{\nsql = SELECT 'Hello World'\n\n}\n \n #more comment\n\n 20 {\nsql = SELECT 'Hello World'\n\n}\n #more comment \n\n\n 30 {\nsql = SELECT 'Hello World'\n\n}"; + $expected = [10 => 5, 20 => 12, 30 => 19]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' + // Simple statements, multiple unnecessary lines, nested + $given = "\n#some comments\n\n{\nsql = SELECT 'Hello World'\n\n}\n \n #more comment\n\n {\nsql = SELECT 'Hello World'\n\n}\n #more comment \n\n\n {\nsql = SELECT 'Hello World'\n\n}"; + $expected = [1 => 5, 2 => 12, 3 => 19]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Simple statements, multiple unnecessary lines, nested + $given = "\n#some comments\n\nmyAlias{\nsql = SELECT 'Hello World'\n\n}\n \n #more comment\n\n myAlias2{\nsql = SELECT 'Hello World'\n\n}\n #more comment \n\n\n myAlias3{\nsql = SELECT 'Hello World'\n\n}"; + $expected = [1 => 5, 2 => 12, 3 => 19]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statements on multiple lines + $given = "\n10.sql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n\n 20.sql = SELECT 'Hello World', \n 'some more content'"; + $expected = [10 => 2, 20 => 6]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Simple statements on multiple lines, nested + $given = "\n10 {\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}\n\n20\n {\nsql = SELECT 'Hello World', \n 'some more content'\n}"; + $expected = [10 => 3, 20 => 10]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' + // Simple statements on multiple lines, nested + $given = "\n {\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}\n\n{\nsql = SELECT 'Hello World', \n 'some more content'\n}"; + $expected = [1 => 3, 2 => 9]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Simple statements on multiple lines, nested + $given = "\n myAlias{\nsql = SELECT 'Hello World',\n'more content'\n WHERE help=1\n}\n\nmyAlias2{\nsql = SELECT 'Hello World', \n 'some more content'\n}"; + $expected = [1 => 3, 2 => 9]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Complex statements + $given = "10.sql = SELECT 'Hello World'\n20.sql='Hello world2'\n20.30.sql=SELECT 'Hello World3'\n20.30.40.sql = SELECT 'Hello World4'"; + $expected = [10 => 1, 20 => 2, '20.30' => 3, '20.30.40' => 4]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Complex statements, nested + $given = "10.sql = SELECT 'Hello World'\n20 {\nsql='Hello world2'\n30 { \n sql=SELECT 'Hello World3'\n40 { \n sql = SELECT 'Hello World4'\n } \n } \n } "; + $expected = [10 => 1, 20 => 3, '20.30' => 5, '20.30.40' => 7]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' + // Complex statements: complex + $given = "{\nsql = SELECT 'Hello World'\n}\n {\nsql='Hello world2'\n { \n sql=SELECT 'Hello World3'\n { \n sql = SELECT 'Hello World4'\n } \n } \n } "; + $expected = [1 => 2, 2 => 5, '2.3' => 7, '2.3.4' => 9]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + + // Report notation 'alias' with alias + // Complex statements: complex + $given = "myAlias{\nsql = SELECT 'Hello World'\n}\n myAlias2{\nsql='Hello world2'\n myAlias3{ \n sql=SELECT 'Hello World3'\n myAlias4{ \n sql = SELECT 'Hello World4'\n } \n } \n } "; + $expected = [1 => 2, 2 => 5, '2.3' => 7, '2.3.4' => 9]; + $btp->process($given); + $result = $btp->reportLines; + $this->assertEquals($expected, $result); + } +} \ No newline at end of file diff --git a/extension/Tests/Unit/Core/BuildFormPlainTest.php b/extension/Tests/Unit/Core/BuildFormPlainTest.php index 0aab8fdfb1cab35134867d1e4cf55e7ec8479e19..c9733c71eb9ff035a61f1418bf3ae3162307faf3 100644 --- a/extension/Tests/Unit/Core/BuildFormPlainTest.php +++ b/extension/Tests/Unit/Core/BuildFormPlainTest.php @@ -40,11 +40,17 @@ class BuildFormPlainTest extends AbstractDatabaseTest { $build = new BuildFormPlain([F_DB_INDEX => DB_INDEX_DEFAULT], array(), array(), $this->dbArray); $formId1 = $build->getFormId(); - $this->assertMatchesRegularExpression('/qfq-form-[0-9a-f]{13}/', $formId1); + $this->assertMatchesRegularExpression('/qfq-form-[0-9]*-[0-9]*-[0-9]*/', $formId1); $formId2 = $build->getFormId(); $this->assertEquals($formId1, $formId2); + /* + * 1) IMATHUZH\Qfq\Tests\Unit\Core\BuildFormPlainTest::testGetFormId +Failed asserting that 'qfq-form-1234--3' matches PCRE pattern "/qfq-form-[0-9a-f]{13}/". +/home/gitlab-runner/builds/_jtKSCsb/0/typo3/qfq/typo3conf/ext/qfq/Tests/Unit/Core/BuildFormPlainTest.php:43 +FAILURES! + */ } /** diff --git a/extension/Tests/Unit/Core/Form/DirtyTest.php b/extension/Tests/Unit/Core/Form/DirtyTest.php index 3fe8a8c1098c372b81aedfe20d9012d6e3f9df3f..f8a18f000fa7d8e1165cf280d25f6aef6c3ff69b 100644 --- a/extension/Tests/Unit/Core/Form/DirtyTest.php +++ b/extension/Tests/Unit/Core/Form/DirtyTest.php @@ -9,7 +9,7 @@ namespace IMATHUZH\Qfq\Tests\Unit\Core\Form; use IMATHUZH\Qfq\Core\Database\Database; - + use IMATHUZH\Qfq\Core\Form\Dirty; use IMATHUZH\Qfq\Core\Store\Session; use IMATHUZH\Qfq\Core\Store\Sip; @@ -17,6 +17,8 @@ use IMATHUZH\Qfq\Tests\Unit\Core\Database\AbstractDatabaseTest; require_once(__DIR__ . '/../Database/AbstractDatabaseTest.php'); +const MSG_RECORD_ALREADY_LOCKED = 'Record already locked'; + /* * Open to check * - FORM_DELETE @@ -352,7 +354,7 @@ class DirtyTest extends AbstractDatabaseTest { // Alice lock again $result = $dirty->process(); - $msg = 'The record has already'; + $msg = MSG_RECORD_ALREADY_LOCKED; $expected = [API_STATUS => API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE, API_MESSAGE => $msg]; // cut IP, User and Timestamp @@ -508,7 +510,7 @@ class DirtyTest extends AbstractDatabaseTest { $result = $dirty->process(); - $msg = 'The record has already'; + $msg = MSG_RECORD_ALREADY_LOCKED; $expected = [API_STATUS => API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE, API_MESSAGE => $msg]; // cut IP, User and Timestamp @@ -700,8 +702,8 @@ class DirtyTest extends AbstractDatabaseTest { // Alice lock again $result = $dirty->process(); - $msg = 'The record has already'; - $expected = [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => $msg]; + $msg = MSG_RECORD_ALREADY_LOCKED; + $expected = [API_STATUS => API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE, API_MESSAGE => $msg]; // cut IP, User and Timestamp $result[API_MESSAGE] = substr($result[API_MESSAGE], 0, strlen($msg)); @@ -855,7 +857,7 @@ class DirtyTest extends AbstractDatabaseTest { $result = $dirty->process(); - $msg = 'The record has already'; + $msg = MSG_RECORD_ALREADY_LOCKED; $expected = [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => $msg]; // cut IP, User and Timestamp @@ -977,7 +979,7 @@ class DirtyTest extends AbstractDatabaseTest { $result = $dirty->process(); - $msg = 'The record has already'; + $msg = MSG_RECORD_ALREADY_LOCKED; $expected = [API_STATUS => API_ANSWER_STATUS_CONFLICT_ALLOW_FORCE, API_MESSAGE => $msg]; // cut IP, User and Timestamp @@ -1021,7 +1023,7 @@ class DirtyTest extends AbstractDatabaseTest { $result = $dirty->process(); - $msg = 'The record has already'; + $msg = 'Record already locked '; $expected = [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => $msg]; // cut IP, User and Timestamp @@ -1065,7 +1067,7 @@ class DirtyTest extends AbstractDatabaseTest { $result = $dirty->process(); - $msg = 'The record has already'; + $msg = MSG_RECORD_ALREADY_LOCKED; $expected = [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => $msg]; // cut IP, User and Timestamp @@ -1109,7 +1111,7 @@ class DirtyTest extends AbstractDatabaseTest { $result = $dirty->process(); - $msg = 'The record has already'; + $msg = MSG_RECORD_ALREADY_LOCKED; $expected = [API_STATUS => API_ANSWER_STATUS_CONFLICT, API_MESSAGE => $msg]; // cut IP, User and Timestamp diff --git a/extension/Tests/Unit/Core/Helper/HelperFormElementTest.php b/extension/Tests/Unit/Core/Helper/HelperFormElementTest.php index f8c552f052321554cc8480e2dacee83894fa939a..8ac295a49da39a0a92a01ca751c49737c879ced1 100644 --- a/extension/Tests/Unit/Core/Helper/HelperFormElementTest.php +++ b/extension/Tests/Unit/Core/Helper/HelperFormElementTest.php @@ -119,7 +119,7 @@ class HelperFormElementTest extends TestCase { public function testInitActionFormElement() { $list = [FE_TYPE, FE_SLAVE_ID, FE_SQL_VALIDATE, FE_SQL_BEFORE, FE_SQL_INSERT, FE_SQL_UPDATE, FE_SQL_DELETE, - FE_SQL_AFTER, FE_EXPECT_RECORDS, FE_REQUIRED_LIST, FE_MESSAGE_FAIL, FE_SENDMAIL_TO, FE_SENDMAIL_CC, + FE_SQL_AFTER, FE_EXPECT_RECORDS, FE_REQUIRED_LIST, FE_ALERT, FE_QFQ_LOG, FE_MESSAGE_FAIL, FE_SENDMAIL_TO, FE_SENDMAIL_CC, FE_SENDMAIL_BCC, FE_SENDMAIL_FROM, FE_SENDMAIL_SUBJECT, FE_SENDMAIL_REPLY_TO, FE_SENDMAIL_FLAG_AUTO_SUBMIT, FE_SENDMAIL_GR_ID, FE_SENDMAIL_X_ID, FE_SENDMAIL_X_ID2, FE_SENDMAIL_X_ID3, FE_SENDMAIL_BODY_MODE, FE_SENDMAIL_BODY_HTML_ENTITY, FE_SENDMAIL_SUBJECT_HTML_ENTITY]; diff --git a/extension/Tests/Unit/Core/Parser/KVPairListParserTest.php b/extension/Tests/Unit/Core/Parser/KVPairListParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ae87a6c8b81293ef5dfc1493010e7e04b8ae069b --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/KVPairListParserTest.php @@ -0,0 +1,79 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\KVPairListParser; +use IMATHUZH\Qfq\Core\Parser\SimpleParser; +use PHPUnit\Framework\TestCase; + +class KVPairListParserTest extends TestCase { + public function testEmptyString() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + [], + $parser->parse('') + ); + } + + public function testSinglePair() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals(['a' => 42], $parser->parse('a:42')); + } + + public function testSingleKey() { + $parser = new KVPairListParser('|', ':', [ + SimpleParser::OPTION_KEY_IS_VALUE => true + ]); + $this->assertEquals(['a' => 'a'], $parser->parse('a:')); + + $parser = new KVPairListParser('|', ':', [ + SimpleParser::OPTION_KEY_IS_VALUE => false, + SimpleParser::OPTION_EMPTY_VALUE => 18 + ]); + $this->assertEquals(['a' => 18], $parser->parse('a:')); + $this->assertEquals(['a' => 18], $parser->parse('a')); + } + + public function testSimpleList() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + ['ab'=>'x','cd'=>'y','ef'=>'z'], + $parser->parse('ab:x|cd:y|ef:z') + ); + } + + public function testEscapedSeparators() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + ['ab' => 'x','cd:y|ef' => 'z:a'], + $parser->parse('ab:x|cd\\:y\\|ef:z\\:a') + ); + } + + public function testQuotedSeparators() { + $parser = new KVPairListParser('|', ':'); + $this->assertEquals( + ['ab' => 'x','cd:y| ef' => 'z:a'], + $parser->parse('ab:x|"cd:y| ef":z":a"') + ); + } + + public function testIterate() { + $parser = new KVPairListParser('|', ':'); + $iterator = $parser->iterate('a:1|b:2|c:3'); + $expected = ['a' => 1, 'b' => 2, 'c' => 3]; + foreach($iterator as $key => $value) { + $expectedKey = key($expected); + $expectedValue = current($expected); + $this->assertSame($expectedKey, $key); + $this->assertSame($expectedValue, $value); + next($expected); + } + $this->assertFalse(current($expected)); + } +} diff --git a/extension/Tests/Unit/Core/Parser/MixedTypeParserTest.php b/extension/Tests/Unit/Core/Parser/MixedTypeParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..66f9f69b78e6afdf219e41fc62e395cf6f167b5c --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/MixedTypeParserTest.php @@ -0,0 +1,192 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\MixedTypeParser; +use PHPUnit\Framework\TestCase; + +/** + * Class MixedTypeParserTest + * @package qfq + */ +class MixedTypeParserTest extends TestCase { + + protected function assertGeneratorOutput($generator, $output) { + foreach ($output as $value) { + $data = $generator->current(); + $data[0] = strval($data[0]); + $this->assertSame($value, $data, "Expecting " . json_encode($value)); + $generator->next(); + } + $this->assertNull($generator->current()); + } + + public function testLiteralValues() { + $parser = new MixedTypeParser(null, ['empty' => 18]); + $data = [ + 'abc' => 'abc', ' x\\:y ' => 'x:y', + '' => 18, '""' => '', + ' ' => 18, '" "' => ' ', + '123' => 123, '"123"' => '123', + '12.5' => 12.5, '"12.5"' => '12.5', + 'false' => false, '"false"' => 'false', + 'true' => true, '"true"' => 'true', + 'yes' => true, '"yes"' => 'yes', + 'no' => false, '"no"' => 'no', + 'null' => null, '"null"' => 'null' + ]; + foreach ($data as $input => $expected) + $this->assertSame($expected, $parser->parse($input)); + } + + public function testParseFlatList() { + $parser = new MixedTypeParser(); + // Empty list + $this->assertEquals([], $parser->parseList('')); + $this->assertEquals([], $parser->parseList(' ')); + // Compact list + $this->assertEquals( + ['a', 'b', 'c', 'd'], + $parser->parseList("a,b,c,d") + ); + // Spaces are stripped + $this->assertEquals( + ['a', 'b', 'c'], + $parser->parseList("a, b, c ") + ); + // Internal spaces are preserved + $this->assertEquals( + ['a', 'b c', 'd'], + $parser->parseList("a, b c , d") + ); + // Escaped commas are ignored + $this->assertEquals( + ['a,b', 'c'], + $parser->parseList("a\\,b,c") + ); + // Quoted commas are ignored + $this->assertEquals( + ['a, b', 'c'], + $parser->parseList("'a, b', c") + ); + // Trailing comma adds an empty element + $this->assertEquals( + [null, null, 'c', null], + $parser->parseList(",,c,") + ); + } + + public function testParseListValues() { + $parser = new MixedTypeParser(); + $this->assertEquals([], $parser->parse("[]")); + $this->assertEquals([], $parser->parse("[ ]")); + + // $this->assertEquals([''], $parser->parse("['']")); + + $this->assertEquals( + ['a', 'b', 'c', 'd'], + $parser->parse("[a,b,c,d]") + ); + $this->assertEquals( + ['a', 'b', 'c', 'd'], + $parser->parse(" [a, b, c ,d] ") + ); + + } + + public function testParseFlatDictionary() { + $parser = new MixedTypeParser(); + // Empty object + $this->assertEquals([], $parser->parseDictionary('')); + $this->assertEquals([], $parser->parseDictionary(' ')); + // Compact dictionary + $this->assertEquals( + ['a'=>'1', 'b'=>'2', 'c'=>'3', 'd'=>'4'], + $parser->parseDictionary("a:1,b:2,c:3,d:4") + ); + // Spaces are stripped + $this->assertEquals( + ['a'=>'1', 'b'=>'2'], + $parser->parseDictionary(" a : 1, b: 2\n\t ") + ); + // Internal spaces are preserved + $this->assertEquals( + ['a b'=>'1', 'b'=>'2 3 4'], + $parser->parseDictionary(" a b: 1, b: 2 3 4") + ); + // Escaped delimiters are ignored + $this->assertEquals( + ['a,b'=>'1', 'b'=>'2:3,4'], + $parser->parseDictionary("a\\,b:1,b:2\\:3\\,4") + ); + // Quoted delimiters are ignored + $this->assertGeneratorOutput( + $parser->tokenized("'a:1,b':\"23,4\""), + [['a:1,b',':'],['23,4',null]] + ); + $this->assertEquals( + ['a:1,b' => '23,4'], + $parser->parseDictionary("'a:1,b':\"23,4\"") + ); + // Trailing commas are ignored + $this->assertEquals( + ['a' => 1, 'b' => 2], + $parser->parseDictionary('a:1, b:2, ') + ); + } + + public function testParseDictValues() { + $parser = new MixedTypeParser(); + $this->assertEquals([], $parser->parse("{}")); + $this->assertEquals([], $parser->parse("{ }")); + + $this->assertEquals(['a' => null], $parser->parse("{a:}")); + + $this->assertEquals( + ['a'=>'1', 'b'=>'2', 'c'=>'3', 'd'=>'4'], + $parser->parse("{a:1,b:2,c:3,d:4}") + ); + + $this->assertEquals( + ['a'=>'1', 'b'=>'2', 'c'=>'3', 'd'=>'4'], + $parser->parse(" { a:1, b:2, c : 3 ,d:4 } ") + ); + } + + public function testParseNestedStructures() { + $parser = new MixedTypeParser(); + // Nested lists + $this->assertEquals( + ['a', 'b', ['ca', 'cb'], 'd'], + $parser->parse("[a, b, [ca, cb], d]") + ); + // Dictionary nested in a list + $this->assertEquals( + ['a', 'b', ['ca'=>'0', 'cb'=>'1'], 'd'], + $parser->parse("[a, b, {ca:0, cb:1}, d]") + ); + // List nested in a dictionary + $this->assertEquals( + ['a'=>'0', 'b'=>'1', 'c'=>['ca', 'cb'], 'd'=>'3'], + $parser->parse("{a:0, b:1, c:[ca, cb], d:3}") + ); + // Nested dictionaries + $this->assertEquals( + ['a'=>'0', 'b'=>'1', 'c'=>['ca'=>'5', 'cb'=>'6'], 'd'=>'3'], + $parser->parse("{a:0, b:1, c:{ca:5, cb:6}, d:3}") + ); + } + + public function testRestrictedParser() { + $parser = new MixedTypeParser(',: {}'); + $this->assertEquals( + ['a' => '[b]', 'c' => '[d', 'e]' => ''], + $parser->parse('{a:[b],c:[d,e]}') + ); + } +} \ No newline at end of file diff --git a/extension/Tests/Unit/Core/Parser/SimpleParserTest.php b/extension/Tests/Unit/Core/Parser/SimpleParserTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e6de69da32215b9fe1ce2860f6e97358739928f5 --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/SimpleParserTest.php @@ -0,0 +1,75 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\SimpleParser; +use PHPUnit\Framework\TestCase; + +class SimpleParserTest extends TestCase { + + public function testEmptyString() { + $parser = new SimpleParser(','); + $this->assertEquals( + [], + $parser->parse('') + ); + } + + public function testSingletons() { + $parser = new SimpleParser(','); + $this->assertEquals(['abcd'], $parser->parse('abcd')); + $this->assertEquals([42], $parser->parse('42')); + } + + public function testSimpleList() { + $parser = new SimpleParser(','); + $this->assertEquals( + ['ab','cd','ef'], + $parser->parse('ab,cd,ef') + ); + $parser = new SimpleParser('|'); + $this->assertEquals( + ['ab','cd','ef'], + $parser->parse('ab|cd|ef') + ); + $parser = new SimpleParser('|,.'); + $this->assertEquals( + ['ab','cd','ef','gh'], + $parser->parse('ab|cd,ef.gh') + ); + } + + public function testEscapedSeparators() { + $parser = new SimpleParser(','); + $this->assertEquals( + ['ab','cd,ef'], + $parser->parse('ab,cd\\,ef') + ); + } + + public function testQuotedSeparators() { + $parser = new SimpleParser(','); + $this->assertEquals( + ['ab','cd, ef'], + $parser->parse('ab,"cd, ef"') + ); + } + + public function testIterate() { + $parser = new SimpleParser(','); + $iterator = $parser->iterate('a,b,c'); + $expected = ['a', 'b', 'c']; + foreach($iterator as $value) { + $this->assertSame(current($expected), $value); + next($expected); + } + $this->assertFalse(current($expected)); + + } +} + diff --git a/extension/Tests/Unit/Core/Parser/StringTokenizerTest.php b/extension/Tests/Unit/Core/Parser/StringTokenizerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..cdf586cf755c735473f9f1a9a2b889a70db83653 --- /dev/null +++ b/extension/Tests/Unit/Core/Parser/StringTokenizerTest.php @@ -0,0 +1,132 @@ +<?php +/** + * @package qfq + * @author kputyr + * @date: 09.11.2023 + */ + +namespace Unit\Core\Parser; + +use IMATHUZH\Qfq\Core\Parser\StringTokenizer; +use PHPUnit\Framework\TestCase; + +class StringTokenizerTest extends TestCase { + + protected function assertTokenizer($delimiters, $data, $expected) { + $parser = new StringTokenizer($delimiters); + $generator = $parser->tokenized($data); + foreach ($expected as $data) { + list($token, $delimiter) = $generator->current(); + if (is_array($data)) { + $this->assertSame($data[0], $token->value, "Expecting $data[0]"); + $this->assertSame($data[1], $delimiter, "Expecting '$data[1]'"); + } else { + $this->assertSame($data, $token->value, "Expecting $data"); + } + $generator->next(); + } + $this->assertNull($generator->current()); + } + + public function testOffset() { + $parser = new StringTokenizer(':|'); + $input = 'a:bc:de|f'; + $offsets = [1,4,7,null]; + $tokens = $parser->tokenized($input); + foreach($offsets as $value) { + $tokens->current(); + if (!is_null($value)) $this->assertSame($value, $parser->offset()); + $tokens->next(); + } + $this->assertFalse($tokens->valid()); + } + + public function testEmptyString() { + $this->assertTokenizer(':,[]{}', '', []); + } + + public function testSimpleString() { + $this->assertTokenizer(':,[]{}', 'abc', [['abc',null]]); + } + + public function testUnescapedDelimiters() { + $this->assertTokenizer( + ':,./]{', + "x:7,y.z/24]{x", + [[ 'x', ':'], ['7', ','], ['y', '.'], ['z', '/'], + ['24', ']'], ['','{'], ['x',null]] + ); + } + + public function testEscapedDelimiters() { + $this->assertTokenizer( + ':,./]{', + "x\\:7\\,y\\.z\\/24\\]\\{x", + [[ "x:7,y.z/24]{x", null]] + ); + } + + public function testEscapedNonDelimiters() { + $this->assertTokenizer( + ':', + 'x\\\\y\\n', + ['x\\\\y\\n'] + ); + } + + public function testTokensAreTrimmed() { + $this->assertTokenizer( + '|', + ' a|b | cd ', + ['a', 'b', 'cd'] + ); + } + + public function testSpacesInside() { + $this->assertTokenizer( + ':', + "x y: sy ca : f\t\ng", + ['x y', 'sy ca', "f\t\ng"] + ); + } + + public function testQuotesAreRemoved() { + $this->assertTokenizer( + ':', + '"x y":ab\'x\'cd', + ['x y', 'abxcd'] + ); + } + + public function testQuotedSpacesArePreserved() { + $this->assertTokenizer( + ':', + '" "x:ab" x ":\' \'', + [' x', 'ab x ', ' '] + ); + } + + public function testEscapedQuotesArePreserved() { + $this->assertTokenizer( + ':', + '\"x y\":ab\\\'cd', + ['"x y"', "ab'cd"] + ); + } + + public function testNestedQuotesAreNotParsed() { + $this->assertTokenizer( + ':', + '"\'":\'"\'', + ["'", '"'] + ); + } + + public function testQuotedDelimitersAreIgnored() { + $this->assertTokenizer( + ':,|', + 'x:a\\|b|c\\,d\\:e:24', + ['x', 'a|b', 'c,d:e', '24'] + ); + } +} diff --git a/extension/Tests/Unit/Core/Report/ReportTest.php b/extension/Tests/Unit/Core/Report/ReportTest.php index 789b04a23d134f5480adf434c6ab768b05d85644..b8135a028bc918d113e21ada43283591505233c9 100644 --- a/extension/Tests/Unit/Core/Report/ReportTest.php +++ b/extension/Tests/Unit/Core/Report/ReportTest.php @@ -1384,6 +1384,9 @@ EOF; */ public function testReportPageWrapper() { + // Report notation 'alias' with alias + $this->store->setVar(TOKEN_ALIAS . '.1', 'myAlias', STORE_TYPO3); + $line = <<<EOF 10.sql = SELECT firstname FROM Person ORDER BY id LIMIT 2 10.head = <table> @@ -1403,6 +1406,32 @@ EOF; 10.10.tail = Dynamic tail 10.10.althead = No record found 10.10.altsql = SELECT 'alt sql fired' +EOF; + + $result = $this->report->process($line); + $expect = "<table><tr><td>John</td><br>Static headDynamic headnestedDynamic tailStatic tail</tr>--<tr><td>Jane</td><br>Static headNo record foundalt sql firedStatic tail</tr></table>"; + $this->assertEquals($expect, $result); + + // Report notation 'alias' with alias + $line = <<<EOF +1.sql = SELECT firstname FROM Person ORDER BY id LIMIT 2 +1.head = <table> +1.tail = </table> +1.rbeg = <tr> +1.rend = <br> +1.renr = </tr> +1.fbeg = <td> +1.fend = </td> +1.rsep = -- +1.fsep = ++ + +1.2.sql = SELECT 'nested' FROM (SELECT '') AS fake WHERE '{{myAlias.line.count}}'='1' +1.2.shead = Static head +1.2.stail = Static tail +1.2.head = Dynamic head +1.2.tail = Dynamic tail +1.2.althead = No record found +1.2.altsql = SELECT 'alt sql fired' EOF; $result = $this->report->process($line); @@ -1425,6 +1454,9 @@ EOF; */ public function testReportContent() { + // Report notation 'alias' with alias + $this->store->setVar(TOKEN_ALIAS . '.1', 'myAlias', STORE_TYPO3); + $line = <<<EOF 10.sql = SELECT 'Hello' 20.sql = SELECT 'World' @@ -1434,6 +1466,16 @@ EOF; $expect = "HelloWorld{{10.line.content}}"; $this->assertEquals($expect, $result); + // Report notation 'alias' with alias + $line = <<<EOF +1.sql = SELECT 'Hello' +2.sql = SELECT 'World' +2.tail = {{myAlias.line.content}} +EOF; + $result = $this->report->process($line); + $expect = "HelloWorld{{myAlias.line.content}}"; + $this->assertEquals($expect, $result); + $line = <<<EOF 10.sql = SELECT 'Hello' 10.content = hide @@ -1444,6 +1486,17 @@ EOF; $expect = "WorldHello"; $this->assertEquals($expect, $result); + // Report notation 'alias' with alias + $line = <<<EOF +1.sql = SELECT 'Hello' +1.content = hide +2.sql = SELECT 'World' +2.tail = {{myAlias.line.content}} +EOF; + $result = $this->report->process($line); + $expect = "WorldHello"; + $this->assertEquals($expect, $result); + $line = <<<EOF 10.sql = SELECT 'Hello' 10.content = show @@ -1454,6 +1507,17 @@ EOF; $expect = "HelloWorldHello"; $this->assertEquals($expect, $result); + // Report notation 'alias' with alias + $line = <<<EOF +1.sql = SELECT 'Hello' +1.content = show +2.sql = SELECT 'World' +2.tail = {{myAlias.line.content}} +EOF; + $result = $this->report->process($line); + $expect = "HelloWorldHello"; + $this->assertEquals($expect, $result); + // Check that current row can be reused in head, tail, rbeg, rend, renr $line = <<<EOF 10.sql = SELECT 'Hello' @@ -1468,11 +1532,37 @@ EOF; $expect = "HelloHelloHelloHelloHelloHello"; $this->assertEquals($expect, $result); + // Report notation 'alias' with alias + // Check that current row can be reused in head, tail, rbeg, rend, renr + $line = <<<EOF +1.sql = SELECT 'Hello' +1.content = show +1.head = {{myAlias.line.content}} +1.tail = {{myAlias.line.content}} +1.rbeg = {{myAlias.line.content}} +1.rend = {{myAlias.line.content}} +1.renr = {{myAlias.line.content}} +EOF; + $result = $this->report->process($line); + $expect = "HelloHelloHelloHelloHelloHello"; + $this->assertEquals($expect, $result); + // Check single tick escape $line = <<<EOF 10.sql = SELECT "Hel'lo" 10.content = hide 20.sql = SELECT '--{{10.line.content::s}}--', "--{{10.line.content}}--", "--{{10.line.content::s}}--" +EOF; + $result = $this->report->process($line); + $expect = "--Hel'lo----Hel'lo----Hel'lo--"; + $this->assertEquals($expect, $result); + + // Report notation 'alias' with alias + // Check single tick escape + $line = <<<EOF +1.sql = SELECT "Hel'lo" +1.content = hide +2.sql = SELECT '--{{myAlias.line.content::s}}--', "--{{myAlias.line.content}}--", "--{{myAlias.line.content::s}}--" EOF; $result = $this->report->process($line); $expect = "--Hel'lo----Hel'lo----Hel'lo--"; @@ -1563,15 +1653,27 @@ EOF; */ public function testReportVariables() { + // Report notation 'alias' with alias + $this->store->setVar(TOKEN_ALIAS . '.1', 'myAlias', STORE_TYPO3); + $this->store->setVar(TOKEN_ALIAS . '.1.2', 'myAlias2', STORE_TYPO3); + $result = $this->report->process("10.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id LIMIT 1"); $this->assertEquals("normal text ", $result); $result = $this->report->process("10.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id LIMIT 1\n10.10.sql = SELECT '{{10.hidden}}'"); $this->assertEquals("normal text hidden", $result); + // Report notation 'alias' with alias + $result = $this->report->process("1.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id LIMIT 1\n1.2.sql = SELECT '{{myAlias.hidden}}'"); + $this->assertEquals("normal text hidden", $result); + $result = $this->report->process("10.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id LIMIT 1\n10.10.sql = SELECT '{{10.unknown}}'"); $this->assertEquals("normal text {{10.unknown}}", $result); + // Report notation 'alias' with alias + $result = $this->report->process("1.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id LIMIT 1\n1.2.sql = SELECT '{{myAlias.unknown}}'"); + $this->assertEquals("normal text {{myAlias.unknown}}", $result); + $result = $this->report->process("10.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id LIMIT 1\n10.10.sql = SELECT '{{fake}}'"); $this->assertEquals("normal text {{fake}}", $result); @@ -1588,12 +1690,24 @@ EOF; $result = $this->report->process("10.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id\n10.10.sql = SELECT '{{fake:V}}-{{10.line.count}}-{{10.line.total}}-{{10.line.insertId}} '"); $this->assertEquals("normal text hello world -1-2-0 normal text hello world -2-2-0 ", $result); + // Report notation 'alias' with alias + $result = $this->report->process("1.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id\n1.2.sql = SELECT '{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}} '"); + $this->assertEquals("normal text hello world -1-2-0 normal text hello world -2-2-0 ", $result); + $result = $this->report->process("10.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id\n10.10.sql = SELECT '{{fake:V}}-{{10.line.count}}-{{10.line.total}} '"); $this->assertEquals("normal text hello world -1-2 normal text hello world -2-2 ", $result); + // Report notation 'alias' with alias + $result = $this->report->process("1.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id\n1.2.sql = SELECT '{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}} '"); + $this->assertEquals("normal text hello world -1-2 normal text hello world -2-2 ", $result); + $result = $this->report->process("10.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id\n10.10.sql = SELECT '{{fake:V}}-{{10.line.count}}-{{10.line.total}}-{{10.10.line.count}}-{{10.10.line.total}} '"); $this->assertEquals("normal text hello world -1-2-1-1 normal text hello world -2-2-1-1 ", $result); + // Report notation 'alias' with alias + $result = $this->report->process("1.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id\n1.2.sql = SELECT '{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias2.line.count}}-{{myAlias2.line.total}} '"); + $this->assertEquals("normal text hello world -1-2-1-1 normal text hello world -2-2-1-1 ", $result); + $result = $this->report->process("10.sql = SELECT 'normal ', 'hidden' AS _hidden, 'text ' FROM Person ORDER BY id\n10.10.sql = SELECT '{{fake:V:::not found}} '"); $this->assertEquals("normal text hello world normal text hello world ", $result); @@ -1643,6 +1757,26 @@ EOF; $expect = "h:hello world-1-2-0,rb:hello world-1-2-0,-fb:hello world-1-2-0,Doe-fe:hello world-1-2-0,fs:hello world-1-2-0,-fb:hello world-1-2-0,John-fe:hello world-1-2-0,re:hello world-1-2-0,rr:hello world-1-2-0,rs:hello world-1-2-0,rb:hello world-2-2-0,-fb:hello world-2-2-0,Smith-fe:hello world-2-2-0,fs:hello world-2-2-0,-fb:hello world-2-2-0,Jane-fe:hello world-2-2-0,re:hello world-2-2-0,rr:hello world-2-2-0,t:hello world-2-2-0,"; $this->assertEquals($expect, $result); + // Report notation 'alias' with alias + $line = <<<EOF +1.sql = SELECT name, firstname FROM Person ORDER BY id LIMIT 2 +1.head = h:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.tail = t:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.rbeg = rb:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.rend = re:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.renr = rr:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.fbeg = -fb:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.fend = -fe:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.rsep = rs:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.fsep = fs:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +EOF; + + $this->store->setVar('fake', 'hello world', STORE_VAR); + $this->store->setVar(TOKEN_ALIAS . '.1', 'myAlias', STORE_TYPO3); + $result = $this->report->process($line); + $expect = "h:hello world-1-2-0,rb:hello world-1-2-0,-fb:hello world-1-2-0,Doe-fe:hello world-1-2-0,fs:hello world-1-2-0,-fb:hello world-1-2-0,John-fe:hello world-1-2-0,re:hello world-1-2-0,rr:hello world-1-2-0,rs:hello world-1-2-0,rb:hello world-2-2-0,-fb:hello world-2-2-0,Smith-fe:hello world-2-2-0,fs:hello world-2-2-0,-fb:hello world-2-2-0,Jane-fe:hello world-2-2-0,re:hello world-2-2-0,rr:hello world-2-2-0,t:hello world-2-2-0,"; + $this->assertEquals($expect, $result); + $line = <<<EOF 10.sql = SELECT name, firstname FROM Person ORDER BY id LIMIT 2 10.10.sql = SELECT ' blue ' @@ -1662,6 +1796,26 @@ EOF; $expect = "DoeJohnh:hello world-1-2-0,rb:hello world-1-2-0,-fb:hello world-1-2-0, blue -fe:hello world-1-2-0,re:hello world-1-2-0,rr:hello world-1-2-0,t:hello world-1-2-0,SmithJaneh:hello world-2-2-0,rb:hello world-2-2-0,-fb:hello world-2-2-0, blue -fe:hello world-2-2-0,re:hello world-2-2-0,rr:hello world-2-2-0,t:hello world-2-2-0,"; $this->assertEquals($expect, $result); + // Report notation 'alias' with alias + $line = <<<EOF +1.sql = SELECT name, firstname FROM Person ORDER BY id LIMIT 2 +1.2.sql = SELECT ' blue ' +1.2.head = h:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.2.tail = t:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.2.rbeg = rb:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.2.rend = re:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.2.renr = rr:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.2.fbeg = -fb:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.2.fend = -fe:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.2.rsep = rs:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +1.2.fsep = fs:{{fake:V}}-{{myAlias.line.count}}-{{myAlias.line.total}}-{{myAlias.line.insertId}}, +EOF; + + $this->store->setVar('fake', 'hello world', STORE_VAR); + $this->store->setVar(TOKEN_ALIAS . '.1', 'myAlias', STORE_TYPO3); + $result = $this->report->process($line); + $expect = "DoeJohnh:hello world-1-2-0,rb:hello world-1-2-0,-fb:hello world-1-2-0, blue -fe:hello world-1-2-0,re:hello world-1-2-0,rr:hello world-1-2-0,t:hello world-1-2-0,SmithJaneh:hello world-2-2-0,rb:hello world-2-2-0,-fb:hello world-2-2-0, blue -fe:hello world-2-2-0,re:hello world-2-2-0,rr:hello world-2-2-0,t:hello world-2-2-0,"; + $this->assertEquals($expect, $result); } diff --git a/extension/Tests/Unit/Core/Store/StoreTest.php b/extension/Tests/Unit/Core/Store/StoreTest.php index 449738d1ce62b92c38529e0215f9cdb79de54682..c50ddf77a148896829fce635faf577fd535483bd 100644 --- a/extension/Tests/Unit/Core/Store/StoreTest.php +++ b/extension/Tests/Unit/Core/Store/StoreTest.php @@ -439,7 +439,6 @@ class StoreTest extends TestCase { // SYSTEM_DO_NOT_LOG_COLUMN => SYSTEM_DO_NOT_LOG_COLUMN_DEFAULT, SYSTEM_PROTECTED_FOLDER_CHECK => 1, SYSTEM_CMD_WGET => 'wget >/dev/null 2>&1' - ]; $body = json_encode([ diff --git a/extension/composer.json b/extension/composer.json index 848142983e29bb2e9ddcc9e0b9ab647c57ce8e58..8824686e2ad8f659fff4475fb59276db055c2dfc 100644 --- a/extension/composer.json +++ b/extension/composer.json @@ -3,7 +3,8 @@ "phpoffice/phpspreadsheet": "^1.3", "ext-json": "*", "twig/twig": "^2.0", - "ezyang/htmlpurifier": "^4.15" + "ezyang/htmlpurifier": "^4.15", + "firebase/php-jwt": "^6.9" }, "require-dev": { "phpunit/phpunit": "^9" @@ -18,7 +19,8 @@ "Classes/Core/Exception/DownloadException.php", "Classes/Core/Exception/ShellException.php", "Classes/Core/Exception/UserFormException.php", - "Classes/Core/Exception/UserReportException.php" + "Classes/Core/Exception/UserReportException.php", + "Classes/Core/Exception/InfoException.php" ] }, "autoload-dev": { diff --git a/extension/config-example.qfq.php b/extension/config-example.qfq.php deleted file mode 100644 index a4c8744e26819dd8b0d255e0b632c281f396b768..0000000000000000000000000000000000000000 --- a/extension/config-example.qfq.php +++ /dev/null @@ -1,23 +0,0 @@ -<?php - -// QFQ configuration -// -// Save this file as: <site path>/typo3conf/config.qfq.php - -return [ - 'DB_1_USER' => '<DBUSER>', - 'DB_1_SERVER' => '<DBSERVER>', - 'DB_1_PASSWORD' => '<DBPW>', - 'DB_1_NAME' => '<DB>', - - //DB_2_USER => <DBUSER> - //DB_2_SERVER => <DBSERVER> - //DB_2_PASSWORD => <DBPW> - //DB_2_NAME => <DB> - - // DB_n ... - // ... - - // LDAP_1_RDN => 'ou=Admin,ou=example,dc=com' - // LDAP_1_PASSWORD => 'mySecurePassword' -]; diff --git a/extension/ext_conf_template.txt b/extension/ext_conf_template.txt index b0c6998e04688d0d5258851188f0852b8c82c1db..677b072fd45c842303994dbe66a4cdc2ea782135 100644 --- a/extension/ext_conf_template.txt +++ b/extension/ext_conf_template.txt @@ -13,6 +13,9 @@ dateFormat = dd.mm.yyyy # cat=config/config; type=boolean; label=Show edit inline reports. In the frontend, for every QFQ Report record an edit symbol is shown. Click on it will open a window to edit the QFQ report. editInlineReports = 1 +# cat=config/config; type=boolean; label=Switch edit inline report editor to dark mode. Default is light mode. +editInlineReportDarkTheme = 0 + # cat=config/config; type=string; label=Report as File Auto Export:Default is 'no'. If set to 'yes': When a QFQ tt-content record is rendered which does not contain the "file=" keyword, then its body is exported to a file in the qfq-project directory and the tt-content body is replaced by "file=<path_to_file>". reportAsFileAutoExport = @@ -130,7 +133,6 @@ encryptionMethod = protectedFolderCheck = 1 - # cat=form-config/config; type=string; label=Dirty record lock timeout (seconds):Default is '900'. Time in seconds to lock a record, starting from the first modification. If lock expires, it is acquired again on the next modification. recordLockTimeoutSeconds = 900 diff --git a/extension/ext_emconf.php b/extension/ext_emconf.php index 8a4d7505b49a630d4803b1e01df960a3683d4938..776d94d9d8f8f282026da472b5127926cf71f4b5 100644 --- a/extension/ext_emconf.php +++ b/extension/ext_emconf.php @@ -7,12 +7,12 @@ $EM_CONF['qfq'] = array( 'title' => 'Quick Form Query', 'description' => 'Framework to build web applications: Form (dynamic), report, typeahead, multi language, link protection, PDF, send mail (dynamic attachments, PDFs), multiple databases, record locking, secure up/download.', 'category' => 'fe', - 'author' => 'Carsten Rose, Benjamin Baer', + 'author' => 'Carsten Rose, Benjamin Baer, Enis Nuredini, Jan Haller', 'author_email' => 'carsten.rose@math.uzh.ch', 'dependencies' => 'fluid,extbase', 'clearcacheonload' => true, 'state' => 'stable', - 'version' => '23.6.4', + 'version' => '23.10.1', 'constraints' => [ 'depends' => [ 'typo3' => '8.0.0-11.9.99', @@ -20,6 +20,5 @@ $EM_CONF['qfq'] = array( 'conflicts' => [], 'suggests' => [], ], - ); diff --git a/extension/ext_localconf.php b/extension/ext_localconf.php index 726084a9766662b399afa72ce5d3daed57a56ccf..c8e7f35721ff158ef5ede212dc35f12da20e5c62 100644 --- a/extension/ext_localconf.php +++ b/extension/ext_localconf.php @@ -27,7 +27,7 @@ if ($typo3VersionInteger >= 10000000) { } \TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin( - 'IMATHUZH.' . 'qfq', + 'qfq', 'Qfq', $controllerAction, $nonCacheableControllerAction, diff --git a/extension/ext_tables.php b/extension/ext_tables.php index ead3b010609e0c76f3d3719d203adbc68d601deb..9e69f1d4992264d8ed1f385594744948060257fe 100644 --- a/extension/ext_tables.php +++ b/extension/ext_tables.php @@ -8,7 +8,7 @@ if (!defined('TYPO3_MODE')) { } \TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin( - 'IMATHUZH.' . 'qfq', + 'qfq', 'Qfq', 'QFQ Element', 'EXT:qfq/ext_icon.png' diff --git a/javascript/build/copy.js b/javascript/build/copy.js new file mode 100644 index 0000000000000000000000000000000000000000..4fe12050d189eefe93e5cea90e3a09970f49763e --- /dev/null +++ b/javascript/build/copy.js @@ -0,0 +1,196 @@ +const { ncp } = require("ncp") +const fs = require("fs") +const { exec } = require("child_process"); + +ncp.limit = 16 + +const options = { + js: { + clobber: true, //overwrite dir + stopOnErr: true, + filter: /(.+(?<!\..*)|.+\.debug\.js|qfq\..*\.js|.+\.min\.js|.+\.min\.js\.map)$/ + }, + css: { + clobber: true, //overwrite dir + stopOnErr: true, + filter: /(.+(?<!\..*)|.*\.min\.css|qfq.*\.css|.*\.min\.css\.map)$/ + }, + font: { + clobber: true, //overwrite dir + stopOnErr: true, + filter: /(.+(?<!\..*)|.*\.ttf|.*\.svg|.*\.woff|.*\.woff2)$/ + } +} + +const target = { + js: "extension/Resources/Public/JavaScript/", + css: "extension/Resources/Public/Css/", + font: "extension/Resources/Public/fonts" +} + +const target_dev = { + js: "js/", + css: "css/" +} + +const todos = [ + { + name: "bootstrap", + js: "node_modules/bootstrap/dist/js/", + css: "node_modules/bootstrap/dist/css/", + font: "node_modules/bootstrap/dist/fonts/" + },{ + name: "jquery", + js: "node_modules/jquery/dist/" + },{ + name: "tablesorter", + custom: [ + { + from: "node_modules/tablesorter/dist/js/jquery.tablesorter.combined.min.js", + to: target.js + },{ + from: "node_modules/tablesorter/dist/js/extras/jquery.tablesorter.pager.min.js", + to: target.js + },{ + from: "node_modules/tablesorter/dist/js/widgets/widget-columnSelector.min.js", + to: target.js + },{ + from: "node_modules/tablesorter/dist/js/widgets/widget-output.min.js", + to: target.js + } + ] + },{ + name: "datetimepicker", + js: "javascript/src/Plugins/bootstrap-datetimepicker.min.js", + css: "javascript/src/Plugins/" + },{ + name: "chart-js", + js: "node_modules/chart.js/dist/" + },{ + name: "qfq", + js: "javascript/build/dist/", + css: "less/dist/" + },{ + name: "tinymce", + js: 'node_modules/tinymce/', + custom: [ + { + from: "node_modules/tinymce/skins", + to: target.js + },{ + from: "node_modules/tinymce/plugins", + to: target.js + } + ] + },{ + name: "qfq plugins", + js: "javascript/src/Plugins/", + css: "javascript/src/Plugins/" + },{ + name: "fontAwesome", + css: "node_modules/@fortawesome/fontawesome-free/css/fontawesome.min.css", + custom: [ + { + from: "node_modules/@fortawesome/fontawesome-free/css/all.min.css", + to: target.css + "font-awesome.min.css" + },{ + from: "node_modules/@fortawesome/fontawesome-free/webfonts", + to: "extension/Resources/Public/" + } + ] + },{ + name: "fontPassword", + font: "resources/custom_fonts/" + },{ + name: "typeAhead", + js: "node_modules/corejs-typeahead/dist/" + },{ + name: "codemirror", + css: "node_modules/codemirror/lib/", + custom: [ + { + from: "node_modules/codemirror/mode/", + to: target.js + "code-mirror-mode/" + }, + { + from: "node_modules/codemirror/theme/monokai.css", + to: target.css + "theme/" + }, + { + from: "node_modules/codemirror/lib/codemirror.css", + to: target.css + }, + { + from: "node_modules/codemirror/lib/codemirror.js", + to: target.js + } + ] + },{ + name: "EventEmitter", + js: "node_modules/wolfy87-eventemitter/" + },{ + name: "fullcalendar", + js: "node_modules/fullcalendar/dist/", + css: "node_modules/fullcalendar/dist/" + },{ + name: "moment", + js: "node_modules/moment/min/", + },{ + name: "jqwidgets", + custom: [ + { + from: "node_modules/jqwidgets-framework/jqwidgets/jqx-all.js", + to: target.js + },{ + from: "node_modules/jqwidgets-framework/jqwidgets/globalization/globalize.js", + to: target.js + },{ + from: "node_modules/jqwidgets-framework/jqwidgets/styles/jqx.base.css", + to: target.css + },{ + from: "node_modules/jqwidgets-framework/jqwidgets/styles/jqx.bootstrap.css", + to: target.css + } + ] + },{ + name: "filepond", + js: "node_modules/filepond/dist/", + css: "node_modules/filepond/dist/", + custom: [ + { + from: "node_modules/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.js", + to: target.js + },{ + from: "node_modules/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.js", + to: target.js + }, + ] + }, +] + +const types = ["js", "css", "font"] + +console.log("Async copying files:") +for (const todo of todos) { + for(const type of types) { + if(todo.hasOwnProperty(type)) { + ncp(todo[type], target[type], options[type], (err) => printProgress(err, todo.name, type)); + } + } + if(todo.hasOwnProperty("custom")) { + for (const job of todo.custom) { + if (!fs.existsSync(job.to.substring(0, job.to.lastIndexOf("/")))) { + fs.mkdirSync(job.to.substring(0, job.to.lastIndexOf("/"))); + } + exec('cp -r "' + job.from + '" "' + job.to + '"', (error, stdout, stderr) => printProgress(error, todo.name, "custom")) + //ncp(job.from, job.to, options.custom, (err) => printProgress(err, todo.name, "custom")) + } + } +} + +function printProgress(err, name, type) { + if (err) { + return console.error(err); + } + console.log(' * copied ' + type + ' ' + name); +} \ No newline at end of file diff --git a/javascript/build/terser.js b/javascript/build/terser.js new file mode 100644 index 0000000000000000000000000000000000000000..f433cde1b647f03cd8eaf5e8ed9c9f4e348b5763 --- /dev/null +++ b/javascript/build/terser.js @@ -0,0 +1,60 @@ +const { minify } = require("terser"); +const fs = require('fs'); + +const jsPath = "javascript/build/dist/" +const extPath = "extension/Resources/Public/JavaScript/" +const cssPath = "extension/Resources/Public/Css/" + +const todos = [ + { + name: "qfq", + input: "javascript/build/dist/qfq.debug.js", + output: jsPath + "qfq.min.js" + },{ + name: "qfqFabric", + input: "javascript/src/Plugins/qfq.fabric.js", + output: jsPath + "qfq.fabric.min.js" + },{ + name: "qfqValidator", + input: "javascript/src/Plugins/validator.js", + output: jsPath + "validator.min.js" + },{ + name: "codemirror", + input: "node_modules/codemirror/lib/codemirror.js", + output: extPath + "codemirror.min.js" + },{ + name: "codemirror sql", + input: "node_modules/codemirror/mode/sql/sql.js", + output: extPath + "code-mirror-mode/sql/sql.min.js", + mkdir: extPath + "code-mirror-mode/sql" + } +] + +const defaultOptions = { + compress: { + defaults: false, + ecma: 2015 + } +}; + +async function minifySource(input, output, options) { + let sourceCode = fs.readFileSync(input, 'utf8'); + minify(sourceCode, options) + .then( (res) => callWriteFile(output, res)) +} + +function callWriteFile(output, sourceCode) { + //console.log("Source Code", sourceCode) + fs.writeFileSync(output, sourceCode.code) +} + +for (const todo of todos) { + console.log("minifying " + todo.name) + let options = defaultOptions + if(todo.hasOwnProperty("options")) options = todo.options + if(todo.hasOwnProperty("mkdir")) { + fs.mkdirSync(todo.mkdir, { recursive: true }) + } + minifySource(todo.input, todo.output, options) +} + diff --git a/javascript/src/Alert.js b/javascript/src/Alert.js index 77461e1490bc036fa2320d4003afd5b8f6a62e29..a965139499fee10606415f33a491b3561a0e3beb 100644 --- a/javascript/src/Alert.js +++ b/javascript/src/Alert.js @@ -116,6 +116,15 @@ var QfqNS = QfqNS || {}; this.identifier = false; this.eventEmitter = new EventEmitter(); + + if (this.message.indexOf('qfq-debug-detail') >= 0) { + this.buttons.push({ label: 'Debug info', eventName: 'show-debug' }); + setTimeout(function() { + this.on('alert.show-debug', function() { + $('.qfq-debug-detail').toggleClass('qfq-alert-hidden'); + }); + }.bind(this), 100); + } }; n.Alert.prototype.on = n.EventEmitter.onMixin; @@ -342,7 +351,7 @@ var QfqNS = QfqNS || {}; * @private */ n.Alert.prototype.buttonHandler = function (event) { - this.removeAlert(); + if(event.data.eventName !== "show-debug") this.removeAlert(); this.eventEmitter.emitEvent('alert.' + event.data.eventName, n.EventEmitter.makePayload(this, null)); }; diff --git a/javascript/src/BSTabs.js b/javascript/src/BSTabs.js index 7aef8f3a3175018046d4a58ba5e7246ac6d3333e..347754af0ff2534f6e7fcd92785483d4202b7295 100644 --- a/javascript/src/BSTabs.js +++ b/javascript/src/BSTabs.js @@ -37,7 +37,7 @@ var QfqNS = QfqNS || {}; this.eventEmitter = new EventEmitter(); this.currentFormName = $('#' + this.tabId + ' .active a[data-toggle="tab"]')[0].hash.slice(1).split("_")[0]; this.currentRecordId = $('#' + this.tabId + ' a[data-toggle="tab"]')[0].id.split("-")[2]; - this.currentActiveLastPill = document.getElementById('qfqTabs').getAttribute('data-active-last-pill'); + this.currentActiveLastPill = document.getElementById(this.tabId).getAttribute('data-active-last-pill'); // Fill this.tabs this.fillTabInformation(); diff --git a/javascript/src/Element/FormGroup.js b/javascript/src/Core/FormGroup.js similarity index 91% rename from javascript/src/Element/FormGroup.js rename to javascript/src/Core/FormGroup.js index 3ea9086fc3ce286f18cc1112be99c5f7b5669689..7d707635e76093717934139a3555e683d3ce72c5 100644 --- a/javascript/src/Element/FormGroup.js +++ b/javascript/src/Core/FormGroup.js @@ -92,14 +92,21 @@ QfqNS.Element = QfqNS.Element || {}; * @private */ n.FormGroup.prototype.$findFormGroup = function ($enclosedElement) { - var $formGroup = $('#' + $enclosedElement.attr('id') + '-i'); + var idArray = $enclosedElement.attr('id').split("-"); + var searchString = "#"; + for(var i = 0; i < 8 && i < idArray.length; i++) { + searchString += idArray[i] + "-"; + } + var $formGroup = $(searchString + 'i'); if (!$formGroup || $formGroup.length === 0) { - throw new Error("Unable to find Form Group"); + console.log("Unable to find Form Group for", $enclosedElement); + console.log("trying with: " + '#' + $enclosedElement.attr('id') + '-i'); + throw new Error("Unable to find Form Group for", $enclosedElement); } if ($formGroup.length > 1) { - $formGroup = $('#' + $enclosedElement.attr('id') + '-i'); + $formGroup = $(searchString + 'i'); console.log("Enclosed Element Id: " + $enclosedElement.attr('id')); if ($formGroup.length !== 1) { throw new Error("enclosed element yields ambiguous form group"); diff --git a/javascript/src/QfqEvents.js b/javascript/src/Core/QfqEvents.js similarity index 100% rename from javascript/src/QfqEvents.js rename to javascript/src/Core/QfqEvents.js diff --git a/javascript/src/Element/NameSpaceFunctions.js b/javascript/src/Element/NameSpaceFunctions.js index e30998c3fee2087c1bc6aa42e96250edbf096d87..c19cd8bbaff44da7f10b0b982e8e25c0bb1a56c6 100644 --- a/javascript/src/Element/NameSpaceFunctions.js +++ b/javascript/src/Element/NameSpaceFunctions.js @@ -68,6 +68,7 @@ QfqNS.Element = QfqNS.Element || {}; case 'checkbox': return new n.Checkbox($element); case 'nonFormGroupCheckbox': + case "file": return $element; case 'radio': return new n.Radio($element); diff --git a/javascript/src/Form.js b/javascript/src/Form.js index 19958e805b3efa4104053b407e55f19824f41d07..dfbc9e44596df9d0eb82673ee47cce9b5ca611d2 100644 --- a/javascript/src/Form.js +++ b/javascript/src/Form.js @@ -53,10 +53,16 @@ var QfqNS = QfqNS || {}; this.$form.find("input, textarea").on("input paste", this.inputAndPasteHandler.bind(this)); // Fire handler while using dateTimePickerType qfq - function getDatetimePickerChanges() { + function getDatetimePickerChanges(element) { $('div tbody').on('click', 'td.day:not(.disabled)', formObject.inputAndPasteHandler.bind(formObject)); var timepickerElements = 'td a.btn[data-action="incrementHours"], td a.btn[data-action="incrementMinutes"], td a.btn[data-action="incrementSeconds"], td a.btn[data-action="decrementHours"], td a.btn[data-action="decrementMinutes"], td a.btn[data-action="decrementSeconds"]'; $('div table').on('click', 'td.hour, td.minute, td a[data-action="clear"], '+timepickerElements, formObject.inputAndPasteHandler.bind(formObject)); + + element.addEventListener('keydown', function(event) { + if (event.key === 'Delete') { + formObject.inputAndPasteHandler(event); + } + }); } // Function to trigger onfocus event again while element is already focused @@ -81,7 +87,7 @@ var QfqNS = QfqNS || {}; // Open datetimepicker over click event even if first element is already focused and get all changes of datetimepicker for dirty lock this.$form.find(".qfq-datepicker").on("click", function(){ triggerFocus(this); - getDatetimePickerChanges(); + getDatetimePickerChanges(this); }); // Fire handler while using dateTimePickerType browser @@ -90,8 +96,7 @@ var QfqNS = QfqNS || {}; // Use ctrl+alt+s for saving form document.addEventListener('keydown', function(event) { if (event.ctrlKey && event.altKey && event.key === 's') { - console.log("submit"); - $("#save-button:not([disabled=disabled])").click(); + $("#save-button-" + this.formId + ":not([disabled=disabled])").click(); } }); @@ -181,6 +186,7 @@ var QfqNS = QfqNS || {}; // Reset disabled inputs disabled.attr('disabled','disabled'); + console.log("Serialized form", serializedForm); $.post(submitUrl, serializedForm) .done(this.ajaxSuccessHandler.bind(this)) .fail(this.submitFailureHandler.bind(this)); @@ -230,8 +236,7 @@ var QfqNS = QfqNS || {}; textStatus: textStatus, jqXHR: jqXHR })); - $("#save-button").removeClass('btn-warning active disabled'); - $("#save-button").addClass('btn-default'); + this.saveInProgress = false; }; diff --git a/javascript/src/Helper/codemirror.js b/javascript/src/Helper/codemirror.js index 57c7996e231726067513438a45529deabd0812c0..aad30e4e008a4984dc75a86f3f12a50ebfff3620 100644 --- a/javascript/src/Helper/codemirror.js +++ b/javascript/src/Helper/codemirror.js @@ -118,18 +118,21 @@ $(document).ready(function () { // We prepare the content for extern window and show it. Only if onclick doesn't exist. Compatibility for old way is given this way. $(targetEditReportButton).click(function () { + var baseUrl = $(this).data('base-url'); if (!$(this).is("[onclick]")) { var formContent = $($(this).next()[0].outerHTML); - showHtmlEditor(formContent); + showHtmlEditor(formContent, baseUrl); } }); //function to show editor window - function showHtmlEditor(formContent) { + function showHtmlEditor(formContent, baseUrl) { $(formContent[0]).removeAttr("class"); $(formContent[0]).addClass("externWindow"); + var cssPath = baseUrl + "typo3conf/ext/qfq/Resources/Public/Css/codemirror/monokai.css"; + var darkTheme = '<link rel="stylesheet" href="' + cssPath + '">'; var idNameForWindow = $(formContent[0]).attr('id'); - htmlContent = '<!DOCTYPE html>' + $("head").html() + $(formContent)[0].outerHTML; + htmlContent = '<!DOCTYPE html>' + $("head").html() + darkTheme + $(formContent)[0].outerHTML; newWindow(idNameForWindow); } @@ -185,6 +188,10 @@ $(document).ready(function () { QfqNS.Log.warning("'data-config' is invalid: " + configData); } } + + // Add viewportMargin to the configuration, makes whole content searchable + configData.viewportMargin = Infinity; + var cm = CodeMirror.fromTextArea(this, configData); cm.on('change', (function ($form, $textArea) { return function (instance, changeObj) { diff --git a/javascript/src/Helper/filePond.js b/javascript/src/Helper/filePond.js new file mode 100644 index 0000000000000000000000000000000000000000..5fbc52b5dc315d19c15248e02641812a418fa2da --- /dev/null +++ b/javascript/src/Helper/filePond.js @@ -0,0 +1,419 @@ +/** + * Qfq Namespace + * + * @namespace QfqNS + */ + +var QfqNS = QfqNS || {}; + +(function (n) { + + n.filePond = function createFileUpload(inputElement) { + // Retrieve all needed data and configurations + this.inputElement = inputElement; + this.pond = null; + + const configData = inputElement.getAttribute('data-config'); + this.configuration = configData ? JSON.parse(configData) : []; + this.normalizeConfiguration(); + + const apiUrls = inputElement.getAttribute('data-api-urls'); + this.apiUrls = apiUrls ? JSON.parse(apiUrls) : []; + + const sipValues = inputElement.getAttribute('data-sips'); + this.sipValues = sipValues ? JSON.parse(sipValues) : []; + + //Initialize existing preloaded files + this.filePondFiles = this.getPreloadedFiles(this.inputElement); + + // Initialize flags + this.lastUploadId = null; + this.currentFieldId = false; + this.deletedFileId = true; + this.lastSipTmp = false; + }; + + n.filePond.prototype.createFilePondObject = function() { + // Create the FilePond instance + const pond = FilePond.create(this.inputElement, { + allowMultiple: this.configuration.multiUpload, + allowRemove: this.configuration.deleteOption, + allowRevert: true, + maxFileSize: this.configuration.maxFileSize, + allowFileSizeValidation: this.configuration.activeSizeValidation, + acceptedFileTypes: this.configuration.accept, + allowFileTypeValidation: this.configuration.activeTypeValidation, + allowImagePreview: this.configuration.imageEditor, + allowImageEdit: this.configuration.imageEditor, + allowDrop: this.configuration.allowUpload, + allowBrowse: this.configuration.allowUpload, + labelIdle: this.configuration.text, + maxFiles: this.configuration.maxFiles, + allowReorder: false, + imagePreviewMaxHeight: 150, + styleButtonRemoveItemPosition: 'right', + credits: false, + dropValidation: true, + maxParallelUploads: 1, + files: this.filePondFiles, + iconRemove: '<i class="fas fa-trash" style="color: white;"></i>', + server: { + process: { + url: this.apiUrls.upload + "?s=" + this.sipValues.upload, + method: 'POST', + withCredentials: false, + headers: {}, + ondata: (formData) => { + return this.setOnData(formData); + }, + onload: (response) => { + // response is the JSON string returned by the server + const res = JSON.parse(response); + if (this.lastUploadId === null) { + this.lastUploadId = res.groupId; + + } + // Here you can handle the unique file ID as needed + console.log('File uploaded successfully:', res.uniqueFileId); + console.log('Upload Id:', res.groupId); + console.log('sipTmp:', res.sipTmp); + this.lastSipTmp = res.sipTmp; + + return res.uniqueFileId; // Must return the unique file ID to FilePond + }, + onerror: (response) => { + // Handle error here + console.error('Error during upload:', response); + } + }, + revert: (uniqueFileId, load, error) => { + this.setRevert(uniqueFileId, load, error); + }, + remove: (uniqueFileId, load, error) => { + this.setRemove(uniqueFileId, load, error); + }, + load: (source, load, error, progress, abort, headers) => { + console.log('loaded'); + } + }, + onprocessfile: (error, fileItem) => { + if (error) { + console.error('Error processing file:', error); + return; + } + + setTimeout(() => { + const foundIndicators = this.findFalseIndicators(); + // You can now do something with the indicators, like logging them or changing their styles + foundIndicators.forEach(indicator => { + indicator.style.opacity = 0; // Or apply styles or other changes + }); + }, 1000); + }, + onremovefile: (error, file) => { + if (error) { + console.error('Error removing file:', error); + return; + } + this.deletedFileId = true; // Reset fileId when a file is removed + }, + onaddfile: (err, fileItem) => { + if (err) { + console.error('Error adding file:', err); + return; + } + + // Wait for the file item to be added to the DOM + setTimeout(() => { + // Access the file item's element using FilePond's internal API + const item = pond.getFile(fileItem.id); + if (!item || !item.file || !item.id) { + console.error('The file item is missing information.'); + return; + } + + // If it's a form-element and downloadButton is not given then there is no download button needed. + if (!this.configuration.form || this.configuration.form && this.configuration.downloadButton !== false) { + this.createDownloadButton(fileItem, item); + } + }, 100); + }, + oninit: () => { + // Change the styling for filePond uploads in form + const rootElement = pond.element; + const dropLabel = rootElement.querySelector('.filepond--drop-label'); + const listElement = rootElement.querySelector('.filepond--list'); + if (this.configuration.form) { + rootElement.id = this.configuration.formId; + if (listElement) { + listElement.classList.add('filepond--list-form'); + } + } + + if (dropLabel && this.configuration.dropBackground !== undefined) { + dropLabel.classList.add('filepond--drop-label-form'); + } + + if (this.configuration.form && this.configuration.downloadButton === false) { + const sizeInfo = rootElement.querySelector('.filepond--file-info-sub'); + if (sizeInfo !== null) { + sizeInfo.style.display = 'none'; + } + } + } + }); + + this.pond = pond; + }; + + // This function will return an array of all processing complete indicators + // that have a sibling with the revert button processing class. + n.filePond.prototype.getPreloadedFiles = function () { + const preloadedData = this.inputElement.getAttribute('data-preloadedFiles'); + const preloadedFiles = preloadedData ? JSON.parse(preloadedData) : []; + + return preloadedFiles.length > 0 ? preloadedFiles.map(file => ({ + source: file.id, + options: { + type: 'local', + file: { + name: file.pathFileName.split('/').pop(), + size: file.size, + type: file.type + }, + metadata: { + poster: file.pathFileName + } + } + })) : []; + }; + + // This function will return an array of all processing complete indicators + // that have a sibling with the revert button processing class. + n.filePond.prototype.findFalseIndicators = function () { + const indicatorsWithRevertSibling = []; + + // Select all processing complete indicators + const indicators = document.querySelectorAll('.filepond--processing-complete-indicator'); + + indicators.forEach(indicator => { + // Check if the revert button processing class exists as a sibling + const revertButton = indicator.closest('.filepond--item').querySelector('.filepond--file-action-button.filepond--action-revert-item-processing'); + if (revertButton) { + indicatorsWithRevertSibling.push(indicator); + } + }); + + return indicatorsWithRevertSibling; + }; + + n.filePond.prototype.setOnData = function (formData) { + if (this.lastUploadId) { + formData.append('groupId', this.lastUploadId); + } + // Add your own variables here + formData.append('pathFileName', this.configuration.pathFileName); + formData.append('pathDefault', this.configuration.pathDefault); + formData.append('recordData', this.configuration.recordData); + if (this.lastUploadId == null) { + formData.append('groupId', this.configuration.groupId); + } + if (this.deletedFileId) { + formData.append('uploadId', 0); + } else { + formData.append('uploadId', this.configuration.uploadId); + } + formData.append('table', this.configuration.table); + + // Return the modified FormData object + return formData; + }; + + n.filePond.prototype.setRevert = function (uniqueFileId, load, error) { + const formData = new FormData(); + formData.append('uploadId', uniqueFileId); + formData.append('table', this.configuration.table); + + // The uniqueFileId parameter is the ID returned by the server during the 'process' call + // This ID can be used to identify and delete the file on the server + const xhr = new XMLHttpRequest(); + xhr.open('POST', this.apiUrls.upload + `?s=${this.sipValues.delete}`); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.onload = () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + // Update the new fileId from the response, if necessary + //this.currentFieldId = response.uniqueFileId; + load(); + } else { + error('oh no'); + } + }; + xhr.send(formData); + }; + + n.filePond.prototype.setRemove = function (uniqueFileId, load, error) { + const formData = new FormData(); + formData.append('uploadId', uniqueFileId); + formData.append('table', this.configuration.table); + + // The uniqueFileId parameter is the ID returned by the server during the 'process' call + // This ID can be used to identify and delete the file on the server + const xhr = new XMLHttpRequest(); + xhr.open('POST', this.apiUrls.upload + `?s=${this.sipValues.delete}`); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.onload = () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + // Update the new fileId from the response, if necessary + //this.currentFieldId = response.uniqueFileId; + load(); + } else { + error('oh no'); + } + }; + xhr.send(formData); + }; + + // Trying to load the pictures for preview for preloaded files. Currently not working. Maybe not needed if ImageEditor is implemented. + n.filePond.prototype.setServerLoad = function (source, load, error, progress, abort, headers) { + if (this.allowImagePreview) { + const fetchRequest = new Request(this.apiUrls.download + `?type=preview&fileId=` + source); + fetch(fetchRequest).then(response => { + if (response.ok && response.headers.get('Content-Length') > 0) { + response.blob().then(blob => { + if (blob.size > 0) { + load(blob); + } else { + load(''); + } + }); + } else { + load(''); + } + }).catch(err => { + // Log the error but don't trigger FilePond's error state + console.error(err.message); + load(''); + }); + return { + abort: () => { + } + }; + } + + }; + + // Create new downloadButton and append it to existing upload element. + // + n.filePond.prototype.createDownloadButton = function (fileItem, item) { + // Create a download button + const downloadButton = document.createElement('button'); + + // Find the filepond--file-wrapper element for this file item + const fileElementPanel = this.getFilePondElementPanel(item); + + if (fileElementPanel) { + let removeButton = fileElementPanel.querySelector('.filepond--file-action-button.filepond--action-remove-item'); + const fileInfo = fileElementPanel.querySelector('.filepond--file-info'); + + downloadButton.classList.add('filepond--file-action-button'); + downloadButton.classList.add('filepond--action-download-item'); + downloadButton.type = 'button'; + downloadButton.setAttribute('data-align', 'right'); + + if (removeButton && window.getComputedStyle(removeButton).visibility === 'hidden') { + downloadButton.style.marginRight = '110px'; + } else { + downloadButton.style.marginRight = '30px'; // or set to some default value + } + + // Create an icon element for the downward arrow + const icon = document.createElement('i'); + icon.classList.add('fas', 'fa-arrow-down'); + icon.style.color = 'white'; + + // Append the icon to the button + downloadButton.appendChild(icon); + + // Append download button text if downloadButton config given. Only in case of form-element possible. + if (this.configuration.downloadButton !== false && this.configuration.downloadButton !== undefined) { + const downloadButtonText = document.createElement('span'); + downloadButtonText.innerText = this.configuration.downloadButton; + downloadButton.appendChild(downloadButtonText); + + downloadButtonText.style.width = 'fit-content'; + downloadButtonText.style.height = 'fit-content'; + downloadButtonText.style.position = 'unset'; + downloadButtonText.style.marginLeft = '5px'; + downloadButtonText.style.marginRight = '5px'; + downloadButtonText.style.clipPath = 'inset(100%)'; + + icon.style.marginLeft = '4px'; + downloadButton.style.left = '5px'; + downloadButton.style.marginRight = 'unset'; + downloadButton.style.display = 'flex'; + downloadButton.style.alignItems = 'center'; + downloadButton.style.width = 'auto'; + downloadButton.style.borderRadius = '6px'; + + fileInfo.style.display = 'none'; + } + + // Append the download button to the filepond--file element + fileElementPanel.appendChild(downloadButton); + } + + // Set up the click event listener to trigger the download + downloadButton.addEventListener('click', () => { + let sipParameter = this.sipValues.download; + if (this.lastSipTmp !== false && this.lastSipTmp !== undefined) { + sipParameter = this.lastSipTmp; + } + + // Implement the download action here + const downloadUrl = this.apiUrls.download + `?s=${sipParameter}&sipDownloadKey=${this.configuration.sipDownloadKey}&uploadId=${fileItem.serverId}`; + window.open(downloadUrl, '_blank'); + }); + }; + + // Get fileElement panel to customize. + // + n.filePond.prototype.getFilePondElementPanel = function (item) { + const fileElement = document.querySelector(`#filepond--item-${item.id}`); + const fileWrapper = fileElement.querySelector(`.filepond--file-wrapper`); + if (fileWrapper) { + const fileElementPanel = fileWrapper.querySelector('.filepond--file'); + if (fileElementPanel) { + return fileElementPanel; + } else { + console.error('The filepond--file element was not found.'); + } + } else { + console.error('The filepond--file-wrapper element was not found.'); + } + }; + + // Normalize 'null' string values to actual nulls and string 'true' to boolean + // + n.filePond.prototype.normalizeConfiguration = function () { + Object.keys(this.configuration).forEach(key => { + if (this.configuration[key] === 'null') { + this.configuration[key] = null; + } else if (this.configuration[key] === 'true') { + this.configuration[key] = true; + } else if (this.configuration[key] === 'false') { + this.configuration[key] = false; + } + }); + + if (this.configuration.recordData === undefined) { + this.configuration.recordData = ''; + } + + this.configuration.activeTypeValidation = this.configuration.accept !== null; + this.configuration.activeSizeValidation = this.configuration.maxFileSize !== null; + }; + +})(QfqNS); + diff --git a/javascript/src/Helper/tinyMCE.js b/javascript/src/Helper/tinyMCE.js index 095bbbe5c4f9e6e69831e3fa6a23f33bec2258d2..18120be577c7e8e10014fd313c0cf1d13c30d2be 100644 --- a/javascript/src/Helper/tinyMCE.js +++ b/javascript/src/Helper/tinyMCE.js @@ -148,7 +148,14 @@ QfqNS.Helper = QfqNS.Helper || {}; }); }; - tinymce.init(config); + var defaults = { + relative_urls : false, + remove_script_host : false, + }; + + var tinyConfig = Object.assign(defaults, config); + + tinymce.init(tinyConfig); if($(this).is('[disabled]')) { myEditor.setMode("readonly"); } diff --git a/javascript/src/Main.js b/javascript/src/Main.js index b4660c404f6f6d4d28c34e28fbb58032a5870c49..694165c2d6b7c68092a3c1c13126b94e54de4ced 100644 --- a/javascript/src/Main.js +++ b/javascript/src/Main.js @@ -14,24 +14,36 @@ var QfqNS = QfqNS || {}; $(document).ready( function () { (function (n) { - + n.form = ''; try { var tablesorterController = new n.TablesorterController(); $('.tablesorter').each(function (i) { tablesorterController.setup($(this), i); }); // end .each() - $('.tablesorter-filter').addClass('qfq-skip-dirty'); - $('select.qfq-tablesorter-menu-item').addClass('qfq-skip-dirty'); - $('.tablesorter-column-selector>label>input').addClass('qfq-skip-dirty'); - // This is needed because after changing table-view, class of input field is empty again - $('button.qfq-column-selector').click(function () { + $('.tablesorter-filter').addClass('qfq-skip-dirty'); + $('select.qfq-tablesorter-menu-item').addClass('qfq-skip-dirty'); $('.tablesorter-column-selector>label>input').addClass('qfq-skip-dirty'); - }); - } catch (e) { - console.log(e); - } + // This is needed because after changing table-view, class of input field is empty again + $('button.qfq-column-selector').click(function () { + $('.tablesorter-column-selector>label>input').addClass('qfq-skip-dirty'); + }); + + var collection = document.getElementsByClassName("qfq-form"); + var qfqPages = []; + for (const form of collection) { + const page = new n.QfqPage(form.dataset); + qfqPages.push(page); + } + // Get form object for later manipulations (example: filePond objects) + if (qfqPages[0] !== undefined) { + n.form = qfqPages[0].qfqForm.form; + } + } catch (e) { + console.log(e); + } + $('.qfq-auto-grow').each(function() { var minHeight = $(this).attr("rows") * 14 + 18; var newHeight = $(this).prop('scrollHeight'); @@ -194,8 +206,32 @@ $(document).ready( function () { }; n.initializeQfqClearMe(); + n.initializeDatetimepicker(); n.Helper.calendar(); + FilePond.registerPlugin(FilePondPluginFileValidateSize); + FilePond.registerPlugin(FilePondPluginFileValidateType); + FilePond.registerPlugin(FilePondPluginImagePreview); + FilePond.registerPlugin(FilePondPluginImageEdit); + + // Get a reference to the file input element + const inputElements = document.querySelectorAll('input[type="file"].fileupload'); + + // Iterate over the NodeList and create a FilePond instance for each element + inputElements.forEach((inputElement, index) => { + let fileObject = new n.filePond(inputElement); + fileObject.createFilePondObject(); + + // Call the form change after file remove + if (n.form !== '') { + fileObject.pond.on('removefile', function(file) { + const element = fileObject.pond.element; + n.form.inputAndPasteHandlerCalled = true; + n.form.markChanged(element); + }); + } + }); + })(QfqNS); }); diff --git a/javascript/src/Plugins/bootstrap-datetimepicker.min.css b/javascript/src/Plugins/bootstrap-datetimepicker.min.css new file mode 100644 index 0000000000000000000000000000000000000000..365654ba572673b19b7e707635120e02de98f935 --- /dev/null +++ b/javascript/src/Plugins/bootstrap-datetimepicker.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap Datetime Picker v4.17.49 + * Copyright 2015-2020 Jonathan Peterson + * Licensed under MIT (https://github.com/Eonasdan/bootstrap-datetimepicker/blob/master/LICENSE) + */.bootstrap-datetimepicker-widget{list-style:none}.bootstrap-datetimepicker-widget.dropdown-menu{display:block;margin:2px 0;padding:4px;width:19em}@media (min-width:768px){.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs{width:38em}}@media (min-width:992px){.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs{width:38em}}@media (min-width:1200px){.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs{width:38em}}.bootstrap-datetimepicker-widget.dropdown-menu:before,.bootstrap-datetimepicker-widget.dropdown-menu:after{content:'';display:inline-block;position:absolute}.bootstrap-datetimepicker-widget.dropdown-menu.bottom:before{border-left:7px solid transparent;border-right:7px solid transparent;border-bottom:7px solid #ccc;border-bottom-color:rgba(0,0,0,0.2);top:-7px;left:7px}.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after{border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:6px solid white;top:-6px;left:8px}.bootstrap-datetimepicker-widget.dropdown-menu.top:before{border-left:7px solid transparent;border-right:7px solid transparent;border-top:7px solid #ccc;border-top-color:rgba(0,0,0,0.2);bottom:-7px;left:6px}.bootstrap-datetimepicker-widget.dropdown-menu.top:after{border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid white;bottom:-6px;left:7px}.bootstrap-datetimepicker-widget.dropdown-menu.pull-right:before{left:auto;right:6px}.bootstrap-datetimepicker-widget.dropdown-menu.pull-right:after{left:auto;right:7px}.bootstrap-datetimepicker-widget .list-unstyled{margin:0}.bootstrap-datetimepicker-widget a[data-action]{padding:6px 0}.bootstrap-datetimepicker-widget a[data-action]:active{box-shadow:none}.bootstrap-datetimepicker-widget .timepicker-hour,.bootstrap-datetimepicker-widget .timepicker-minute,.bootstrap-datetimepicker-widget .timepicker-second{width:54px;font-weight:bold;font-size:1.2em;margin:0}.bootstrap-datetimepicker-widget button[data-action]{padding:6px}.bootstrap-datetimepicker-widget .btn[data-action="incrementHours"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Increment Hours"}.bootstrap-datetimepicker-widget .btn[data-action="incrementMinutes"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Increment Minutes"}.bootstrap-datetimepicker-widget .btn[data-action="decrementHours"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Decrement Hours"}.bootstrap-datetimepicker-widget .btn[data-action="decrementMinutes"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Decrement Minutes"}.bootstrap-datetimepicker-widget .btn[data-action="showHours"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Show Hours"}.bootstrap-datetimepicker-widget .btn[data-action="showMinutes"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Show Minutes"}.bootstrap-datetimepicker-widget .btn[data-action="togglePeriod"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Toggle AM/PM"}.bootstrap-datetimepicker-widget .btn[data-action="clear"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Clear the picker"}.bootstrap-datetimepicker-widget .btn[data-action="today"]::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Set the date to today"}.bootstrap-datetimepicker-widget .picker-switch{text-align:center}.bootstrap-datetimepicker-widget .picker-switch::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Toggle Date and Time Screens"}.bootstrap-datetimepicker-widget .picker-switch td{padding:0;margin:0;height:auto;width:auto;line-height:inherit}.bootstrap-datetimepicker-widget .picker-switch td span{line-height:2.5;height:2.5em;width:100%}.bootstrap-datetimepicker-widget table{width:100%;margin:0}.bootstrap-datetimepicker-widget table td,.bootstrap-datetimepicker-widget table th{text-align:center;border-radius:4px}.bootstrap-datetimepicker-widget table th{height:20px;line-height:20px;width:20px}.bootstrap-datetimepicker-widget table th.picker-switch{width:145px}.bootstrap-datetimepicker-widget table th.disabled,.bootstrap-datetimepicker-widget table th.disabled:hover{background:none;color:#777;cursor:not-allowed}.bootstrap-datetimepicker-widget table th.prev::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Previous Month"}.bootstrap-datetimepicker-widget table th.next::after{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0;content:"Next Month"}.bootstrap-datetimepicker-widget table thead tr:first-child th{cursor:pointer}.bootstrap-datetimepicker-widget table thead tr:first-child th:hover{background:#eee}.bootstrap-datetimepicker-widget table td{height:54px;line-height:54px;width:54px}.bootstrap-datetimepicker-widget table td.cw{font-size:.8em;height:20px;line-height:20px;color:#777}.bootstrap-datetimepicker-widget table td.day{height:20px;line-height:20px;width:20px}.bootstrap-datetimepicker-widget table td.day:hover,.bootstrap-datetimepicker-widget table td.hour:hover,.bootstrap-datetimepicker-widget table td.minute:hover,.bootstrap-datetimepicker-widget table td.second:hover{background:#eee;cursor:pointer}.bootstrap-datetimepicker-widget table td.old,.bootstrap-datetimepicker-widget table td.new{color:#777}.bootstrap-datetimepicker-widget table td.today{position:relative}.bootstrap-datetimepicker-widget table td.today:before{content:'';display:inline-block;border:solid transparent;border-width:0 0 7px 7px;border-bottom-color:#337ab7;border-top-color:rgba(0,0,0,0.2);position:absolute;bottom:4px;right:4px}.bootstrap-datetimepicker-widget table td.active,.bootstrap-datetimepicker-widget table td.active:hover{background-color:#337ab7;color:#fff;text-shadow:0 -1px 0 rgba(0,0,0,0.25)}.bootstrap-datetimepicker-widget table td.active.today:before{border-bottom-color:#fff}.bootstrap-datetimepicker-widget table td.disabled,.bootstrap-datetimepicker-widget table td.disabled:hover{background:none;color:#777;cursor:not-allowed}.bootstrap-datetimepicker-widget table td span{display:inline-block;width:54px;height:54px;line-height:54px;margin:2px 1.5px;cursor:pointer;border-radius:4px}.bootstrap-datetimepicker-widget table td span:hover{background:#eee}.bootstrap-datetimepicker-widget table td span.active{background-color:#337ab7;color:#fff;text-shadow:0 -1px 0 rgba(0,0,0,0.25)}.bootstrap-datetimepicker-widget table td span.old{color:#777}.bootstrap-datetimepicker-widget table td span.disabled,.bootstrap-datetimepicker-widget table td span.disabled:hover{background:none;color:#777;cursor:not-allowed}.bootstrap-datetimepicker-widget.usetwentyfour td.hour{height:27px;line-height:27px}.bootstrap-datetimepicker-widget.wider{width:21em}.bootstrap-datetimepicker-widget .datepicker-decades .decade{line-height:1.8em !important}.input-group.date .input-group-addon{cursor:pointer}.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0, 0, 0, 0);border:0} \ No newline at end of file diff --git a/javascript/src/Plugins/bootstrap-datetimepicker.min.js b/javascript/src/Plugins/bootstrap-datetimepicker.min.js new file mode 100644 index 0000000000000000000000000000000000000000..884a8f879923ac55f9046afc164b592c41dfae46 --- /dev/null +++ b/javascript/src/Plugins/bootstrap-datetimepicker.min.js @@ -0,0 +1 @@ +!function(e){"use strict";if("function"==typeof define&&define.amd)define(["jquery","moment"],e);else if("object"==typeof exports)module.exports=e(require("jquery"),require("moment"));else{if("undefined"==typeof jQuery)throw"bootstrap-datetimepicker requires jQuery to be loaded first";if("undefined"==typeof moment)throw"bootstrap-datetimepicker requires Moment.js to be loaded first";e(jQuery,moment)}}(function($,_){"use strict";if(!_)throw new Error("bootstrap-datetimepicker requires Moment.js to be loaded first");function i(i,p){function a(){return void 0!==_.tz&&void 0!==p.timeZone&&null!==p.timeZone&&""!==p.timeZone}function c(e){var t;return t=null==e?_():_.isDate(e)||_.isMoment(e)?_(e):a()?_.tz(e,B,p.useStrict,p.timeZone):_(e,B,p.useStrict),a()&&t.tz(p.timeZone),t}function d(e){if("string"!=typeof e||1<e.length)throw new TypeError("isEnabled expects a single character string parameter");switch(e){case"y":return-1!==q.indexOf("Y");case"M":return-1!==q.indexOf("M");case"d":return-1!==q.toLowerCase().indexOf("d");case"h":case"H":return-1!==q.toLowerCase().indexOf("h");case"m":return-1!==q.indexOf("m");case"s":return-1!==q.indexOf("s");default:return!1}}function l(){return d("h")||d("m")||d("s")}function u(){return d("y")||d("M")||d("d")}function f(){var e,t,a,n=$("<div>").addClass("timepicker-hours").append($("<table>").addClass("table-condensed")),r=$("<div>").addClass("timepicker-minutes").append($("<table>").addClass("table-condensed")),i=$("<div>").addClass("timepicker-seconds").append($("<table>").addClass("table-condensed")),o=[(e=$("<tr>"),t=$("<tr>"),a=$("<tr>"),d("h")&&(e.append($("<td>").append($("<a>").attr({href:"#",tabindex:"-1",title:p.tooltips.incrementHour}).addClass("btn").attr("data-action","incrementHours").append($("<span>").addClass(p.icons.up)))),t.append($("<td>").append($("<span>").addClass("timepicker-hour").attr({"data-time-component":"hours",title:p.tooltips.pickHour}).attr("data-action","showHours"))),a.append($("<td>").append($("<a>").attr({href:"#",tabindex:"-1",title:p.tooltips.decrementHour}).addClass("btn").attr("data-action","decrementHours").append($("<span>").addClass(p.icons.down))))),d("m")&&(d("h")&&(e.append($("<td>").addClass("separator")),t.append($("<td>").addClass("separator").html(":")),a.append($("<td>").addClass("separator"))),e.append($("<td>").append($("<a>").attr({href:"#",tabindex:"-1",title:p.tooltips.incrementMinute}).addClass("btn").attr("data-action","incrementMinutes").append($("<span>").addClass(p.icons.up)))),t.append($("<td>").append($("<span>").addClass("timepicker-minute").attr({"data-time-component":"minutes",title:p.tooltips.pickMinute}).attr("data-action","showMinutes"))),a.append($("<td>").append($("<a>").attr({href:"#",tabindex:"-1",title:p.tooltips.decrementMinute}).addClass("btn").attr("data-action","decrementMinutes").append($("<span>").addClass(p.icons.down))))),d("s")&&(d("m")&&(e.append($("<td>").addClass("separator")),t.append($("<td>").addClass("separator").html(":")),a.append($("<td>").addClass("separator"))),e.append($("<td>").append($("<a>").attr({href:"#",tabindex:"-1",title:p.tooltips.incrementSecond}).addClass("btn").attr("data-action","incrementSeconds").append($("<span>").addClass(p.icons.up)))),t.append($("<td>").append($("<span>").addClass("timepicker-second").attr({"data-time-component":"seconds",title:p.tooltips.pickSecond}).attr("data-action","showSeconds"))),a.append($("<td>").append($("<a>").attr({href:"#",tabindex:"-1",title:p.tooltips.decrementSecond}).addClass("btn").attr("data-action","decrementSeconds").append($("<span>").addClass(p.icons.down))))),Y||(e.append($("<td>").addClass("separator")),t.append($("<td>").append($("<button>").addClass("btn btn-primary").attr({"data-action":"togglePeriod",tabindex:"-1",title:p.tooltips.togglePeriod}))),a.append($("<td>").addClass("separator"))),$("<div>").addClass("timepicker-picker").append($("<table>").addClass("table-condensed").append([e,t,a])))];return d("h")&&o.push(n),d("m")&&o.push(r),d("s")&&o.push(i),o}function t(){var e,t,a,n=$("<div>").addClass("bootstrap-datetimepicker-widget dropdown-menu"),r=$("<div>").addClass("datepicker").append((t=$("<thead>").append($("<tr>").append($("<th>").addClass("prev").attr("data-action","previous").append($("<span>").addClass(p.icons.previous))).append($("<th>").addClass("picker-switch").attr("data-action","pickerSwitch").attr("colspan",p.calendarWeeks?"6":"5")).append($("<th>").addClass("next").attr("data-action","next").append($("<span>").addClass(p.icons.next)))),a=$("<tbody>").append($("<tr>").append($("<td>").attr("colspan",p.calendarWeeks?"8":"7"))),[$("<div>").addClass("datepicker-days").append($("<table>").addClass("table-condensed").append(t).append($("<tbody>"))),$("<div>").addClass("datepicker-months").append($("<table>").addClass("table-condensed").append(t.clone()).append(a.clone())),$("<div>").addClass("datepicker-years").append($("<table>").addClass("table-condensed").append(t.clone()).append(a.clone())),$("<div>").addClass("datepicker-decades").append($("<table>").addClass("table-condensed").append(t.clone()).append(a.clone()))])),i=$("<div>").addClass("timepicker").append(f()),o=$("<ul>").addClass("list-unstyled"),s=$("<li>").addClass("picker-switch"+(p.collapse?" accordion-toggle":"")).append((e=[],p.showTodayButton&&e.push($("<td>").append($("<a>").attr({"data-action":"today",title:p.tooltips.today}).append($("<span>").addClass(p.icons.today)))),!p.sideBySide&&u()&&l()&&e.push($("<td>").append($("<a>").attr({"data-action":"togglePicker",title:p.tooltips.selectTime}).append($("<span>").addClass(p.icons.time)))),p.showClear&&e.push($("<td>").append($("<a>").attr({"data-action":"clear",title:p.tooltips.clear}).append($("<span>").addClass(p.icons.clear)))),p.showClose&&e.push($("<td>").append($("<a>").attr({"data-action":"close",title:p.tooltips.close}).append($("<span>").addClass(p.icons.close)))),$("<table>").addClass("table-condensed").append($("<tbody>").append($("<tr>").append(e)))));return p.inline&&n.removeClass("dropdown-menu"),Y&&n.addClass("usetwentyfour"),d("s")&&!Y&&n.addClass("wider"),p.sideBySide&&u()&&l()?(n.addClass("timepicker-sbs"),"top"===p.toolbarPlacement&&n.append(s),n.append($("<div>").addClass("row").append(r.addClass("col-md-6")).append(i.addClass("col-md-6"))),"bottom"===p.toolbarPlacement&&n.append(s),n):("top"===p.toolbarPlacement&&o.append(s),u()&&o.append($("<li>").addClass(p.collapse&&l()?"collapse in":"").append(r)),"default"===p.toolbarPlacement&&o.append(s),l()&&o.append($("<li>").addClass(p.collapse&&u()?"collapse":"").append(i)),"bottom"===p.toolbarPlacement&&o.append(s),n.append(o))}function n(){var e,t=(z||i).position(),a=(z||i).offset(),n=p.widgetPositioning.vertical,r=p.widgetPositioning.horizontal;if(p.widgetParent)e=p.widgetParent.append(N);else if(i.is("input"))e=i.after(N).parent();else{if(p.inline)return void(e=i.append(N));(e=i).children().first().after(N)}if("auto"===n&&(n=a.top+1.5*N.height()>=$(window).height()+$(window).scrollTop()&&N.height()+i.outerHeight()<a.top?"top":"bottom"),"auto"===r&&(r=e.width()<a.left+N.outerWidth()/2&&a.left+N.outerWidth()>$(window).width()?"right":"left"),"top"===n?N.addClass("top").removeClass("bottom"):N.addClass("bottom").removeClass("top"),"right"===r?N.addClass("pull-right"):N.removeClass("pull-right"),"static"===e.css("position")&&(e=e.parents().filter(function(){return"static"!==$(this).css("position")}).first()),0===e.length)throw new Error("datetimepicker component should be placed within a non-static positioned container");N.css({top:"top"===n?"auto":t.top+i.outerHeight(),bottom:"top"===n?e.outerHeight()-(e===i?0:t.top):"auto",left:"left"===r?e===i?0:t.left:"auto",right:"left"===r?"auto":e.outerWidth()-i.outerWidth()-(e===i?0:t.left)})}function h(e){"dp.change"===e.type&&(e.date&&e.date.isSame(e.oldDate)||!e.date&&!e.oldDate)||i.trigger(e)}function r(e){"y"===e&&(e="YYYY"),h({type:"dp.update",change:e,viewDate:H.clone()})}function o(e){N&&(e&&(j=Math.max(V,Math.min(3,j+e))),N.find(".datepicker > div").hide().filter(".datepicker-"+Z[j].clsName).show())}function m(e,t){var a,n,r,i;if(e.isValid()&&!(p.disabledDates&&"d"===t&&(a=e,!0===p.disabledDates[a.format("YYYY-MM-DD")])||p.enabledDates&&"d"===t&&(n=e,!0!==p.enabledDates[n.format("YYYY-MM-DD")])||p.minDate&&e.isBefore(p.minDate,t)||p.maxDate&&e.isAfter(p.maxDate,t)||p.daysOfWeekDisabled&&"d"===t&&-1!==p.daysOfWeekDisabled.indexOf(e.day())||p.disabledHours&&("h"===t||"m"===t||"s"===t)&&(r=e,!0===p.disabledHours[r.format("H")])||p.enabledHours&&("h"===t||"m"===t||"s"===t)&&(i=e,!0!==p.enabledHours[i.format("H")]))){if(p.disabledTimeIntervals&&("h"===t||"m"===t||"s"===t)){var o=!1;if($.each(p.disabledTimeIntervals,function(){if(e.isBetween(this[0],this[1]))return!(o=!0)}),o)return}return 1}}function s(){var e,t,a,n=N.find(".datepicker-days"),r=n.find("th"),i=[],o=[];if(u()){for(r.eq(0).find("span").attr("title",p.tooltips.prevMonth),r.eq(1).attr("title",p.tooltips.selectMonth),r.eq(2).find("span").attr("title",p.tooltips.nextMonth),n.find(".disabled").removeClass("disabled"),r.eq(1).text(H.format(p.dayViewHeaderFormat)),m(H.clone().subtract(1,"M"),"M")||r.eq(0).addClass("disabled"),m(H.clone().add(1,"M"),"M")||r.eq(2).addClass("disabled"),e=H.clone().startOf("M").startOf("w").startOf("d"),a=0;a<42;a++)0===e.weekday()&&(t=$("<tr>"),p.calendarWeeks&&t.append('<td class="cw">'+e.week()+"</td>"),i.push(t)),o=["day"],e.isBefore(H,"M")&&o.push("old"),e.isAfter(H,"M")&&o.push("new"),e.isSame(E,"d")&&!W&&o.push("active"),m(e,"d")||o.push("disabled"),e.isSame(c(),"d")&&o.push("today"),0!==e.day()&&6!==e.day()||o.push("weekend"),h({type:"dp.classify",date:e,classNames:o}),t.append('<td data-action="selectDay" data-day="'+e.format("L")+'" class="'+o.join(" ")+'">'+e.date()+"</td>"),e.add(1,"d");var s,d,l;n.find("tbody").empty().append(i),s=N.find(".datepicker-months"),d=s.find("th"),l=s.find("tbody").find("span"),d.eq(0).find("span").attr("title",p.tooltips.prevYear),d.eq(1).attr("title",p.tooltips.selectYear),d.eq(2).find("span").attr("title",p.tooltips.nextYear),s.find(".disabled").removeClass("disabled"),m(H.clone().subtract(1,"y"),"y")||d.eq(0).addClass("disabled"),d.eq(1).text(H.year()),m(H.clone().add(1,"y"),"y")||d.eq(2).addClass("disabled"),l.removeClass("active"),E.isSame(H,"y")&&!W&&l.eq(E.month()).addClass("active"),l.each(function(e){m(H.clone().month(e),"M")||$(this).addClass("disabled")}),function(){var e=N.find(".datepicker-years"),t=e.find("th"),a=H.clone().subtract(5,"y"),n=H.clone().add(6,"y"),r="";for(t.eq(0).find("span").attr("title",p.tooltips.prevDecade),t.eq(1).attr("title",p.tooltips.selectDecade),t.eq(2).find("span").attr("title",p.tooltips.nextDecade),e.find(".disabled").removeClass("disabled"),p.minDate&&p.minDate.isAfter(a,"y")&&t.eq(0).addClass("disabled"),t.eq(1).text(a.year()+"-"+n.year()),p.maxDate&&p.maxDate.isBefore(n,"y")&&t.eq(2).addClass("disabled");!a.isAfter(n,"y");)r+='<span data-action="selectYear" class="year'+(a.isSame(E,"y")&&!W?" active":"")+(m(a,"y")?"":" disabled")+'">'+a.year()+"</span>",a.add(1,"y");e.find("td").html(r)}(),function(){var e,t=N.find(".datepicker-decades"),a=t.find("th"),n=_({y:H.year()-H.year()%100-1}),r=n.clone().add(100,"y"),i=n.clone(),o=!1,s=!1,d="";for(a.eq(0).find("span").attr("title",p.tooltips.prevCentury),a.eq(2).find("span").attr("title",p.tooltips.nextCentury),t.find(".disabled").removeClass("disabled"),(n.isSame(_({y:1900}))||p.minDate&&p.minDate.isAfter(n,"y"))&&a.eq(0).addClass("disabled"),a.eq(1).text(n.year()+"-"+r.year()),(n.isSame(_({y:2e3}))||p.maxDate&&p.maxDate.isBefore(r,"y"))&&a.eq(2).addClass("disabled");!n.isAfter(r,"y");)e=n.year()+12,o=p.minDate&&p.minDate.isAfter(n,"y")&&p.minDate.year()<=e,s=p.maxDate&&p.maxDate.isAfter(n,"y")&&p.maxDate.year()<=e,d+='<span data-action="selectDecade" class="decade'+(E.isAfter(n)&&E.year()<=e?" active":"")+(m(n,"y")||o||s?"":" disabled")+'" data-selection="'+(n.year()+6)+'">'+(n.year()+1)+" - "+(n.year()+12)+"</span>",n.add(12,"y");d+="<span></span><span></span><span></span>",t.find("td").html(d),a.eq(1).text(i.year()+1+"-"+n.year())}()}}function e(){var e,t,a=N.find(".timepicker span[data-time-component]");Y||(e=N.find(".timepicker [data-action=togglePeriod]"),t=E.clone().add(12<=E.hours()?-12:12,"h"),e.text(E.format("A")),m(t,"h")?e.removeClass("disabled"):e.addClass("disabled")),a.filter("[data-time-component=hours]").text(E.format(Y?"HH":"hh")),a.filter("[data-time-component=minutes]").text(E.format("mm")),a.filter("[data-time-component=seconds]").text(E.format("ss")),function(){var e=N.find(".timepicker-hours table"),t=H.clone().startOf("d"),a=[],n=$("<tr>");for(11<H.hour()&&!Y&&t.hour(12);t.isSame(H,"d")&&(Y||H.hour()<12&&t.hour()<12||11<H.hour());)t.hour()%4==0&&(n=$("<tr>"),a.push(n)),n.append('<td data-action="selectHour" class="hour'+(m(t,"h")?"":" disabled")+'">'+t.format(Y?"HH":"hh")+"</td>"),t.add(1,"h");e.empty().append(a)}(),function(){for(var e=N.find(".timepicker-minutes table"),t=H.clone().startOf("h"),a=[],n=$("<tr>"),r=1===p.stepping?5:p.stepping;H.isSame(t,"h");)t.minute()%(4*r)==0&&(n=$("<tr>"),a.push(n)),n.append('<td data-action="selectMinute" class="minute'+(m(t,"m")?"":" disabled")+'">'+t.format("mm")+"</td>"),t.add(r,"m");e.empty().append(a)}(),function(){for(var e=N.find(".timepicker-seconds table"),t=H.clone().startOf("m"),a=[],n=$("<tr>");H.isSame(t,"m");)t.second()%20==0&&(n=$("<tr>"),a.push(n)),n.append('<td data-action="selectSecond" class="second'+(m(t,"s")?"":" disabled")+'">'+t.format("ss")+"</td>"),t.add(5,"s");e.empty().append(a)}()}function y(){N&&(s(),e())}function g(e){var t=W?null:E;if(!e)return W=!0,I.val(""),i.data("date",""),h({type:"dp.change",date:!1,oldDate:t}),void y();if(e=e.clone().locale(p.locale),a()&&e.tz(p.timeZone),1!==p.stepping)for(e.minutes(Math.round(e.minutes()/p.stepping)*p.stepping).seconds(0);p.minDate&&e.isBefore(p.minDate);)e.add(p.stepping,"minutes");m(e)?(H=(E=e).clone(),I.val(E.format(q)),i.data("date",E.format(q)),W=!1,y(),h({type:"dp.change",date:E.clone(),oldDate:t})):(p.keepInvalid?h({type:"dp.change",date:e,oldDate:t}):I.val(W?"":E.format(q)),h({type:"dp.error",date:e,oldDate:t}))}function b(){var t=!1;return N?(N.find(".collapse").each(function(){var e=$(this).data("collapse");return!e||!e.transitioning||!(t=!0)}),t||(z&&z.hasClass("btn")&&z.toggleClass("active"),N.hide(),$(window).off("resize",n),N.off("click","[data-action]"),N.off("mousedown",!1),N.remove(),N=!1,h({type:"dp.hide",date:E.clone()}),I.blur(),H=E.clone()),L):L}function w(){g(null)}function v(e){return void 0===p.parseInputDate?(!_.isMoment(e)||e instanceof Date)&&(e=c(e)):e=p.parseInputDate(e),e}function k(e){return $(e.currentTarget).is(".disabled")||X[$(e.currentTarget).data("action")].apply(L,arguments),!1}function D(){var e;return I.prop("disabled")||!p.ignoreReadonly&&I.prop("readonly")||N||(void 0!==I.val()&&0!==I.val().trim().length?g(v(I.val().trim())):W&&p.useCurrent&&(p.inline||I.is("input")&&0===I.val().trim().length)&&(e=c(),"string"==typeof p.useCurrent&&(e={year:function(e){return e.month(0).date(1).hours(0).seconds(0).minutes(0)},month:function(e){return e.date(1).hours(0).seconds(0).minutes(0)},day:function(e){return e.hours(0).seconds(0).minutes(0)},hour:function(e){return e.seconds(0).minutes(0)},minute:function(e){return e.seconds(0)}}[p.useCurrent](e)),g(e)),N=t(),function(){var e=$("<tr>"),t=H.clone().startOf("w").startOf("d");for(!0===p.calendarWeeks&&e.append($("<th>").addClass("cw").text("#"));t.isBefore(H.clone().endOf("w"));)e.append($("<th>").addClass("dow").text(t.format("dd"))),t.add(1,"d");N.find(".datepicker-days thead").append(e)}(),function(){for(var e=[],t=H.clone().startOf("y").startOf("d");t.isSame(H,"y");)e.push($("<span>").attr("data-action","selectMonth").addClass("month").text(t.format("MMM"))),t.add(1,"M");N.find(".datepicker-months td").empty().append(e)}(),N.find(".timepicker-hours").hide(),N.find(".timepicker-minutes").hide(),N.find(".timepicker-seconds").hide(),y(),o(),$(window).on("resize",n),N.on("click","[data-action]",k),N.on("mousedown",!1),z&&z.hasClass("btn")&&z.toggleClass("active"),n(),N.show(),p.focusOnShow&&!I.is(":focus")&&I.focus(),h({type:"dp.show"})),L}function C(){return(N?b:D)()}function x(e){var t,a,n,r,i=null,o=[],s={},d=e.which;for(t in K[d]="p",K)K.hasOwnProperty(t)&&"p"===K[t]&&(o.push(t),parseInt(t,10)!==d&&(s[t]=!0));for(t in p.keyBinds)if(p.keyBinds.hasOwnProperty(t)&&"function"==typeof p.keyBinds[t]&&(n=t.split(" ")).length===o.length&&J[d]===n[n.length-1]){for(r=!0,a=n.length-2;0<=a;a--)if(!(J[n[a]]in s)){r=!1;break}if(r){i=p.keyBinds[t];break}}i&&(i.call(L,N),e.stopPropagation(),e.preventDefault())}function T(e){K[e.which]="r",e.stopPropagation(),e.preventDefault()}function M(e){var t=$(e.target).val().trim(),a=t?v(t):null;return g(a),e.stopImmediatePropagation(),!1}function S(e){var t={};return $.each(e,function(){var e=v(this);e.isValid()&&(t[e.format("YYYY-MM-DD")]=!0)}),!!Object.keys(t).length&&t}function O(e){var t={};return $.each(e,function(){t[this]=!0}),!!Object.keys(t).length&&t}function P(){var e=p.format||"L LT";q=e.replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,function(e){return(E.localeData().longDateFormat(e)||e).replace(/(\[[^\[]*\])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,function(e){return E.localeData().longDateFormat(e)||e})}),(B=p.extraFormats?p.extraFormats.slice():[]).indexOf(e)<0&&B.indexOf(q)<0&&B.push(q),Y=q.toLowerCase().indexOf("a")<1&&q.replace(/\[.*?\]/g,"").indexOf("h")<1,d("y")&&(V=2),d("M")&&(V=1),d("d")&&(V=0),j=Math.max(V,j),W||g(E)}var E,H,I,Y,q,B,j,A,F,L={},W=!0,z=!1,N=!1,V=0,Z=[{clsName:"days",navFnc:"M",navStep:1},{clsName:"months",navFnc:"y",navStep:1},{clsName:"years",navFnc:"y",navStep:10},{clsName:"decades",navFnc:"y",navStep:100}],R=["days","months","years","decades"],Q=["top","bottom","auto"],U=["left","right","auto"],G=["default","top","bottom"],J={up:38,38:"up",down:40,40:"down",left:37,37:"left",right:39,39:"right",tab:9,9:"tab",escape:27,27:"escape",enter:13,13:"enter",pageUp:33,33:"pageUp",pageDown:34,34:"pageDown",shift:16,16:"shift",control:17,17:"control",space:32,32:"space",t:84,84:"t",delete:46,46:"delete"},K={},X={next:function(){var e=Z[j].navFnc;H.add(Z[j].navStep,e),s(),r(e)},previous:function(){var e=Z[j].navFnc;H.subtract(Z[j].navStep,e),s(),r(e)},pickerSwitch:function(){o(1)},selectMonth:function(e){var t=$(e.target).closest("tbody").find("span").index($(e.target));H.month(t),j===V?(g(E.clone().year(H.year()).month(H.month())),p.inline||b()):(o(-1),s()),r("M")},selectYear:function(e){var t=parseInt($(e.target).text(),10)||0;H.year(t),j===V?(g(E.clone().year(H.year())),p.inline||b()):(o(-1),s()),r("YYYY")},selectDecade:function(e){var t=parseInt($(e.target).data("selection"),10)||0;H.year(t),j===V?(g(E.clone().year(H.year())),p.inline||b()):(o(-1),s()),r("YYYY")},selectDay:function(e){var t=H.clone();$(e.target).is(".old")&&t.subtract(1,"M"),$(e.target).is(".new")&&t.add(1,"M"),g(t.date(parseInt($(e.target).text(),10))),l()||p.keepOpen||p.inline||b()},incrementHours:function(){var e=E.clone().add(1,"h");m(e,"h")&&g(e)},incrementMinutes:function(){var e=E.clone().add(p.stepping,"m");m(e,"m")&&g(e)},incrementSeconds:function(){var e=E.clone().add(1,"s");m(e,"s")&&g(e)},decrementHours:function(){var e=E.clone().subtract(1,"h");m(e,"h")&&g(e)},decrementMinutes:function(){var e=E.clone().subtract(p.stepping,"m");m(e,"m")&&g(e)},decrementSeconds:function(){var e=E.clone().subtract(1,"s");m(e,"s")&&g(e)},togglePeriod:function(){g(E.clone().add(12<=E.hours()?-12:12,"h"))},togglePicker:function(e){var t,a=$(e.target),n=a.closest("ul"),r=n.find(".in"),i=n.find(".collapse:not(.in)");if(r&&r.length){if((t=r.data("collapse"))&&t.transitioning)return;r.collapse?(r.collapse("hide"),i.collapse("show")):(r.removeClass("in"),i.addClass("in")),a.is("span")?a.toggleClass(p.icons.time+" "+p.icons.date):a.find("span").toggleClass(p.icons.time+" "+p.icons.date)}},showPicker:function(){N.find(".timepicker > div:not(.timepicker-picker)").hide(),N.find(".timepicker .timepicker-picker").show()},showHours:function(){N.find(".timepicker .timepicker-picker").hide(),N.find(".timepicker .timepicker-hours").show()},showMinutes:function(){N.find(".timepicker .timepicker-picker").hide(),N.find(".timepicker .timepicker-minutes").show()},showSeconds:function(){N.find(".timepicker .timepicker-picker").hide(),N.find(".timepicker .timepicker-seconds").show()},selectHour:function(e){var t=parseInt($(e.target).text(),10);Y||(12<=E.hours()?12!==t&&(t+=12):12===t&&(t=0)),g(E.clone().hours(t)),X.showPicker.call(L)},selectMinute:function(e){g(E.clone().minutes(parseInt($(e.target).text(),10))),X.showPicker.call(L)},selectSecond:function(e){g(E.clone().seconds(parseInt($(e.target).text(),10))),X.showPicker.call(L)},clear:w,today:function(){var e=c();m(e,"d")&&g(e)},close:b};if(L.destroy=function(){b(),I.off({change:M,blur:blur,keydown:x,keyup:T,focus:p.allowInputToggle?b:""}),i.is("input")?I.off({focus:D}):z&&(z.off("click",C),z.off("mousedown",!1)),i.removeData("DateTimePicker"),i.removeData("date")},L.toggle=C,L.show=D,L.hide=b,L.disable=function(){return b(),z&&z.hasClass("btn")&&z.addClass("disabled"),I.prop("disabled",!0),L},L.enable=function(){return z&&z.hasClass("btn")&&z.removeClass("disabled"),I.prop("disabled",!1),L},L.ignoreReadonly=function(e){if(0===arguments.length)return p.ignoreReadonly;if("boolean"!=typeof e)throw new TypeError("ignoreReadonly () expects a boolean parameter");return p.ignoreReadonly=e,L},L.options=function(e){if(0===arguments.length)return $.extend(!0,{},p);if(!(e instanceof Object))throw new TypeError("options() options parameter should be an object");return $.extend(!0,p,e),$.each(p,function(e,t){if(void 0===L[e])throw new TypeError("option "+e+" is not recognized!");L[e](t)}),L},L.date=function(e){if(0===arguments.length)return W?null:E.clone();if(!(null===e||"string"==typeof e||_.isMoment(e)||e instanceof Date))throw new TypeError("date() parameter must be one of [null, string, moment or Date]");return g(null===e?null:v(e)),L},L.format=function(e){if(0===arguments.length)return p.format;if("string"!=typeof e&&("boolean"!=typeof e||!1!==e))throw new TypeError("format() expects a string or boolean:false parameter "+e);return p.format=e,q&&P(),L},L.timeZone=function(e){if(0===arguments.length)return p.timeZone;if("string"!=typeof e)throw new TypeError("newZone() expects a string parameter");return p.timeZone=e,L},L.dayViewHeaderFormat=function(e){if(0===arguments.length)return p.dayViewHeaderFormat;if("string"!=typeof e)throw new TypeError("dayViewHeaderFormat() expects a string parameter");return p.dayViewHeaderFormat=e,L},L.extraFormats=function(e){if(0===arguments.length)return p.extraFormats;if(!1!==e&&!(e instanceof Array))throw new TypeError("extraFormats() expects an array or false parameter");return p.extraFormats=e,B&&P(),L},L.disabledDates=function(e){if(0===arguments.length)return p.disabledDates?$.extend({},p.disabledDates):p.disabledDates;if(!e)return p.disabledDates=!1,y(),L;if(!(e instanceof Array))throw new TypeError("disabledDates() expects an array parameter");return p.disabledDates=S(e),p.enabledDates=!1,y(),L},L.enabledDates=function(e){if(0===arguments.length)return p.enabledDates?$.extend({},p.enabledDates):p.enabledDates;if(!e)return p.enabledDates=!1,y(),L;if(!(e instanceof Array))throw new TypeError("enabledDates() expects an array parameter");return p.enabledDates=S(e),p.disabledDates=!1,y(),L},L.daysOfWeekDisabled=function(e){if(0===arguments.length)return p.daysOfWeekDisabled.splice(0);if("boolean"==typeof e&&!e)return p.daysOfWeekDisabled=!1,y(),L;if(!(e instanceof Array))throw new TypeError("daysOfWeekDisabled() expects an array parameter");if(p.daysOfWeekDisabled=e.reduce(function(e,t){return 6<(t=parseInt(t,10))||t<0||isNaN(t)||-1===e.indexOf(t)&&e.push(t),e},[]).sort(),p.useCurrent&&!p.keepInvalid){for(var t=0;!m(E,"d");){if(E.add(1,"d"),31===t)throw"Tried 31 times to find a valid date";t++}g(E)}return y(),L},L.maxDate=function(e){if(0===arguments.length)return p.maxDate?p.maxDate.clone():p.maxDate;if("boolean"==typeof e&&!1===e)return p.maxDate=!1,y(),L;"string"==typeof e&&("now"!==e&&"moment"!==e||(e=c()));var t=v(e);if(!t.isValid())throw new TypeError("maxDate() Could not parse date parameter: "+e);if(p.minDate&&t.isBefore(p.minDate))throw new TypeError("maxDate() date parameter is before options.minDate: "+t.format(q));return p.maxDate=t,p.useCurrent&&!p.keepInvalid&&E.isAfter(e)&&g(p.maxDate),H.isAfter(t)&&(H=t.clone().subtract(p.stepping,"m")),y(),L},L.minDate=function(e){if(0===arguments.length)return p.minDate?p.minDate.clone():p.minDate;if("boolean"==typeof e&&!1===e)return p.minDate=!1,y(),L;"string"==typeof e&&("now"!==e&&"moment"!==e||(e=c()));var t=v(e);if(!t.isValid())throw new TypeError("minDate() Could not parse date parameter: "+e);if(p.maxDate&&t.isAfter(p.maxDate))throw new TypeError("minDate() date parameter is after options.maxDate: "+t.format(q));return p.minDate=t,p.useCurrent&&!p.keepInvalid&&E.isBefore(e)&&g(p.minDate),H.isBefore(t)&&(H=t.clone().add(p.stepping,"m")),y(),L},L.defaultDate=function(e){if(0===arguments.length)return p.defaultDate?p.defaultDate.clone():p.defaultDate;if(!e)return p.defaultDate=!1,L;"string"==typeof e&&(e="now"===e||"moment"===e?c():c(e));var t=v(e);if(!t.isValid())throw new TypeError("defaultDate() Could not parse date parameter: "+e);if(!m(t))throw new TypeError("defaultDate() date passed is invalid according to component setup validations");return p.defaultDate=t,(p.defaultDate&&p.inline||""===I.val().trim())&&g(p.defaultDate),L},L.locale=function(e){if(0===arguments.length)return p.locale;if(!_.localeData(e))throw new TypeError("locale() locale "+e+" is not loaded from moment locales!");return p.locale=e,E.locale(p.locale),H.locale(p.locale),q&&P(),N&&(b(),D()),L},L.stepping=function(e){return 0===arguments.length?p.stepping:(e=parseInt(e,10),(isNaN(e)||e<1)&&(e=1),p.stepping=e,L)},L.useCurrent=function(e){var t=["year","month","day","hour","minute"];if(0===arguments.length)return p.useCurrent;if("boolean"!=typeof e&&"string"!=typeof e)throw new TypeError("useCurrent() expects a boolean or string parameter");if("string"==typeof e&&-1===t.indexOf(e.toLowerCase()))throw new TypeError("useCurrent() expects a string parameter of "+t.join(", "));return p.useCurrent=e,L},L.collapse=function(e){if(0===arguments.length)return p.collapse;if("boolean"!=typeof e)throw new TypeError("collapse() expects a boolean parameter");return p.collapse===e||(p.collapse=e,N&&(b(),D())),L},L.icons=function(e){if(0===arguments.length)return $.extend({},p.icons);if(!(e instanceof Object))throw new TypeError("icons() expects parameter to be an Object");return $.extend(p.icons,e),N&&(b(),D()),L},L.tooltips=function(e){if(0===arguments.length)return $.extend({},p.tooltips);if(!(e instanceof Object))throw new TypeError("tooltips() expects parameter to be an Object");return $.extend(p.tooltips,e),N&&(b(),D()),L},L.useStrict=function(e){if(0===arguments.length)return p.useStrict;if("boolean"!=typeof e)throw new TypeError("useStrict() expects a boolean parameter");return p.useStrict=e,L},L.sideBySide=function(e){if(0===arguments.length)return p.sideBySide;if("boolean"!=typeof e)throw new TypeError("sideBySide() expects a boolean parameter");return p.sideBySide=e,N&&(b(),D()),L},L.viewMode=function(e){if(0===arguments.length)return p.viewMode;if("string"!=typeof e)throw new TypeError("viewMode() expects a string parameter");if(-1===R.indexOf(e))throw new TypeError("viewMode() parameter must be one of ("+R.join(", ")+") value");return p.viewMode=e,j=Math.max(R.indexOf(e),V),o(),L},L.toolbarPlacement=function(e){if(0===arguments.length)return p.toolbarPlacement;if("string"!=typeof e)throw new TypeError("toolbarPlacement() expects a string parameter");if(-1===G.indexOf(e))throw new TypeError("toolbarPlacement() parameter must be one of ("+G.join(", ")+") value");return p.toolbarPlacement=e,N&&(b(),D()),L},L.widgetPositioning=function(e){if(0===arguments.length)return $.extend({},p.widgetPositioning);if("[object Object]"!=={}.toString.call(e))throw new TypeError("widgetPositioning() expects an object variable");if(e.horizontal){if("string"!=typeof e.horizontal)throw new TypeError("widgetPositioning() horizontal variable must be a string");if(e.horizontal=e.horizontal.toLowerCase(),-1===U.indexOf(e.horizontal))throw new TypeError("widgetPositioning() expects horizontal parameter to be one of ("+U.join(", ")+")");p.widgetPositioning.horizontal=e.horizontal}if(e.vertical){if("string"!=typeof e.vertical)throw new TypeError("widgetPositioning() vertical variable must be a string");if(e.vertical=e.vertical.toLowerCase(),-1===Q.indexOf(e.vertical))throw new TypeError("widgetPositioning() expects vertical parameter to be one of ("+Q.join(", ")+")");p.widgetPositioning.vertical=e.vertical}return y(),L},L.calendarWeeks=function(e){if(0===arguments.length)return p.calendarWeeks;if("boolean"!=typeof e)throw new TypeError("calendarWeeks() expects parameter to be a boolean value");return p.calendarWeeks=e,y(),L},L.showTodayButton=function(e){if(0===arguments.length)return p.showTodayButton;if("boolean"!=typeof e)throw new TypeError("showTodayButton() expects a boolean parameter");return p.showTodayButton=e,N&&(b(),D()),L},L.showClear=function(e){if(0===arguments.length)return p.showClear;if("boolean"!=typeof e)throw new TypeError("showClear() expects a boolean parameter");return p.showClear=e,N&&(b(),D()),L},L.widgetParent=function(e){if(0===arguments.length)return p.widgetParent;if("string"==typeof e&&(e=$(e)),null!==e&&"string"!=typeof e&&!(e instanceof $))throw new TypeError("widgetParent() expects a string or a jQuery object parameter");return p.widgetParent=e,N&&(b(),D()),L},L.keepOpen=function(e){if(0===arguments.length)return p.keepOpen;if("boolean"!=typeof e)throw new TypeError("keepOpen() expects a boolean parameter");return p.keepOpen=e,L},L.focusOnShow=function(e){if(0===arguments.length)return p.focusOnShow;if("boolean"!=typeof e)throw new TypeError("focusOnShow() expects a boolean parameter");return p.focusOnShow=e,L},L.inline=function(e){if(0===arguments.length)return p.inline;if("boolean"!=typeof e)throw new TypeError("inline() expects a boolean parameter");return p.inline=e,L},L.clear=function(){return w(),L},L.keyBinds=function(e){return 0===arguments.length?p.keyBinds:(p.keyBinds=e,L)},L.getMoment=function(e){return c(e)},L.debug=function(e){if("boolean"!=typeof e)throw new TypeError("debug() expects a boolean parameter");return p.debug=e,L},L.allowInputToggle=function(e){if(0===arguments.length)return p.allowInputToggle;if("boolean"!=typeof e)throw new TypeError("allowInputToggle() expects a boolean parameter");return p.allowInputToggle=e,L},L.showClose=function(e){if(0===arguments.length)return p.showClose;if("boolean"!=typeof e)throw new TypeError("showClose() expects a boolean parameter");return p.showClose=e,L},L.keepInvalid=function(e){if(0===arguments.length)return p.keepInvalid;if("boolean"!=typeof e)throw new TypeError("keepInvalid() expects a boolean parameter");return p.keepInvalid=e,L},L.datepickerInput=function(e){if(0===arguments.length)return p.datepickerInput;if("string"!=typeof e)throw new TypeError("datepickerInput() expects a string parameter");return p.datepickerInput=e,L},L.parseInputDate=function(e){if(0===arguments.length)return p.parseInputDate;if("function"!=typeof e)throw new TypeError("parseInputDate() sholud be as function");return p.parseInputDate=e,L},L.disabledTimeIntervals=function(e){if(0===arguments.length)return p.disabledTimeIntervals?$.extend({},p.disabledTimeIntervals):p.disabledTimeIntervals;if(!e)return p.disabledTimeIntervals=!1,y(),L;if(!(e instanceof Array))throw new TypeError("disabledTimeIntervals() expects an array parameter");return p.disabledTimeIntervals=e,y(),L},L.disabledHours=function(e){if(0===arguments.length)return p.disabledHours?$.extend({},p.disabledHours):p.disabledHours;if(!e)return p.disabledHours=!1,y(),L;if(!(e instanceof Array))throw new TypeError("disabledHours() expects an array parameter");if(p.disabledHours=O(e),p.enabledHours=!1,p.useCurrent&&!p.keepInvalid){for(var t=0;!m(E,"h");){if(E.add(1,"h"),24===t)throw"Tried 24 times to find a valid date";t++}g(E)}return y(),L},L.enabledHours=function(e){if(0===arguments.length)return p.enabledHours?$.extend({},p.enabledHours):p.enabledHours;if(!e)return p.enabledHours=!1,y(),L;if(!(e instanceof Array))throw new TypeError("enabledHours() expects an array parameter");if(p.enabledHours=O(e),p.disabledHours=!1,p.useCurrent&&!p.keepInvalid){for(var t=0;!m(E,"h");){if(E.add(1,"h"),24===t)throw"Tried 24 times to find a valid date";t++}g(E)}return y(),L},L.viewDate=function(e){if(0===arguments.length)return H.clone();if(!e)return H=E.clone(),L;if(!("string"==typeof e||_.isMoment(e)||e instanceof Date))throw new TypeError("viewDate() parameter must be one of [string, moment or Date]");return H=v(e),r(),L},i.is("input"))I=i;else if(0===(I=i.find(p.datepickerInput)).length)I=i.find("input");else if(!I.is("input"))throw new Error('CSS class "'+p.datepickerInput+'" cannot be applied to non input element');if(i.hasClass("input-group")&&(z=0===i.find(".datepickerbutton").length?i.find(".input-group-addon"):i.find(".datepickerbutton")),!p.inline&&!I.is("input"))throw new Error("Could not initialize DateTimePicker without an input element");return E=c(),H=E.clone(),$.extend(!0,p,(F={},(A=i.is("input")||p.inline?i.data():i.find("input").data()).dateOptions&&A.dateOptions instanceof Object&&(F=$.extend(!0,F,A.dateOptions)),$.each(p,function(e){var t="date"+e.charAt(0).toUpperCase()+e.slice(1);void 0!==A[t]&&(F[e]=A[t])}),F)),L.options(p),P(),I.on({change:M,blur:p.debug?"":b,keydown:x,keyup:T,focus:p.allowInputToggle?D:""}),i.is("input")?I.on({focus:D}):z&&(z.on("click",C),z.on("mousedown",!1)),I.prop("disabled")&&L.disable(),I.is("input")&&0!==I.val().trim().length?g(v(I.val().trim())):p.defaultDate&&void 0===I.attr("placeholder")&&g(p.defaultDate),p.inline&&D(),L}return $.fn.datetimepicker=function(a){a=a||{};var t,n=Array.prototype.slice.call(arguments,1),r=!0;if("object"==typeof a)return this.each(function(){var e,t=$(this);t.data("DateTimePicker")||(e=$.extend(!0,{},$.fn.datetimepicker.defaults,a),t.data("DateTimePicker",i(t,e)))});if("string"==typeof a)return this.each(function(){var e=$(this).data("DateTimePicker");if(!e)throw new Error('bootstrap-datetimepicker("'+a+'") method was called on an element that is not using DateTimePicker');t=e[a].apply(e,n),r=t===e}),r||-1<$.inArray(a,["destroy","hide","show","toggle"])?this:t;throw new TypeError("Invalid arguments for DateTimePicker: "+a)},$.fn.datetimepicker.defaults={timeZone:"",format:!1,dayViewHeaderFormat:"MMMM YYYY",extraFormats:!1,stepping:1,minDate:!1,maxDate:!1,useCurrent:!0,collapse:!0,locale:_.locale(),defaultDate:!1,disabledDates:!1,enabledDates:!1,icons:{time:"glyphicon glyphicon-time",date:"glyphicon glyphicon-calendar",up:"glyphicon glyphicon-chevron-up",down:"glyphicon glyphicon-chevron-down",previous:"glyphicon glyphicon-chevron-left",next:"glyphicon glyphicon-chevron-right",today:"glyphicon glyphicon-screenshot",clear:"glyphicon glyphicon-trash",close:"glyphicon glyphicon-remove"},tooltips:{today:"Go to today",clear:"Clear selection",close:"Close the picker",selectMonth:"Select Month",prevMonth:"Previous Month",nextMonth:"Next Month",selectYear:"Select Year",prevYear:"Previous Year",nextYear:"Next Year",selectDecade:"Select Decade",prevDecade:"Previous Decade",nextDecade:"Next Decade",prevCentury:"Previous Century",nextCentury:"Next Century",pickHour:"Pick Hour",incrementHour:"Increment Hour",decrementHour:"Decrement Hour",pickMinute:"Pick Minute",incrementMinute:"Increment Minute",decrementMinute:"Decrement Minute",pickSecond:"Pick Second",incrementSecond:"Increment Second",decrementSecond:"Decrement Second",togglePeriod:"Toggle Period",selectTime:"Select Time"},useStrict:!1,sideBySide:!1,daysOfWeekDisabled:!1,calendarWeeks:!1,viewMode:"days",toolbarPlacement:"default",showTodayButton:!1,showClear:!1,showClose:!1,widgetPositioning:{horizontal:"auto",vertical:"auto"},widgetParent:null,ignoreReadonly:!1,keepOpen:!1,focusOnShow:!0,inline:!1,keepInvalid:!1,datepickerInput:".datepickerinput",keyBinds:{up:function(e){if(e){var t=this.date()||this.getMoment();e.find(".datepicker").is(":visible")?this.date(t.clone().subtract(7,"d")):this.date(t.clone().add(this.stepping(),"m"))}},down:function(e){if(e){var t=this.date()||this.getMoment();e.find(".datepicker").is(":visible")?this.date(t.clone().add(7,"d")):this.date(t.clone().subtract(this.stepping(),"m"))}else this.show()},"control up":function(e){if(e){var t=this.date()||this.getMoment();e.find(".datepicker").is(":visible")?this.date(t.clone().subtract(1,"y")):this.date(t.clone().add(1,"h"))}},"control down":function(e){if(e){var t=this.date()||this.getMoment();e.find(".datepicker").is(":visible")?this.date(t.clone().add(1,"y")):this.date(t.clone().subtract(1,"h"))}},left:function(e){if(e){var t=this.date()||this.getMoment();e.find(".datepicker").is(":visible")&&this.date(t.clone().subtract(1,"d"))}},right:function(e){if(e){var t=this.date()||this.getMoment();e.find(".datepicker").is(":visible")&&this.date(t.clone().add(1,"d"))}},pageUp:function(e){if(e){var t=this.date()||this.getMoment();e.find(".datepicker").is(":visible")&&this.date(t.clone().subtract(1,"M"))}},pageDown:function(e){if(e){var t=this.date()||this.getMoment();e.find(".datepicker").is(":visible")&&this.date(t.clone().add(1,"M"))}},enter:function(){this.hide()},escape:function(){this.hide()},"control space":function(e){e&&e.find(".timepicker").is(":visible")&&e.find('.btn[data-action="togglePeriod"]').click()},t:function(){this.date(this.getMoment())},delete:function(){this.clear()}},debug:!1,allowInputToggle:!1,disabledTimeIntervals:!1,disabledHours:!1,enabledHours:!1,viewDate:!1},$.fn.datetimepicker}); \ No newline at end of file diff --git a/javascript/fabric.min.js b/javascript/src/Plugins/fabric.min.js similarity index 100% rename from javascript/fabric.min.js rename to javascript/src/Plugins/fabric.min.js diff --git a/javascript/src/Plugins/qfq.fabric.js b/javascript/src/Plugins/qfq.fabric.js index f87b882f1c1033c0eb479213fd99671bcfeba6f8..efc5ffa087f5330ce4e3b8332a27d8d1fa57f4d4 100644 --- a/javascript/src/Plugins/qfq.fabric.js +++ b/javascript/src/Plugins/qfq.fabric.js @@ -181,13 +181,13 @@ $(function (n) { }; ModeSettings.prototype.getButtonById = function (needle) { - var needleInHaystack = {}; + var needleInHaystack = false; this.myButtons.forEach(function (haystack) { if (haystack[0].id === needle) { needleInHaystack = haystack; } }); - if (needleInHaystack === {}) { + if (needleInHaystack) { console.error("Button not found, id: " + string); } else { return needleInHaystack; diff --git a/javascript/src/QfqForm.js b/javascript/src/QfqForm.js index 147144d1c92e9e22c5f4361a3a3b977b634a7464..e292a30b5a4819d9700216d32e6bb5cb0992d3a9 100644 --- a/javascript/src/QfqForm.js +++ b/javascript/src/QfqForm.js @@ -146,9 +146,9 @@ var QfqNS = QfqNS || {}; this.applyFormConfiguration(configurationData); // Initialize jqxDateTimeInput elements. - n.Helper.jqxDateTimeInput(); + //n.Helper.jqxDateTimeInput(); // Initialize jqxComboBox elements. - n.Helper.jqxComboBox(); + //n.Helper.jqxComboBox(); // Deprecated //n.Helper.jqxEditor(); n.Helper.tinyMce(); @@ -213,7 +213,7 @@ var QfqNS = QfqNS || {}; }]; if (obj.data.status == "conflict_allow_force") { messageButtons.push({ - label: "Ignore", + label: "Continue", eventName: 'ignore' }); } @@ -278,10 +278,14 @@ var QfqNS = QfqNS || {}; break; case "conflict_allow_force": messageType = "warning"; - messageButtons.push({ - label: "Ignore", + + messageButtons = [{ + label: "Continue", eventName: 'ignore' - }); + }, { + label: "Cancel", + eventName: 'reload' + }]; break; case "error": messageType = "error"; @@ -584,7 +588,13 @@ var QfqNS = QfqNS || {}; * @private */ n.QfqForm.prototype.handleSaveClick = function () { - this.lastButtonPress = "save"; + + // "save,force" if sqlValidate() should be ignored, default is "save" + this.lastButtonPress = this.getSaveButton().attr('data-save-force') || "save" ; + + // Remove attribute + this.getSaveButton().removeAttr('data-save-force'); + n.Log.debug("save click"); this.checkHiddenRequired(); this.getSaveButton().removeClass('btn-info'); @@ -638,6 +648,7 @@ var QfqNS = QfqNS || {}; n.QfqForm.prototype.submit = function (queryParameters) { var submitQueryParameters; + console.log("Save in progress", submitQueryParameters); var alert; var submitReason; @@ -652,7 +663,7 @@ var QfqNS = QfqNS || {}; } - var form = document.getElementById(this.form.formId); + var form = document.getElementById(this.formId); var inputs = form.elements; for (var i = 0; i < inputs.length; i++) { @@ -697,8 +708,14 @@ var QfqNS = QfqNS || {}; "submit_reason": this.lastButtonPress === "close" ? "save,close" : this.lastButtonPress }; + // Change to "save" for following actions + if (this.lastButtonPress === "save,force") { + this.lastButtonPress = "save"; + } + submitQueryParameters = $.extend({}, queryParameters, submitReason); this.form.submitTo(this.submitTo, submitQueryParameters); + console.log("Submitting with", submitQueryParameters); this.form.saveInProgress = true; }; @@ -942,7 +959,7 @@ var QfqNS = QfqNS || {}; * @private */ n.QfqForm.prototype.getSaveButton = function () { - return $("#save-button"); + return $("#save-button-" + this.formId); }; /** @@ -952,7 +969,7 @@ var QfqNS = QfqNS || {}; * @private */ n.QfqForm.prototype.getCloseButton = function () { - return $("#close-button"); + return $("#close-button-" + this.formId); }; /** @@ -962,7 +979,7 @@ var QfqNS = QfqNS || {}; * @private */ n.QfqForm.prototype.getDeleteButton = function () { - return $("#delete-button"); + return $("#delete-button-" + this.formId); }; /** @@ -972,7 +989,7 @@ var QfqNS = QfqNS || {}; * @private */ n.QfqForm.prototype.getNewButton = function () { - return $("#form-new-button"); + return $("#form-new-button-" + this.formId); }; @@ -1015,16 +1032,38 @@ var QfqNS = QfqNS || {}; if (!data.message) { throw Error("Status is 'error' but required 'message' attribute is missing."); } - this._createError(data.message); - if (data["field-name"] && this.bsTabs) { - var tabId = this.bsTabs.getContainingTabIdForFormControl(data["field-name"]); - if (tabId) { - this.bsTabs.activateTab(tabId); - } + // Alert with force save option: data.text will be set for sqlValidate() + if (data.text) { - this.setValidationState(data["field-name"], "error"); - this.setHelpBlockValidationMessage(data["field-name"], data["field-message"]); + var forceButton = (data.force) ? { label: data.force, eventName: 'save-force' } : ''; + + var alert = new n.Alert({ + message: data.text, + type: data.level, + buttons: [ { label: data.ok, eventName: 'ok' } ], + modal: data.flagModal, + timeout: data.timeout + }); + + if (forceButton) alert.buttons.unshift(forceButton); + alert.on('alert.save-force', function () { + $("#save-button-" + form.formId).attr('data-save-force', 'save,force'); + $("#save-button-" + form.formId).click(); + }.bind(form.formId)); + alert.show(); + + } else { + this._createError(data.message); + if (data["field-name"] && this.bsTabs) { + var tabId = this.bsTabs.getContainingTabIdForFormControl(data["field-name"]); + if (tabId) { + this.bsTabs.activateTab(tabId); + } + + this.setValidationState(data["field-name"], "error"); + this.setHelpBlockValidationMessage(data["field-name"], data["field-message"]); + } } }; @@ -1073,6 +1112,8 @@ var QfqNS = QfqNS || {}; */ n.QfqForm.prototype.handleSubmitSuccess = function (form, data) { n.Log.debug('Reset form state'); + this.getSaveButton().removeClass('btn-warning active disabled'); + this.getSaveButton().addClass('btn-default'); form.resetFormChanged(); this.resetLockState(); @@ -1237,9 +1278,8 @@ var QfqNS = QfqNS || {}; * confusing. */ var $formGroup = this.getFormGroupByControlName(formControlName); - if (!$formGroup) { - return; - } + if (!$formGroup) return; + var $helpBlockColumn; var $formGroupSubDivs = $formGroup.find("div"); if ($formGroupSubDivs.length < 3) { diff --git a/javascript/src/QfqPage.js b/javascript/src/QfqPage.js index 02791409a413d0762d60b746eafd5e74133181e4..0b9dae588e107cbf2610f6c93218f937bb087e0d 100644 --- a/javascript/src/QfqPage.js +++ b/javascript/src/QfqPage.js @@ -24,6 +24,7 @@ var QfqNS = QfqNS || {}; * @name QfqNS.QfqPage */ n.QfqPage = function (settings) { + console.log("Creating QFQPage", settings); this.qfqForm = {}; this.settings = $.extend( { @@ -40,6 +41,8 @@ var QfqNS = QfqNS || {}; }, settings ); + n.Log.level = settings.logLevel; + this.intentionalClose = false; try { @@ -117,6 +120,7 @@ var QfqNS = QfqNS || {}; that.qfqForm.releaseLock(true); }; })(this)); + this.recordList = new n.QfqRecordList(settings.apiDeleteUrl); } catch (e) { n.Log.error(e.message); this.qfqForm = null; @@ -125,7 +129,7 @@ var QfqNS = QfqNS || {}; var page = this; // Initialize Fabric to access form events try { - $(".annotate-graphic").each(function() { + $("#" + this.formId + " .annotate-graphic").each(function() { var qfqFabric = new QfqNS.Fabric(); qfqFabric.initialize($(this), page); }); @@ -134,7 +138,7 @@ var QfqNS = QfqNS || {}; } try { - $(".annotate-text").each(function() { + $("#" + this.formId + " .annotate-text").each(function() { var codeCorrection = new QfqNS.CodeCorrection(); codeCorrection.initialize($(this), page); }); @@ -144,7 +148,7 @@ var QfqNS = QfqNS || {}; QfqNS.TypeAhead.install(this.settings.typeAheadUrl); QfqNS.CharacterCount.initialize(); - n.initializeDatetimepicker(false); + //n.initializeDatetimepicker(false); }; /** diff --git a/javascript/src/QfqRecordList.js b/javascript/src/QfqRecordList.js index 364b19537a9ebeb8b0ee50bdaee23e13507e7f93..15c9853466604a5bc6539149e52b8cd112fd8a88 100644 --- a/javascript/src/QfqRecordList.js +++ b/javascript/src/QfqRecordList.js @@ -22,6 +22,7 @@ var QfqNS = QfqNS || {}; * @name QfqNS.QfqRecordList */ n.QfqRecordList = function (deleteUrl) { + console.log("initialized with this url", deleteUrl); this.deleteUrl = deleteUrl; this.deleteButtonClass = 'record-delete'; this.recordClass = 'record'; diff --git a/less/qfq-bs.css.less b/less/qfq-bs.css.less index d0d677d01f9ef4e95df81da32d031696a65c546a..2c36093824b9fc2472a33bfd8e0aaad91c5d9cf3 100644 --- a/less/qfq-bs.css.less +++ b/less/qfq-bs.css.less @@ -851,22 +851,55 @@ span.qfq-typeahead-tag { .alert-interactive { position: fixed; - display: box; + display: block; left: 50%; transform: translate(-50%,0); top: 200px; max-height: 60%; - padding: 20px; - color: #d0d0d0; - min-width: 24%; + padding: 30px 20px 20px; + color: #333; + min-width: 300px; max-width: 90%; - border-left: 5px solid; - background-color: #333; + border: 1px solid; + border-left: 10px solid; + border-radius: 5px; + background-color: #fff; + box-shadow: 10px 10px 10px #ccc; overflow-y: auto; overflow-x: hidden; } -.alert-interactive tr td { +.darkmode .alert-interactive { + color: #d0d0d0; + border-left: 5px solid; + background-color: #333; + } + +.alert-interactive .qfq-alert-reference { + position: absolute; + right: 10px; + top: 3px; + font-size: .7em; + color: #888; +} + +.alert-interactive .qfq-alert-timestamp { + position: absolute; + left: 10px; + top: 3px; + font-size: .7em; + color: #888; +} + +.alert-warning .qfq-alert-timestamp::after { + content: ", "; +} + +.alert-interactive .qfq-alert-hidden { + display: none; +} + +.darkmode .alert-interactive tr td { color: #d0d0d0; } @@ -882,29 +915,70 @@ span.qfq-typeahead-tag { .alert-side { position: fixed; - display: box; + display: block; right: 0px; top: 20px; padding: 20px; + color: #333; + border-top: 1px solid; + border-bottom: 1px solid; + border-left: 10px solid; + background-color: #fff; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + z-index: 10000; /* Always on top */ +} + +.darkmode .alert-side { color: #d0d0d0; - border-left: 5px solid; background-color: #333; - z-index: 10000; /* Always on top */ } .border-success { - border-color: #5cb85c; + border-color: #79AC78; +} + +.border-success .qfq-alert-reference, .border-success .qfq-alert-timestamp { + color: #79AC78; } .border-error { - border-color: #fb4f4f; + border-color: #FF8080; +} + +.border-error .qfq-alert-reference, .border-error .qfq-alert-timestamp { + color: #FF8080; } .border-warning { - border-color: #fbb64f; + border-color: #F9B572; +} + +.border-warning .qfq-alert-reference, .border-warning .qfq-alert-timestamp { + color: #F9B572; } .border-info { + border-color: #7286D3; +} + +.border-info .qfq-alert-reference, .border-info .qfq-alert-timestamp { + color: #7286D3; +} + +.darkmode .border-success { + border-color: #5cb85c; +} + +.darkmode .border-error { + border-color: #fb4f4f; +} + +.darkmode .border-warning { + border-color: #fbb64f; +} + +.darkmode .border-info { border-color: #25adf1; } @@ -1491,6 +1565,48 @@ input.qfq-password { color: #333; } +// FilePond Upload label color +.filepond--item[data-filepond-item-state="idle"] .filepond--item-panel { + background-color:#d7d7d7 !important; +} + +// Filepond css adjustments +.filepond--file-info > .filepond--file-info-sub { + opacity: 0.5 !important; +} + +// Clean filename position +.filepond--file > .filepond--file-info { + transform: translate3d(0px, 0px, 0px) !important; + color: black; +} + +// Text mute for upload elements +label[id^="filepond--drop-label-"] { + color: #6c757d !important; +} + +// Hide complete indicator because its never needed +.filepond--processing-complete-indicator { + opacity: 0 !important; +} + +// Uploaded file background +.filepond--drip{ + background-color:#EDEDED !important; + opacity: 1 !important; +} + +// FilePond upload styling for form +.filepond--drop-label-form { + background-color: white; + border-radius: 5px; +} +.filepond--list-form { + width: 100%; + left: -3px !important; +} + .wiki-toc { background-color: #edeff1; border: 1px solid #e4e4e4; diff --git a/package.json b/package.json index 27a62141a9c00a87574c1e8500294efb2ba53b9c..655410b8f8cae664501a398d230faecf02c67a16 100644 --- a/package.json +++ b/package.json @@ -3,41 +3,58 @@ "version": "1.0.0", "dependencies": { "@fortawesome/fontawesome-free": "^5.15.3", - "bootlint": "^0.14.2", "bootstrap": "^3.3.6", "bootstrap-datetimepicker": "0.0.7", "bootstrap-validator": "^0.11.5", "chart.js": "^2.9.4", - "codemirror": "^5.65.12", + "codemirror": "^5.65.15", + "filepond": "latest", + "filepond-plugin-file-validate-type": "latest", + "filepond-plugin-file-validate-size": "latest", + "filepond-plugin-image-preview": "latest", + "filepond-plugin-image-edit": "latest", + "concat": "^1.0.3", "corejs-typeahead": "^1.3.1", - "eonasdan-bootstrap-datetimepicker": "^4.17.49", "fullcalendar": "^3.10.2", - "grunt": "^1.6.1", - "grunt-concat-in-order": "^0.2.6", - "grunt-contrib-concat": "^1.0.1", - "grunt-contrib-copy": "^1.0.0", - "grunt-contrib-jasmine": "^1.1.0", - "grunt-contrib-jshint": "^1.1.0", - "grunt-contrib-less": "^1.2.0", - "grunt-contrib-uglify": "^2.2.0", - "grunt-contrib-watch": "^1.0.0", - "http-server": "^14.1.1", "jquery": "latest", "jqwidgets-framework": "4.2.1", - "jsdoc": "^3.6.11", - "mocha": "^3.2.0", - "moment": "^2.29.4", + "jshint": "^2.13.6", + "less": "^4.2.0", + "less-plugin-clean-css": "^1.5.1", + "moment": "latest", + "ncp": "^2.0.0", "popper.js": "^1.16.1", - "selenium-webdriver": "^3.3.0", + "selenium-webdriver": "^4.14.0", "should": "^11.2.1", "tablesorter": "^2.31.3", + "terser": "latest", "tinymce": "^4.9.11", "wolfy87-eventemitter": "^4.3.0" }, - "devDependencies": {}, + "jshintConfig": { + "esversion": 6, + "asi": true + }, + "config": { + "js-dir": "extension/Resources/Public/JavaScript/", + "css-dir": "extension/Resources/Public/Css/", + "font-dir": "extension/Resources/Public/fonts/" + }, "scripts": { - "test": "mocha tests/selenium/test*.js" + "test": "mocha tests/selenium/test*.js", + "create-dirs": "mkdir -p js && mkdir -p javascript/build/dist && mkdir -p extension/Resources/Public/JavaScript/ && mkdir -p extension/Resources/Public/Css/ && mkdir -p extension/Resources/Public/fonts/", + "copy": "node javascript/build/copy.js", + "echo": "echo \"$npm_package_config_js_dir\"", + "concat": "concat -o javascript/build/dist/qfq.debug.js javascript/src/Core/QfqEvents.js javascript/src/Core/FormGroup.js javascript/src/*.js javascript/src/Helper/*.js javascript/src/Element/*.js", + "terser": "node javascript/build/terser.js", + "jshint": "jshint javascript/src --exclude javascript/src/Plugins", + "less": "lessc -clean-css less/qfq-bs.css.less less/dist/qfq-bs.css && lessc -clean-css less/qfq-letter.css.less less/dist/qfq-letter.css && lessc -clean-css less/qfq-plain.css.less less/dist/qfq-plain.css && lessc -clean-css less/tablesorter-bootstrap.less less/dist/tablesorter-bootstrap.css", + "prebuild": "npm run jshint && npm run create-dirs", + "build": "npm run concat && npm run less && npm run terser && npm run copy" }, "license": "ISC", - "repository": "https://git.math.uzh.ch/typo3/qfq" + "repository": "https://git.math.uzh.ch/typo3/qfq", + "devDependencies": { + "mocha": "^10.2.0" + } } diff --git a/version b/version index 5c688c6f996eb9d85de2fc1a16104c09c6af144d..16e4d40238a8232ecfa8ee3e49f874f4637fd3ad 100644 --- a/version +++ b/version @@ -1 +1 @@ -23.6.4 +23.10.1