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 a8be0e21a3d3c10f537ea57a595e820867dea4e8..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,7 +30,7 @@ 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_COMMIT_REF_NAME}.zip" - mv qfq_${VERSION}_*.zip qfq_${VERSION}_${RELDATE}-${CI_COMMIT_REF_NAME}.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/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/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/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 23f25081d7dbe32da48a1df36b7b4a450914d4b2..677d91d975dc1ef3710fc0a92d58b9169529c179 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 ^^^^^^^^ @@ -3158,30 +3168,6 @@ To automatically delete slave records, use a form and create `beforeDelete` Form 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 ------------- diff --git a/Documentation/Release.rst b/Documentation/Release.rst index 051c77d211869f7dfde85be9cdddbab32f85c284..0203f61e86520496b7c6ce1f6d5a35f4d3d468ba 100644 --- a/Documentation/Release.rst +++ b/Documentation/Release.rst @@ -262,6 +262,214 @@ Bug Fixes * #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 +-------------- + +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. +* #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.2.0 -------------- diff --git a/Documentation/Report.rst b/Documentation/Report.rst index e4cf107554758fb7c7d76377d573c7b6858d5d73..74ce355f6fb7c0863fe858f51f34db6ac3c785b7 100644 --- a/Documentation/Report.rst +++ b/Documentation/Report.rst @@ -398,8 +398,8 @@ 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.:: @@ -417,6 +417,58 @@ This is equal to:: 10.5.sql = SELECT ... 10.5.head = ... +Nesting of levels: `alias` +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Levels can be nested without levels. E.g.:: + + { + sql = SELECT ... + { + sql = SELECT ... + head = ... + } + } + +This is equal to:: + + 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 ^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -532,7 +584,20 @@ Example 'level':: 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: +-------------+------------------------------------------------------------------------------------------------------------------------+ @@ -546,6 +611,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. | @@ -712,6 +779,8 @@ Summary: +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |_decrypt |:ref:`column-decrypt` - Decrypt value. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +|_jwt |:ref:`column-jwt` - generates a json web token from the provided data. | ++------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ .. _column-link: @@ -1802,6 +1871,38 @@ Decrypting selected columns or strings which are encrypted with QFQ. 10.sql = SELECT secret AS _decrypt FROM Person WHERE id = 1 +.. _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: Copy to clipboard diff --git a/Documentation/Variable.rst b/Documentation/Variable.rst index 12b6552e24d8e900a60e6e21bf92f154130c028f..423adc417caa87f3d9ed69cced48d99ea7635824 100644 --- a/Documentation/Variable.rst +++ b/Documentation/Variable.rst @@ -410,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. diff --git a/Makefile b/Makefile index af23e110dc44860f4ea30f1f9ecc97f955a88046..86c102eec88d07024354f6d5ef3b942ef27b10b0 100644 --- a/Makefile +++ b/Makefile @@ -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/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/Constants.php b/extension/Classes/Core/Constants.php index 30d1bc71d847793010e25b2f765ff44ada36297f..b1f928076006df7c8b94a5578c3823612089ec87 100644 --- a/extension/Classes/Core/Constants.php +++ b/extension/Classes/Core/Constants.php @@ -254,6 +254,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; @@ -523,6 +524,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; @@ -769,6 +771,7 @@ 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#'; @@ -1668,6 +1671,7 @@ 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'; @@ -1776,6 +1780,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'; @@ -1864,6 +1871,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'; @@ -2215,6 +2224,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'; 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/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/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/OnString.php b/extension/Classes/Core/Helper/OnString.php index 4a22778881710a77760cad737a20bc87086a4bdb..87c099be40a5747bcaa64576a117903e7b74ba97 100644 --- a/extension/Classes/Core/Helper/OnString.php +++ b/extension/Classes/Core/Helper/OnString.php @@ -811,7 +811,7 @@ class OnString { return array(); } - // Search biggest element. + // Search the biggest element. foreach ($arr as $key => $value) { if (is_array($value)) { @@ -858,7 +858,7 @@ class OnString { 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); + $arrNew = self::limitSizeJsonEncode($arrNew, $newLen, $maxValue); } } } 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 095326334c744cec4cebf35cbc01d56381e18f9b..34855b931a7e55ab27ea89beb3d3540be76a4495 100644 --- a/extension/Classes/Core/QuickFormQuery.php +++ b/extension/Classes/Core/QuickFormQuery.php @@ -172,6 +172,20 @@ 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); @@ -603,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; } } @@ -1578,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. ]; diff --git a/extension/Classes/Core/Report/Report.php b/extension/Classes/Core/Report/Report.php index b9c0c460dddda5e68218963dc4d5f3fa40982920..983b6a34bd274f23340f45fe25c80353406f4c42 100644 --- a/extension/Classes/Core/Report/Report.php +++ b/extension/Classes/Core/Report/Report.php @@ -34,10 +34,15 @@ 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'; const DEFAULT_BOOTSTRAP_BUTTON = 'bootstrapButton'; @@ -323,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; @@ -575,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]); @@ -1341,6 +1371,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); 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/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/Store.php b/extension/Classes/Core/Store/Store.php index bcde3238da1791ff7d55c1dfb9c26372dfca6634..37b8cf83c3075747291700d0eedc38d29a1bdb06 100644 --- a/extension/Classes/Core/Store/Store.php +++ b/extension/Classes/Core/Store/Store.php @@ -519,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/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/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/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/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/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..be32013f560e6fa6bf04c5bedcc9ae2fdf879032 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" diff --git a/extension/ext_conf_template.txt b/extension/ext_conf_template.txt index a0e4908e21f6dccd33aa3062927fa1efce5e712b..677b072fd45c842303994dbe66a4cdc2ea782135 100644 --- a/extension/ext_conf_template.txt +++ b/extension/ext_conf_template.txt @@ -133,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_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..ee8e9c56826bebec5bef876b10a9fd0ca15022fa --- /dev/null +++ b/javascript/build/copy.js @@ -0,0 +1,183 @@ +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 + } + ] + } +] + +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..109f4fccbaeb75a99c70219314604990a2cabd1f --- /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/lib/codemirror.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/Element/FormGroup.js b/javascript/src/Core/FormGroup.js similarity index 100% rename from javascript/src/Element/FormGroup.js rename to javascript/src/Core/FormGroup.js 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/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 ae20acd042bed4be583f27f52429e88375a1cd2d..3c9d5820699162de408a1dab38d4a015e8d518f1 100644 --- a/javascript/src/Main.js +++ b/javascript/src/Main.js @@ -31,7 +31,6 @@ $(document).ready( function () { var collection = document.getElementsByClassName("qfq-form"); - console.log(collection); var qfqPages = []; for (const form of collection) { const page = new n.QfqPage(form.dataset); 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/QfqForm.js b/javascript/src/QfqForm.js index 0a26cb5d6cee905a14460516281f87bd95973989..fae0ac550e00509376f3e34c188b987b99d30fec 100644 --- a/javascript/src/QfqForm.js +++ b/javascript/src/QfqForm.js @@ -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"; diff --git a/less/qfq-bs.css.less b/less/qfq-bs.css.less index 02f2ca35ef968117d23ba3a2eb0c79ff151acf5a..2f34ff5d29f7c4cb02260a7c63354a3bac013905 100644 --- a/less/qfq-bs.css.less +++ b/less/qfq-bs.css.less @@ -327,7 +327,7 @@ i.@{spinner_class} { background-repeat: repeat-x; border-color: #d30b6f; font-weight: 200; - text-shadow: 0 1px 0 #e72a8; + text-shadow: #e72a89 0 1px 0; color: #333; } diff --git a/package.json b/package.json index e3cc91bd7f685c1463ce0ab08e85b7d45f7e8ac6..655410b8f8cae664501a398d230faecf02c67a16 100644 --- a/package.json +++ b/package.json @@ -13,31 +13,44 @@ "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-watch": "^1.0.0", - "grunt-terser": "^2.0.0", "jquery": "latest", "jqwidgets-framework": "4.2.1", - "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": "^4.14.0", "should": "^11.2.1", "tablesorter": "^2.31.3", - "terser": "^5.21.0", + "terser": "latest", "tinymce": "^4.9.11", "wolfy87-eventemitter": "^4.3.0" }, + "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",