diff --git a/Documentation/Form.rst b/Documentation/Form.rst index 295e8cee801e05a62018e9127a9c8395f8956b5a..bb628e33185ae3a7be9cbec6914c2953bff7c561 100644 --- a/Documentation/Form.rst +++ b/Documentation/Form.rst @@ -238,7 +238,7 @@ Form Settings +-------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------+ |multiDetailForm | NOT IMPLEMENTED - Optional. Form to open, if a record is selected to edit (double click on record line) | +-------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------+ -|multiDetailFormParameter | NOT IMPLEMENTED - Optional. Translated Parameter submitted to detailform (like subrecord parameter) | +|multiDetailFormParameter | NOT IMPLEMENTED - Optional. Translated Parameter submitted to detail form (like subrecord parameter) | +-------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------+ .. _`form-permitNewEdit`: @@ -271,7 +271,7 @@ Depending on `r`, the following access permission will be taken: * is *always* the preferred way. With 'sip' it's not necessary to differ between logged in or not, cause the SIP only exist and is only valid, if it's created via QFQ/report earlier. This means 'creating' the SIP implies - 'access granted'. The grant will be revoked when the QFQ session is destroyed - this happens when a user loggs out or + 'access granted'. The grant will be revoked when the QFQ session is destroyed - this happens when a user logs out or the web browser is closed. * `logged_in` / `logged_out`: for forms which might be displayed without a SIP, but maybe on a protected or even @@ -449,7 +449,7 @@ Form.parameter +-----------------------------+--------+----------------------------------------------------------------------------------------------------------+ | typeAheadLdapSearch | string | Regular LDAP search expression. E.g.: `(|(cn=*?*)(mail=*?*))` | +-----------------------------+--------+----------------------------------------------------------------------------------------------------------+ -| typeAheadLdapValuePrintf | string | Value formatting of LDAP result, per entry. E.g.: `'%s / %s / %s', mail, roomnumber, telephonenumber` | +| typeAheadLdapValuePrintf | string | Value formatting of LDAP result, per entry. E.g.: `'%s / %s / %s', mail, room number, telephone number` | +-----------------------------+--------+----------------------------------------------------------------------------------------------------------+ | typeAheadLdapIdPrintf | string | Key formatting of LDAP result, per entry. E.g.: `'%s', mail` | +-----------------------------+--------+----------------------------------------------------------------------------------------------------------+ @@ -678,9 +678,9 @@ Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=readonly|s|t:View| * The form is called with SIP parameter ``formModeGlobal=readonly`` or ``form.parameter.mode=readonly``. * The user can't change any data. -*Readonly systemwide* +*Readonly system wide* -Code (somewhere): ``SELECT 'requiredoff' AS '_=formModeGlobal'`` +Code (somewhere): ``SELECT 'requiredOff' AS '_=formModeGlobal'`` Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1|s|t:View|s|b' AS _link`` @@ -690,7 +690,7 @@ Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1|s|t:View|s|b' AS _link`` *Draft Mode 1* -Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=readquiredOff|s|t:View|s|b' AS _link`` +Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=requiredOff|s|t:View|s|b' AS _link`` * A form has one or more FormElement with 'fe.type=required'. * Opening the form with `formModeGlobal=requiredOff` will allow the user to save the form, even if not all @@ -700,7 +700,7 @@ Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=readquiredOff|s|t: *Draft Mode 2* -Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=readquiredOff|s|t:View|s|b' AS _link`` +Code: ``SELECT 'p:{{pageSlug}}?form=person&r=1&formModeGlobal=requiredOff|s|t:View|s|b' AS _link`` * A form has one or more FormElement with 'fe.type=required'. * Calling the form with `formModeGlobal=requiredOff` will allow the user to save the form, even if not all @@ -746,6 +746,16 @@ Type: fieldset * *name*: technical name, used as HTML identifier. * *label*: Shown title of the fieldset. + * *mode*: + + * `show`: all child elements will be shown. + * `required`: all child elements are also set to 'required'. + * `readonly`: technically it's like HTML/CSS `disabled`. + * `hidden`: + + * The fieldset is invisible. + * The `FormElements` within the fieldset still exist, but are not reachable for the user via UI. + * *parameter*: * *fieldsetClass*: Overwrite default from `Form.parameter.fieldsetClass` @@ -764,7 +774,7 @@ Type: pill (tab) * `show`: all child elements will be shown. * `required`: same as 'show'. This mode has no other meaning than 'show'. - * `readonly`: technical it's like HTML/CSS `disabled`. + * `readonly`: technically it's like HTML/CSS `disabled`. * The pill title is shown, but not clickable. * The `FormElements` on the pill still exist, but are not reachable for the user via UI. @@ -908,7 +918,7 @@ Fields: | | 'beforeInsert', 'beforeUpdate', 'beforeDelete', 'afterLoad', 'afterSave', 'afterInsert', 'afterUpdate', 'afterDelete', | | | 'sendMail') | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ -|Encode | 'none', 'specialchar' | With 'specialchar' (default) the chars <>"'& will be encoded to their htmlentity. _`field-encode` | +|Encode | 'none', 'specialchar' | With 'specialchar' (default) the chars <>"'& will be encoded to their html entity. _`field-encode` | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ |Check Type | enum('auto', 'alnumx', | See: :ref:`sanitize-class` | | | 'digit', 'numerical', | | @@ -945,7 +955,7 @@ Fields: +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ |value | text | Default value: See :ref:`field-value` | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ -|sql1 | text | SQL query. See individual `FormEelement`. _`sql1` | +|sql1 | text | SQL query. See individual `FormElement`. _`sql1` | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ |Parameter | text | Might contain misc parameter. See :ref:`fe-parameter-attributes` | +---------------------+-----------------------------+-----------------------------------------------------------------------------------------------------+ @@ -1133,13 +1143,15 @@ FormElement.parameter +---------------------------------+ | | expectRecords | | +---------------------------------+ | -| messageFail | | +| alert | | ++---------------------------------+ | +| qfqLog | | +---------------------------------+----------------------------------------------------------------------------------------------------------+ | dataReference | Optional. See :ref:`applicationTest` | +---------------------------------+----------------------------------------------------------------------------------------------------------+ | requiredPosition | See :ref:`requiredPosition`. | +---------------------------------+----------------------------------------------------------------------------------------------------------+ -| indicateRequired | By default, indicate 'required' by an asterix. indicateRequired=0 will hide the asterix. Default: 1 | +| indicateRequired | By default, indicate 'required' by an asterisk. indicateRequired=0 will hide the asterisk. Default: 1 | +---------------------------------+----------------------------------------------------------------------------------------------------------+ | minWidth | See :ref:`checkboxRadioMinWidth`. | +---------------------------------+----------------------------------------------------------------------------------------------------------+ @@ -1191,8 +1203,8 @@ extraButtonLock extraButtonPassword ;;;;;;;;;;;;;;;;;;; -* The user has to click on the eye (unhide) to see the value. -* After Form load, the data is hidden by asteriks. +* The user has to click on the eye (un-hide) to see the value. +* After Form load, the data is hidden by asterisk. * Shows an 'eye' on the right side of an input element of type `text`, `date`, `time` or `datetime`. * There is no value needed for this parameter. @@ -1240,8 +1252,8 @@ might be defined per Form or per FormElement. Required Position ^^^^^^^^^^^^^^^^^ -By default, input elements with `Mode=required` will be displayed with a 'red asterix' right beside the label. The position -of the 'red asterix' can be choosen via the `parameter` field:: +By default, input elements with `Mode=required` will be displayed with a 'red asterisk' right beside the label. The position +of the 'red asterisk' can be chosen via the `parameter` field:: requiredPosition = label-left|label-right|input-left|input-right|note-left|note-right @@ -1303,8 +1315,8 @@ Checkboxes can be rendered in mode: * *emptyHide*: Existence of this item hides an entry with an empty string. This is useful for e.g. Enums, which have an empty entry, but the empty value should not be selectable. - * *emptyItemAtStart*: Existence of this item inserts an empty entry at the beginning of the selectlist. - * *emptyItemAtEnd*: Existence of this item inserts an empty entry at the end of the selectlist. + * *emptyItemAtStart*: Existence of this item inserts an empty entry at the beginning of the select list. + * *emptyItemAtEnd*: Existence of this item inserts an empty entry at the end of the select list. * *buttonClass*: Instead of the plain HTML checkbox fields, Bootstrap `buttons <http://getbootstrap.com/docs/3.4/javascript/#buttons-checkbox-radio>`_. are rendered as `checkbox` elements. Use one of the following `classes <http://getbootstrap.com/docs/3.4/css/#buttons-options>`_: @@ -1621,7 +1633,7 @@ Type: editor * *FormElement.checktype* * *all*: The only useful setting for Editor. HTML tags might contain ``% ' " < >`` and so on. This is **dangerous** - due of potential inserted malicous code! But there is no other option, cause the HTML tags are required. + due of potential inserted malicious code! But there is no other option, cause the HTML tags are required. * All configuration and plugins will be configured via the 'parameter' field. Just prepend the word 'editor-' in front of each TinyMCE keyword. Check possible options under: @@ -1688,7 +1700,7 @@ can be shown in edit (and might be modified) or in readonly mode. Two modes are available: grafic - A simple grafic editor to paint on top of the image (best by a tablet with pen or grafic tablet). The uploaded image + A simple graphic editor to paint on top of the image (best by a tablet with pen or graphic tablet). The uploaded image is shown in the background. All drawings are saved as a JSON fabric.js data string. Supported file types: **png, svg**. PDF files can be easily divided into per page SVG files during upload - see :ref:`split-pdf-upload` @@ -1706,7 +1718,7 @@ Grafic """""" An image, specified by ``FormElement.parameter.imageSource={{pathFileName}}``, will be displayed in the background. On -form load, both, the image and an optional already given grafical annotations, will be displayed. The image is SIP +form load, both, the image and an optional already given graphical annotations, will be displayed. The image is SIP protected and will be loaded on demand. **Form.parameter** @@ -1809,7 +1821,7 @@ Type: radio * *vertical* or *horizontal* alignment: * `<value>`: '', 0, 1 - The radios will be aligned *vertical*. - * `<value>`: >1 - The readios will be aligned *horizontal*, with a linebreak every 'value' elements. + * `<value>`: >1 - The radios will be aligned *horizontal*, with a linebreak every 'value' elements. * *FormElement.parameter*: @@ -1872,8 +1884,8 @@ Type: select * *FormElement.parameter*: - * *emptyItemAtStart*: Existence of this item inserts an empty entry at the beginning of the selectlist. - * *emptyItemAtEnd*: Existence of this item inserts an empty entry at the end of the selectlist. + * *emptyItemAtStart*: Existence of this item inserts an empty entry at the beginning of the select list. + * *emptyItemAtEnd*: Existence of this item inserts an empty entry at the end of the select list. * *emptyHide*: Existence of this item hides the empty entry. This is useful for e.g. Enums, which have an empty entry and the empty value should not be an option to be selected. * *datalist*: Similar to 'typeAhead'. Enables the user to select a predefined option (sql1, itemList) or supply any @@ -1967,7 +1979,7 @@ will be rendered inside the form as a HTML table. * Exceptions of the default behaviour have to be defined on the target form in the corresponding *FormElement* in the field *value* by changing the default Store priority definition. E.g. `{{<columnName>:RS0}}` - For existing records, the store `R` will provide a value. For new records, store `R` is empty and store S will be searched for a value: - the value defined in `detail` will be choosen. At last the store '0' is defined as a fallback. + the value defined in `detail` will be chosen. At last the store '0' is defined as a fallback. * *source table column name*: E.g. A person form is opened with person.id=5 (r=5). The definition `detail=id:personId` and `form=address` maps person.id to address.personId. On the target record, the column personId becomes '5'. * *Constant '&'*: Indicate a 'constant' value. E.g. `&12:xId` or `{{...}}` (all possibilities, incl. further SELECT @@ -2028,7 +2040,7 @@ The following parameters can be used in the `parameter` field to customize/activ * *orderColumn*: The dedicated order column in the specified dndTable (needs to match a column in the table definition). Default is `ord`. - * To switch off Drag'n' Drop, specify a non existing columnname. E.g.: `orderColumn=off` + * To switch off Drag 'n' Drop, specify a non existing columnname. E.g.: `orderColumn=off` If `dndTable` is a table with a column `orderColumn`, QFQ automatically applies drag-and-drop logic to the rendered subrecord. It does so by using the subrecord field *sql1*. The `sql1` query should @@ -2105,12 +2117,17 @@ and will be processed after saving the primary record and before any action Form See also :ref:`download Button<downloadButton>` to offer a download of an uploaded file. +Per default the new upload version 2 with drag & drop feature is used. Use following FormElement.parameter to switch to old upload version.:: + + uploadType=v1 + + FormElement.parameter """"""""""""""""""""" * *fileButtonText*: Overwrite default ‘Choose File’ * *capture* = `camera` - On a smartphone, after pressing the 'open file' button, the camera will be opened and a - choosen picture will be uploaded. Automatically set/overwrite `accept=image/*`. + chosen picture will be uploaded. Automatically set/overwrite `accept=image/*`. * *accept* = `<mime type>,image/*,video/*,audio/*,.doc,.docx,.pdf` @@ -2120,7 +2137,7 @@ FormElement.parameter * One or more media types might be specified, separated by ','. * Different browser respect the given definitions in different ways. Typically the 'file choose' dialog offer: - * the specified mime type (some browers only show 'custom', if more than one mime type is given), + * the specified mime type (some browsers only show 'custom', if more than one mime type is given), * the option 'All files' (the user is always free to **try** to upload other file types) - but the server won't accept them, * the 'file choose' dialog only offers files of the selected (in the dialog) type. @@ -2136,7 +2153,7 @@ FormElement.parameter * *fileTrashText* = `<string>` - Default: ''. Will be shown right beside the trash glyph-icon. * *fileDestination* = `<pathFileName>` - Destination where to copy the file. A good practice is to specify a relative `fileDestination` - - such an installation (filesystem and database) are moveable. + such an installation (filesystem and database) are movable. * If the original filename should be part of `fileDestination`, the variable *{{filename}}* (see :ref:`STORE_VARS`) can be used. Example :: @@ -2145,7 +2162,7 @@ FormElement.parameter * Several more variants of the filename and also mimetype and filesize are available. See :ref:`STORE_VARS`. - * The original filename will be sanitized: only '<alnum>', '.' and '_' characters are allowed. German 'umlaut' will + * The original filename will be sanitized: only '<alnumx>', '.' and '_' characters are allowed. German 'Umlaut' will be replaced by 'ae', 'ue', 'oe'. All non valid characters will be replaced by '_'. * If a file already exist under `fileDestination`, an error message is shown and 'save' is aborted. The user has no @@ -2199,8 +2216,8 @@ FormElement.parameter numeric mode is allowed. Will be applied to all new created directories. * *autoOrient:* images might contain EXIF data (e.g. captured via mobile phones) incl. an orientation tag like TopLeft, - BottomRight and so on. Web-Browser and other grafic programs often understand and respect those information and rotate - such images automatically. If not, the image might be displayed in an unwanted oritentation. + BottomRight and so on. Web-Browser and other graphic programs often understand and respect those information and rotate + such images automatically. If not, the image might be displayed in an unwanted orientation. With active option 'autoOrient', QFQ tries to normalize such images via 'convert' (part of ImageMagick). Especially if images are processed by the QFQ internal 'Fabric'-JS it's recommended to normalize images first. The normalization process does not solve all orientation problems. @@ -2232,7 +2249,7 @@ FormElement.parameter fileUnzip sqlValidate ={{! SELECT '' FROM (SELECT '') AS fake WHERE '{{mimeType:V}}' LIKE 'application/pdf%' }} expectRecords=1 - messageFail=Unexpected filetype + alert=Unexpected filetype # Set new sqlAfter={{INSERT INTO Upload (pathFileName) VALUES '{{filename:V}}' }} @@ -2297,7 +2314,7 @@ On form load, the column value will be displayed as the whole value (pathFileNam Deleting an uploaded file in the form (by clicking on the trash near beside) will delete the file on the filesystem as well. The column will be updated to an empty string. -This happens automatically without any further definiton in the 'upload'-FormElement. +This happens automatically without any further definition in the 'upload'-FormElement. Multiple 'upload'-FormElements per form are possible. Each of it needs an own table column. @@ -2331,15 +2348,15 @@ with 'my', e.g. 'myUpload1'. * *sqlBefore* = `{{<query>}}` - fired during a form save, before the following queries are fired. - * *sqlInsert* = `{{<query>}}` - fired if `slaveId=0` and an upload exist (user has choosen a file):: + * *sqlInsert* = `{{<query>}}` - fired if `slaveId=0` and an upload exist (user has chosen a file):: sqlInsert={{INSERT INTO Note (pId, type, pathFileName) VALUE ({{id:R0}}, 'image', '{{fileDestination}}') }} - * *sqlUpdate* = `{{<query>}}` - fired if `slaveId>0` and an upload exist (user has choosen a file). E.g.:: + * *sqlUpdate* = `{{<query>}}` - fired if `slaveId>0` and an upload exist (user has chosen a file). E.g.:: sqlUpdate={{UPDATE Note SET pathFileName = '{{fileDestination}}' WHERE id={{slaveId}} LIMIT 1}} - * *sqlDelete* = `{{<query>}}` - fired if `slaveId>0` and no upload exist (user has not choosen a file). E.g.:: + * *sqlDelete* = `{{<query>}}` - fired if `slaveId>0` and no upload exist (user has not chosen a file). E.g.:: sqlDelete={{DELETE FROM Note WHERE id={{slaveId:V}} LIMIT 1}} @@ -2370,7 +2387,7 @@ file type. * [jpeg] - default: `-density 150 -quality 90` * *fileDestinationSplit* = `<pathFileName (pattern)>` - Target directory and filename pattern for the created & - split'ed files. Default <fileDestination>.split/split.<nr>.<fileSplit>. + split files. Default <fileDestination>.split/split.<nr>.<fileSplit>. If explicit given, respect that SVG needs a printf style for <nr>, whereas JPEG is numbered automatically. E.g. :: [svg] fileDestinationSplit = fileadmin/protected/{{id:R}}.{{filenameBase:V}}.%02d.svg @@ -2390,14 +2407,14 @@ Table 'Split': +==============+============================================================================================+ | id | Uniq auto increment index | +--------------+--------------------------------------------------------------------------------------------+ -| tableName | Name of the table, where the reference to the original file (multipage PDF file) is saved. | +| tableName | Name of the table, where the reference to the original file (multi page PDF file) is saved. | +--------------+--------------------------------------------------------------------------------------------+ | xId | Primary id of the reference record. | +--------------+--------------------------------------------------------------------------------------------+ | pathFileName | Path/filename reference to one of the created files | +--------------+--------------------------------------------------------------------------------------------+ -One usecase why to split an upload: annotate individual pages by using the `FormElement`.type=`annotate`. +One use case why to split an upload: annotate individual pages by using the `FormElement`.type=`annotate`. .. _class-action: @@ -2432,7 +2449,7 @@ FormElement.parameter: sqlValidate * OK: the `expectRecords` number of records has been selected. Continue processing the next *FormElement*. * Fail: the `expectRecords` number of records has not been selected (less or more): Display the error message - `messageFail` and abort the whole (!) current form load or save. + `alert` and abort the whole (!) current form load or save. *FormElement.parameter*: @@ -2447,7 +2464,30 @@ FormElement.parameter: sqlValidate * *expectRecords* = `0` or *expectRecords* = `0,1` or *expectRecords* = `{{SELECT COUNT(id) FROM Person}}` * Separate multiple valid record numbers by ','. If at least one of those matches, the check will pass successfully. -* *messageFail* = `<string>` - Message to show. E.g.: *messageFail* = `There is already a person called {{firstname:F:all}} {{name:F:all}}` +* *qfqLog* = `<value>` - determines if the error should be logged. + + * *qfqLog* and *qfqLog* = `1` (default) - error will be logged in both cases. + * *qfqLog* = `0` - no error will be logged. + +* *alert* = `<alert text>[:<level>[:<ok button text>[:<force button text>[:<timeout>[:<flag modal>]]]]]` + + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Parameter | Description | + +======================+==========================================================================================================================+ + | Text | The text shown by the alert. HTML is allowed to format the text. Any ':' needs to be escaped. | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Level | info (default), success, warning, danger/error | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Ok button text | Default: 'Ok'. Closes the alert. | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Force button text | Forces a save of the form in case *expectRecords* fails. | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Timeout in seconds | Default: 0, no timeout. > 0, after the specified time in seconds, the alert will disappear (no forced save). | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + | Flag modal | Default: 1, alert behaves modal. 0, alert does not behave modal and appears on the side. | + +----------------------+--------------------------------------------------------------------------------------------------------------------------+ + +* *messageFail* = `<string>` - (Deprecated) Message to show. E.g.: *messageFail* = `There is already a person called {{firstname:F:all}} {{name:F:all}}` .. _slave-id: @@ -2744,7 +2784,7 @@ record (defined by `multiSql`). +------------------+----------------------------------+------------------------------------------------+ | Name | | | +==================+==================================+================================================+ -| multiSql | {{!SELECT id, name FROM Person}} | Query to select MulitForm records | +| multiSql | {{!SELECT id, name FROM Person}} | Query to select MultiForm records | +------------------+----------------------------------+------------------------------------------------+ | multiMgsNoRecord | Default: No data | Message shown if `multiSql` selects no records | +------------------+----------------------------------+------------------------------------------------+ @@ -2775,7 +2815,7 @@ The checkbox in the header selects all/none rows at once. * *processRow* = `<string>` - the value displayed in table header next to the checkbox. -* `Form.mulitSql`: If there is a column `_processRow`, value of 0/1 per row will control unchecked/checked during form load. +* `Form.multiSql`: If there is a column `_processRow`, value of 0/1 per row will control unchecked/checked during form load. Implicit Multi Form mode ^^^^^^^^^^^^^^^^^^^^^^^^ @@ -2914,7 +2954,7 @@ Dynamic Update -------------- The 'Dynamic Update' feature makes a form more interactive. If a user changes a *FormElement* who is tagged with -'dynamicUpdate', *all* elements who are tagged with 'dynamicUpdate', will be recalculated and rerendered. +'dynamicUpdate', *all* elements who are tagged with 'dynamicUpdate', will be recalculated and re-rendered. The following fields will be recalculated during 'Dynamic Update' @@ -3092,7 +3132,7 @@ form with the following parameter * FormElement 1: Record id of the source record. * Name: `idSrc` - * Lable: `Source Form` + * Label: `Source Form` * Class: `native` * Type: `select` * sql1: `{{! SELECT id, title FROM Basket }}` @@ -3112,7 +3152,7 @@ form with the following parameter * `sqlValidate={{!SELECT f.id FROM Form AS f WHERE f.name LIKE '{{myName:FE:alnumx}}' LIMIT 1}}` * `expectRecords = 0` - * `messageFail = There is already a form with this name` + * `alert = There is already a form with this name` * `sqlAfter={{DELETE FROM Clipboard WHERE cookie='{{cookieQfq:C0:alnumx}}' }}` * FormElement 4: Update the clipboard source reference, with current {{cookieQfq:C}} identifier. @@ -3181,7 +3221,7 @@ To automatically delete slave records, use a form and create `beforeDelete` Form * class: action * type: beforeDelete - * parameter: sqlAfter={{DELETE FROM <slaveTable> WHERE <slaveTable>.<masteId>={{id:R}} }} + * parameter: sqlAfter={{DELETE FROM <slaveTable> WHERE <slaveTable>.<masterId>={{id:R}} }} You might also check the form 'form' how the slave records 'FormElement' will be deleted. @@ -3224,7 +3264,7 @@ Example: :: FormElement.name = technicalContact Form.parameter.fillStoreVar = {{! SELECT CONCAT(p.firstName, ' ', p.name) AS technicalContact FROM Person AS p WHERE p.account='{{feUser:T}}' }} -What we use here is the default STORE prio FSRVD. If the form loads with r=0, 'F', 'S' and 'R' are empty. 'V' is filled. +What we use here is the default STORE prioritized FSRVD. If the form loads with r=0, 'F', 'S' and 'R' are empty. 'V' is filled. If r>0, than 'F' and 'S' are empty and 'R' is filled. Method 2 @@ -3792,7 +3832,7 @@ The JSON form editor allows developers to view/edit/copy/paste forms in the json * All fields of the Form and the FormElements Table are encoded into one big JSON object. Each formElement is represented as an object contained in the top-level array called `FormElement_ff`. * Form and FormElement **ids are not encoded into the json string**. Therefore the json may be freely copied and pasted i.e. reused without fear of overwriting the original form. - * **Container** : Container FormElements are referenced via their name instead of their id by other FormElements. The additional key `containerName_ff` is added to the JSON of a FormElemnt to reference a container. + * **Container** : Container FormElements are referenced via their name instead of their id by other FormElements. The additional key `containerName_ff` is added to the JSON of a FormElement to reference a container. * **Form Backups** : If a form is edited using the JSON form editor then a backup of the previous version is saved in the directory `form/.backup` inside the qfq project directory (:ref:`qfq-project-path-php`). diff --git a/Documentation/License.rst b/Documentation/License.rst index 42eaaa93d76bc856339885fece2a0b5954d1d979..b192f4c194666c91eaae1637d69f4c871429cc30 100644 --- a/Documentation/License.rst +++ b/Documentation/License.rst @@ -61,3 +61,4 @@ Software distributed together with QFQ * Datetimepicker - https://getdatepicker.com/ * HTMLPurifier - https://github.com/ezyang/htmlpurifier * Font Password-Dots - https://fontstruct.com/fontstructions/show/1106896 The FontStruction “Password Dots†by “JimProuty†is licensed under a Creative Commons Attribution license (http://creativecommons.org/licenses/by/3.0/). +* Filepond - https://github.com/pqina/filepond diff --git a/Documentation/Report.rst b/Documentation/Report.rst index 92c69a968976db27c86dc763904ecab99eed268f..5bcadd94b8c05a710001f2f02ec386685d43111a 100644 --- a/Documentation/Report.rst +++ b/Documentation/Report.rst @@ -287,7 +287,7 @@ Reserved names -------------- The following names have a special meaning in QFQ/Typo3. It is recommended to use such names only in the meaning of -QFQ/Typo3 and not to use them as free defineable user variables. +QFQ/Typo3 and not to use them as free definable user variables. ``id``, ``type``, ``L``, ``form``, ``r`` @@ -781,6 +781,8 @@ Summary: +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |_decrypt |:ref:`column-decrypt` - Decrypt value. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +|_upload |:ref:`column-upload` - Upload field with drag and drop and file browser. | ++------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ |_jwt |:ref:`column-jwt` - generates a json web token from the provided data. | +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ @@ -1004,15 +1006,15 @@ Alert: Question +======================+==========================================================================================================================+ | Text | The text shown by the alert. HTML is allowed to format the text. Any ':' needs to be escaped. Default: 'Please confirm'. | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ -| Level | success, info, warning, danger | +| Level | info (default), success, warning, danger/error | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ | Positive button text | Default: 'Ok' | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ | Negative button text | Default: 'Cancel'. To hide the second button: '-' | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ -| Timeout in seconds | 0: no timeout, >0: after the specified time in seconds, the alert will dissapear and behaves like 'negative answer' | +| Timeout in seconds | Default: 0, no timeout. > 0, after the specified time in seconds, the alert disappears ('negative answer'). | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ -| Flag modal | 0: Alert behaves not modal. 1: (default) Alert behaves modal. | +| Flag modal | Default: 1, alert behaves modal. 0, Alert does not behave modal and appears on the side. | +----------------------+--------------------------------------------------------------------------------------------------------------------------+ Examples: @@ -1037,7 +1039,7 @@ Text before / after link * Renders text before and/or after a link. * Example: ``SELECT 'p:{{pageAlias:T}}|t:Reload|v:Some text before |V: some text after' AS _link`` -* A typical usecase is to get several ``AS _link`` columns in one HTML table cell, by still using ``fbeg,fend``:: +* A typical use case is to get several ``AS _link`` columns in one HTML table cell, by still using ``fbeg,fend``:: 10 { sql = SELECT p.id @@ -1054,6 +1056,16 @@ Text before / after link } +.. _ignoreHistory: + +Ignore/Skip browser history +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To ignore/skip adding a browser history entry when clicking on a link, add the attribute `data-ignore-history`:: + + 'data-ignore-history' + Example: 10.sql = SELECT 'p:{{pageSlug:T}}?form=test_form&r=2&variable1=test|s|b|t:Button|A:data-ignore-history' AS _link + .. _column_pageX: @@ -1076,9 +1088,9 @@ The colum name is composed of the string *page* and a trailing character to spec +---------------+-----------------------------------------------+-------------------------------------+----------------------------------------------+ | column name | Purpose |default value of question parameter | Mandatory parameters | +===============+===============================================+=====================================+==============================================+ -|_page |Internal link without a grafic |empty |p:<pageSlug>[?param] | +|_page |Internal link without a graphic |empty |p:<pageSlug>[?param] | +---------------+-----------------------------------------------+-------------------------------------+----------------------------------------------+ -|_pagec |Internal link without a grafic, with question |*Please confirm!* |p:<pageSlug>[?param] | +|_pagec |Internal link without a graphic, with question |*Please confirm!* |p:<pageSlug>[?param] | +---------------+-----------------------------------------------+-------------------------------------+----------------------------------------------+ |_paged |Internal link with delete icon (trash) |*Delete record ?* | | U:form=<formname>&r=<record id> *or* | | | | | | U:table=<tablename>&r=<record id> | @@ -1138,7 +1150,7 @@ These column offers a link, with a confirmation question, to delete one record ( If the record to delete contains column(s), whose column name match on ``%pathFileName%`` and such a column points to a real existing file, such a file will be deleted too. If the table contains records where the specific file is multiple times referenced, than the file is not deleted (it would break the still existing references). Multiple -references are not found, if they use different colummnnames or tablenames. +references are not found, if they use different column names or table names. Mode: table """"""""""" @@ -1199,7 +1211,7 @@ Use instead :ref:`vertical-column-title` .. warning:: The '... AS _vertical' is deprecated - do not use it anymore. -Render text vertically. This is useful for tables with limited column width. The vertical rendering is achieved via CSS tranformations +Render text vertically. This is useful for tables with limited column width. The vertical rendering is achieved via CSS transformations (rotation) defined in the style attribute of the wrapping tag. You can optionally specify the rotation angle. **Syntax** :: @@ -1404,8 +1416,8 @@ The following options are provided to attach files to an email: | d | d:myfile.pdf | Name of the attachment in the email. | +-------+------------------------------------------------------+--------------------------------------------------------+ | C | C|u:http://www.example.com|F:file1.pdf|C|F:file2.pdf | Concatenate all named sources to one PDF file. The | -| | | souces has to be PDF files or a web page, which will be| -| | | converted to a PDF first. | +| | | sources have to be PDF files or a web page, which will | +| | | be converted to a PDF first. | +-------+------------------------------------------------------+--------------------------------------------------------+ Any combination (incl. repeating them) are possible. Any source will be added as a single attachment. @@ -1465,9 +1477,9 @@ Renders images. Allows to define an alternative text and a title attribute for t **Advanced Examples** :: - 10.sql = SELECT "fileadmin/img/img.jpg|Aternative Text" AS _img # alt="Alternative Text, no title - 20.sql = SELECT "fileadmin/img/img.jpg|Aternative Text|" AS _img # alt="Alternative Text, no title - 30.sql = SELECT "fileadmin/img/img.jpg|Aternative Text|Title Text" AS _img # alt="Alternative Text, title="Title Text" + 10.sql = SELECT "fileadmin/img/img.jpg|Alternative Text" AS _img # alt="Alternative Text, no title + 20.sql = SELECT "fileadmin/img/img.jpg|Alternative Text|" AS _img # alt="Alternative Text, no title + 30.sql = SELECT "fileadmin/img/img.jpg|Alternative Text|Title Text" AS _img # alt="Alternative Text, title="Title Text" 40.sql = SELECT "fileadmin/img/img.jpg|Alternative Text" AS _img # alt="Alternative Text", no title 50.sql = SELECT "fileadmin/img/img.jpg" AS _img # empty alt, no title 60.sql = SELECT "fileadmin/img/img.jpg|" AS _img # empty alt, no title @@ -1586,7 +1598,7 @@ Run a php function defined in an external script. 5.sql = SELECT "IAmInRecordStore" AS _savedInRecordStore 10.sql = SELECT "F:fileadmin/scripts/my_script.php|call:my_function|arg:a1=Hello&a2=World" AS _script - 20.sql = SELECT "<br><br>Returened value: {{IAmInVarStore:V:alnumx}}" + 20.sql = SELECT "<br><br>Returned value: {{IAmInVarStore:V:alnumx}}" * PHP script (``fileadmin/scripts/my_script.php``) :: @@ -1618,7 +1630,7 @@ Run a php function defined in an external script. Make API call: Http code: 301 - Returened value: FooBar + Returned value: FooBar .. _column_pdf: @@ -1681,7 +1693,7 @@ Tips: * Please note that this option does not render anything in the front end, but is executed each time it is parsed. You may want to add a check to prevent multiple execution. * It is not advised to generate the filename with user input for security reasons. -* If the target file already exists it will be overwriten. To save individual files, choose a new filename, +* If the target file already exists it will be overwritten. To save individual files, choose a new filename, for example by adding a timestamp. Example:: @@ -1788,7 +1800,7 @@ Render `Public` thumbnails are rendered at the time when the T3 QFQ record is executed. `Secure` thumbnails are rendered when the 'download.php?s=...' is called. The difference is, that the 'public' thumbnails blocks the page load until all thumbnails -are rendered, instead the `secure` thumbnails are loaded asynchonous via the browser - the main page is already delivered to +are rendered, instead the `secure` thumbnails are loaded asynchronous via the browser - the main page is already delivered to browser, all thumbnails appearing after a time. A way to *pre render* thumbnails, is a periodically called (hidden) T3 page, which iterates over all new uploaded files and @@ -1873,6 +1885,54 @@ Decrypting selected columns or strings which are encrypted with QFQ. 10.sql = SELECT secret AS _decrypt FROM Person WHERE id = 1 +.. _column-upload: + +Column: _upload +^^^^^^^^^^^^^^^ + +Creates an upload field which allows to upload files per drag and drop or over file browser. +There is a qfq delivered table named FileUpload which will be used to store the upload information's as default. +The files will be stored directly in destination folder, no need to trigger something after upload. + ++-----------------+-----------------------------------------------+------------------------------------------------------+ +| Token | Default value | Note | ++=================+===============================================+======================================================+ +| `F` | fileadmin/protected/upload/[currentYear]/ | File destination path | +| `x` | 1 | Delete file option for preloaded files | +| `table` | FileUpload | DB destination table | +| `M` | 0 | Enable multi upload option | +| `maxFileSize` | (none) | Max. allowed file size | +| `accept` | (all) | Allowed file types | +| `allowUpload` | true | Enable file upload | +| `t` | Drag and drop or <span class="btn btn-default | Upload field text | +| | filepond--label-action"> Browse </span> | | +| `recordData` | (none) | Define own column vaules to pass in upload table. | +| `maxFiles` | (unlimited if multi upload) | Allow a max count of files (multi upload) | +| `dbIndex` | (QFQ db index number) | Database index for destination table | ++-----------------+-----------------------------------------------+------------------------------------------------------+ + +The upload destination table must have at least following columns: + * id (unique) + * pathFileName + * uploadId + * size + * type + * ord + +More columns are optional and up to you. + +For multi-database setups with destination tables outside the QFQ database index, define them using the dbIndex parameter. + +**Syntax** :: + + 10.sql = SELECT 'uploadId:0' AS _upload + + 20.sql = SELECT 'uploadId:2|M|x:0' AS _upload + + 30.sql = SELECT 'uploadId:0|F:fileadmin/protected/testfolder/file123.png|dbIndex:2|x|M|accept:image/*|recordData: xId:23,grId:125' AS _upload + + +If multi upload is enabled then the given uploadId references to the column uploadId otherwise it will reference to the column id. .. _column-jwt: Column: _jwt @@ -2093,7 +2153,7 @@ QNBSP: Convert space to ' ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The SQL function QNBSP(text) replaces ` ` (space) by ` `. This prevents unwanted line breaks in text. -E.g. the title 'Prof. Dr.' should never be breaked: QNBSP('Prof. Dr.') +E.g. the title 'Prof. Dr.' should never be separated: QNBSP('Prof. Dr.') Example:: @@ -2248,14 +2308,13 @@ escaped double tick. Example:: - Set Comment.note = 'A "nice" event' + Set Comment.note = 'A "nice" event' 10.sql = SELECT QESC_DQUOTE(style) FROM Music Output:: Rock\'n\'n Roll - .. _qmanr: QMANR: format Matrikel-Nr. @@ -2478,7 +2537,7 @@ Parameter and (element) sources the Typo QFQ extension config) will be prepared and fires ``SELECT CONCAT('d|F:', n.pathFileName, '|t:File.pdf') FROM Note AS n WHERE n.id=1234``. The download of the file, specified by ``n.pathFileName``, will start. - If no record ist selected, a custom error will be shown. If the query selectes more than one record, a general error will be shown. + If no record ist selected, a custom error will be shown. If the query selects more than one record, a general error will be shown. If one of ``dl.php`` or ``dl2.php`` or ``dl3.php`` should be used, please initially create the symlink(s), e.g. in the application directory (same level as typo3conf) ``ln -s typo3conf/ext/qfq/Classes/Api/download.php dl.php`` (or dl2.ph, dl3.php). @@ -2539,7 +2598,7 @@ Parameter and (element) sources * The called tt-content record is identified by `function name`, specified in the subheader field. Optional the numeric id of the tt-content record (=uid) can be given. * Only the specified QFQ content record will be rendered, without any Typo3 layout elements (Menu, Body,...) - * QFQ will retrieve the tt-content's bodytext from the Typo3 database, parse it, and render it as a PDF or Execl data. + * QFQ will retrieve the tt-content's bodytext from the Typo3 database, parse it, and render it as a PDF or Excel data. * Parameters can be passed: ``uid:<tt-content record id>[&arg1=value1][&arg2=value2][...]`` and will be available via STORE_SIP in the QFQ PageContent, or passed as wkhtmltopdf arguments, if applicable. * For more obviously structuring, put the additional tt-content record on the same Typo3 page (where the QFQ @@ -2600,11 +2659,11 @@ Example `_link`: :: # three sources: two pages and one file, parameter to wkhtml will be SIP encoded SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1&_sip=1|p:id=detail2&r=1&_sip=1|F:fileadmin/pdf/test.pdf" AS _link - # three sources: two pages and one file, the second page will be in landscape and pagesize A3 + # three sources: two pages and one file, the second page will be in landscape and page size A3 SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail&r=1|p:id=detail2&r=1&--orientation=Landscape&--page-size=A3|F:fileadmin/pdf/test.pdf" AS _link # One source and a header file. Note: the parameter to the header URL is escaped with double backslash. - SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail2&r=1&--orientation=Landscape&--header={{URL:R}}?indexp.php?id=head\\&L=1|F:fileadmin/pdf/test.pdf" AS _link + SELECT "d:complete.pdf|s|t:Complete PDF|p:id=detail2&r=1&--orientation=Landscape&--header={{URL:R}}?index.php?id=head\\&L=1|F:fileadmin/pdf/test.pdf" AS _link # One indirect source reference SELECT "d:complete.pdf|s|t:Complete PDF|source:centralPdf&pId=1234" AS _link @@ -2659,7 +2718,7 @@ Parameter: ``cache[:[timestamp]|[table/id[/column][,...]]`` * Any of the direct given timestamps are younger than the cached file modified timestamp. * Any of the given database records returns a younger timestamp than the cached file modified timestamp. - * Optional cache parameter(s) to detect an 'outdated' situtation: + * Optional cache parameter(s) to detect an 'outdated' situation: * Multiple timestamp and/or table/id[/column] definitions are supported. * `timestamp`: format = yyyy-mm-dd [hh[:mm[:ss]]] @@ -2868,8 +2927,8 @@ Setup +-------------+----------------------+---------------------------------------------------------------------------------------------------+ | 'newline' | newline | Start a new row. The column will be the one of the last 'position' statement. | +-------------+----------------------+---------------------------------------------------------------------------------------------------+ -| 'str', 's' | s=hello world | Set the given string on the given position. The current position will be shift one to the right. | -| | | If the string contains newlines, option'b' (base64) should be used. | +| 'str', 's' | s=hello world | Set the given string on the given position. The current position will be shifted one to the right.| +| | | If the string contains newlines, option 'b' (base64) should be used. | +-------------+----------------------+---------------------------------------------------------------------------------------------------+ | 'b' | b=aGVsbG8gd29ybGQK | Same as 's', but the given string has to Base64 encoded and will be decoded before export. | +-------------+----------------------+---------------------------------------------------------------------------------------------------+ @@ -2982,7 +3041,7 @@ Format the dropdown menu symbol: * *Text*: Via ``t:Menu`` an additional text will be displayed for the menu symbol. * *Tooltip*: Via ``o:Detail menu`` a tooltip is defined. * *Render mode*: Via ``r:3`` the menu is disabled. No menu entries / links / sip are rendered. - * *Button*: Via ``b`` the dropdown meny symbol will be rendered with a button. Also `b:<style>` might set the BS color. + * *Button*: Via ``b`` the dropdown many symbols will be rendered with a button. Also `b:<style>` might set the BS color. Format a menu entry: @@ -3155,7 +3214,7 @@ Show / update order value in the browser ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; The 'drag and drop' action does not trigger a reload of the page. In case the order number is shown and the user does -a 'drag and drop', the order number shows the old. To update the dragable elements with the latest order number, a +a 'drag and drop', the order number shows the old. To update the draggable elements with the latest order number, a predefined html id has to be assigned them. After an update, all changed order number (referenced by the html id) will be updated via AJAX. @@ -3635,7 +3694,7 @@ Tablesorter View Saver .. important:: - Always speciy a unique (over your whole T3 installation) HTML ID (``id="{{pageSlug:T}}-example">``). On page load, this + Always specify a unique (over your whole T3 installation) HTML ID (``id="{{pageSlug:T}}-example">``). On page load, this reference will be used to load the last used settings again. If not specified, and if there are at least two tablesorter without an HTML ID, those will be mixed and might confuse the whole tablesorter. @@ -3661,7 +3720,7 @@ Tablesorter View Saver * 'public' means the view is tagged as 'public' visible. * 'email' is the name of the view, as it is shown in the dropdown list. - * If there is a public view with the name 'Default' and a user has no choosen a view earlier, that one will be selected. + * If there is a public view with the name 'Default' and a user has not chosen a view earlier, that one will be selected. .. _tablesorter-export-csv: @@ -4184,7 +4243,7 @@ FormElement) forms:: Table: vertical column title ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -To orientate a column title vertical, use the QFQ CSS classe ``qfq-vertical`` in td|th and ``qfq-vertical-text`` around the text. +To orientate a column title vertical, use the QFQ CSS class ``qfq-vertical`` in td|th and ``qfq-vertical-text`` around the text. HTML example (second column title is vertical):: diff --git a/Gruntfile.js b/Gruntfile.js index 6398f40238b95fb4343fd54b67131951088e6003..f23b13320644ecec94e6205582c36d49bc814a68 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -506,6 +506,172 @@ module.exports = function (grunt) { } ] }, + filepond: { + files: [ + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.min.css" + ], + expand: true, + dest: typo3_css, + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.css" + ], + expand: true, + dest: 'css/', + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.esm.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond/dist', + src: [ + "filepond.esm.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-file-validate-type/dist', + src: [ + "filepond-plugin-file-validate-type.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-file-validate-type/dist', + src: [ + "filepond-plugin-file-validate-type.min.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-file-validate-size/dist', + src: [ + "filepond-plugin-file-validate-size.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-file-validate-size/dist', + src: [ + "filepond-plugin-file-validate-size.min.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-preview/dist', + src: [ + "filepond-plugin-image-preview.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-preview/dist', + src: [ + "filepond-plugin-image-preview.min.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-preview/dist', + src: [ + "filepond-plugin-image-preview.min.css" + ], + expand: true, + dest: typo3_css, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-preview/dist', + src: [ + "filepond-plugin-image-preview.min.css" + ], + expand: true, + dest: 'css/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-edit/dist', + src: [ + "filepond-plugin-image-edit.min.js" + ], + expand: true, + dest: typo3_js, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-edit/dist', + src: [ + "filepond-plugin-image-edit.min.js" + ], + expand: true, + dest: 'js/', + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-edit/dist', + src: [ + "filepond-plugin-image-edit.min.css" + ], + expand: true, + dest: typo3_css, + flatten: true + }, + { + cwd: 'node_modules/filepond-plugin-image-edit/dist', + src: [ + "filepond-plugin-image-edit.min.css" + ], + expand: true, + dest: 'css/', + flatten: true + } + ] + }, worker: { files: [ { diff --git a/Makefile b/Makefile index 86c102eec88d07024354f6d5ef3b942ef27b10b0..c0ea69dc9233943cbd797ddc2d72ad44fe711a9f 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ qfq.zip: cd extension; zip -r ../$@ $(EXTENSION_CONTENT) clean: - cd doc/diagram ; $(MAKE) $@ +# cd doc/diagram ; $(MAKE) $@ git-revision: make-dist-dir echo $(GIT_REVISION_LONG) > $(DISTDIR)/revision.git diff --git a/extension/Classes/Api/file.php b/extension/Classes/Api/file.php index 176c2213e5a47f6292566bfb7655c3c6610e7a4c..a3d76cb3514605bdd7c1ee67ba4e142afca7fa02 100644 --- a/extension/Classes/Api/file.php +++ b/extension/Classes/Api/file.php @@ -52,6 +52,13 @@ try { $answer[API_MESSAGE] = 'upload: success'; // $answer[API_REDIRECT] = API_ANSWER_REDIRECT_NO; $answer[API_STATUS] = API_ANSWER_STATUS_SUCCESS; + if ($fileUpload->sipTmp !== null) { + $answer['sipTmp'] = $fileUpload->sipTmp; + } + + if ($fileUpload->uniqueFileId !== null) { + $answer = array('uniqueFileId' => $fileUpload->uniqueFileId, 'groupId' => $fileUpload->groupId); + } } } catch (\UserFormException $e) { $answer[API_MESSAGE] = $e->formatMessage(); @@ -69,4 +76,3 @@ if ($fileUpload->imageUploadFilePath === null) { echo json_encode($answer); - diff --git a/extension/Classes/Api/save.php b/extension/Classes/Api/save.php index 765442db462df36a5c06c3bd0e29d27fc253ed05..5ac3db6f73febf11aa36aee5598702783d3e0a91 100644 --- a/extension/Classes/Api/save.php +++ b/extension/Classes/Api/save.php @@ -88,6 +88,16 @@ try { } catch (\UserFormException $e) { $answer[API_MESSAGE] = $e->formatMessage(); + $val = Store::getVar(FE_ALERT_TEXT,STORE_SYSTEM); + if ($val !== false) { + $answer[FE_ALERT_TEXT] = $answer[API_MESSAGE]; + $answer[FE_ALERT_LEVEL] = Store::getVar(FE_ALERT_LEVEL, STORE_SYSTEM); + $answer[FE_ALERT_BUTTON_OK] = Store::getVar(FE_ALERT_BUTTON_OK, STORE_SYSTEM); + $answer[FE_ALERT_BUTTON_FORCE] = Store::getVar(FE_ALERT_BUTTON_FORCE, STORE_SYSTEM); + $answer[FE_ALERT_TIMEOUT] = Store::getVar(FE_ALERT_TIMEOUT, STORE_SYSTEM); + $answer[FE_ALERT_FLAG_MODAL] = Store::getVar(FE_ALERT_FLAG_MODAL, STORE_SYSTEM); + } + $val = Store::getVar(SYSTEM_FORM_ELEMENT, STORE_SYSTEM); if ($val !== false) { $answer[API_FIELD_NAME] = $val; @@ -102,6 +112,18 @@ try { $answer[API_MESSAGE] = $e->formatMessage(); } catch (\DbException $e) { $answer[API_MESSAGE] = $e->formatMessage(); + } catch (\InfoException $e) { + $answer[API_MESSAGE] = $e->formatMessage(); + + $val = Store::getVar(FE_ALERT_TEXT,STORE_SYSTEM); + if ($val !== false) { + $answer[FE_ALERT_TEXT] = $answer[API_MESSAGE]; + $answer[FE_ALERT_LEVEL] = Store::getVar(FE_ALERT_LEVEL, STORE_SYSTEM); + $answer[FE_ALERT_BUTTON_OK] = Store::getVar(FE_ALERT_BUTTON_OK, STORE_SYSTEM); + $answer[FE_ALERT_BUTTON_FORCE] = Store::getVar(FE_ALERT_BUTTON_FORCE, STORE_SYSTEM); + $answer[FE_ALERT_TIMEOUT] = Store::getVar(FE_ALERT_TIMEOUT, STORE_SYSTEM); + $answer[FE_ALERT_FLAG_MODAL] = Store::getVar(FE_ALERT_FLAG_MODAL, STORE_SYSTEM); + } } } catch (\Throwable $e) { $answer[API_MESSAGE] = "Generic Exception: " . $e->getMessage(); diff --git a/extension/Classes/Core/AbstractBuildForm.php b/extension/Classes/Core/AbstractBuildForm.php index 074a1b500de75e4db279c035fb97b77b92ee455a..5f3c673efbe58a8a7f4d9cb90bfcec54a9d1f8fe 100644 --- a/extension/Classes/Core/AbstractBuildForm.php +++ b/extension/Classes/Core/AbstractBuildForm.php @@ -926,12 +926,12 @@ abstract class AbstractBuildForm { case FE_TYPE_DATE: case FE_TYPE_DATETIME: case FE_TYPE_TIME: - $elementHtml = DateTime::buildDateTime($formElement, $htmlFormElementName, $value, $jsonElement, $this->formSpec, $this->store, $mode, $this->wrap[WRAP_SETUP_ELEMENT][WRAP_SETUP_CLASS]); - //needed for datepicker to be positioned correctly - if ($flagMulti == true) { - $elementHtml = Support::wrapTag('<div class="col-d-12 col-lg-12">', $elementHtml); - } - break; + $elementHtml = DateTime::buildDateTime($formElement, $htmlFormElementName, $value, $jsonElement, $this->formSpec, $this->store, $mode, $this->wrap[WRAP_SETUP_ELEMENT][WRAP_SETUP_CLASS]); + //needed for datepicker to be positioned correctly + if ($flagMulti == true) { + $elementHtml = Support::wrapTag('<div class="col-d-12 col-lg-12">', $elementHtml); + } + break; case FE_TYPE_TEXT: case FE_TYPE_PASSWORD: case 'email': @@ -964,13 +964,13 @@ abstract class AbstractBuildForm { case FE_TYPE_IMAGE_CUT: $elementHtml = $this->buildImageCut($formElement, $htmlFormElementName, $value, $jsonElement, $mode); break; - case 'fieldset': + case FE_TYPE_FIELDSET: $elementHtml = $this->buildFieldset($formElement, $htmlFormElementName, $value, $jsonElement, $mode); break; - case 'pill': + case FE_TYPE_PILL: $elementHtml = $this->buildPill($formElement, $htmlFormElementName, $value, $jsonElement, $mode); break; - case 'templateGroup': + case FE_TYPE_TEMPLATE_GROUP: $elementHtml = $this->buildTemplateGroup($formElement, $htmlFormElementName, $value, $jsonElement, $mode); break; default: @@ -1023,6 +1023,7 @@ abstract class AbstractBuildForm { // Log / Debug: Last FormElement has been processed. $this->store->setVar(SYSTEM_FORM_ELEMENT, '', STORE_SYSTEM); + $this->store->setVar(SYSTEM_FORM_ELEMENT_ID, 0, STORE_SYSTEM); return $html; } @@ -2947,6 +2948,8 @@ abstract class AbstractBuildForm { */ public function buildFile(array $formElement, $htmlFormElementName, $value, array &$json, $mode = FORM_LOAD) { $attribute = ''; + $uploadType = $formElement[UPLOAD_TYPE] ?? UPLOAD_TYPE_V2; + $sipDownloadKey = 'sip-' . $formElement[FE_ID]; $this->store->appendToStore(HelperFile::pathinfo($value), STORE_VAR); @@ -2973,6 +2976,7 @@ abstract class AbstractBuildForm { $arr[CLIENT_PAGE_ID] = 'fake'; $arr[EXISTING_PATH_FILE_NAME] = $value; $arr[FE_FILE_MIME_TYPE_ACCEPT] = $formElement[FE_FILE_MIME_TYPE_ACCEPT]; + $arr[UPLOAD_SIP_DOWNLOAD_KEY] = $sipDownloadKey; // Check Safari Bug #5578: in case Safari (Mac OS X or iOS) loads an 'upload element' with more than one file type, fall back to 'no preselection'. // Still do the file type check on the server side! @@ -3001,17 +3005,6 @@ abstract class AbstractBuildForm { $hiddenSipUpload = HelperFormElement::buildNativeHidden($htmlFormElementName, $sipUpload); - $attribute .= Support::doAttribute('id', $formElement[FE_HTML_ID]); - $attribute .= Support::doAttribute('name', $htmlFormElementName); -// $attribute .= Support::doAttribute('class', 'form-control'); - $attribute .= Support::doAttribute('type', 'file'); - $attribute .= Support::doAttribute('title', $formElement[FE_TOOLTIP]); - $attribute .= Support::doAttribute(FE_FILE_CAPTURE, $formElement[FE_FILE_CAPTURE], true); - $attribute .= HelperFormElement::getAttributeList($formElement, [FE_AUTOFOCUS, FE_FILE_MIME_TYPE_ACCEPT]); - $attribute .= Support::doAttribute('data-load', ($formElement[FE_DYNAMIC_UPDATE] === 'yes') ? 'data-load' : ''); - $attribute .= Support::doAttribute('data-sip', $sipUpload); - $attribute .= Support::doAttribute(ATTRIBUTE_DATA_REFERENCE, $formElement[FE_DATA_REFERENCE]); - // Below, $value and $formElement[FE_MODE]=FE_MODE_REQUIRED will be changed. JSON will be made later, therefore we will need those values unchanged $jsonValue = $value; $jsonFormElement = $formElement; @@ -3030,70 +3023,171 @@ abstract class AbstractBuildForm { // $formElement[FE_MODE] = FE_MODE_HIDDEN; // #3876, CR did not understand why we need this here. Comment. If active, this element will be hide on each dynamic update. } - $attribute .= HelperFormElement::getAttributeFeMode($formElement[FE_MODE]); - $attribute .= Support::doAttribute('class', $uploadClass, true); + $disabled = ($formElement[FE_MODE] == FE_MODE_READONLY) ? 'disabled' : ''; -// $htmlInputFile = '<input ' . $attribute . '>' . HelperFormElement::getHelpBlock(); + Support::setIfNotSet($formElement, FE_FILE_BUTTON_TEXT, FE_FILE_BUTTON_TEXT_DEFAULT); - // <input type="file"> with BS3: https://stackoverflow.com/questions/11235206/twitter-bootstrap-form-file-element-upload-button/25053973#25053973 - $attribute .= Support::doAttribute('style', "display:none;"); - $htmlInputFile = '<input ' . $attribute . '>'; - $attributeFileLabel = Support::doAttribute('for', $formElement[FE_HTML_ID]); - $attributeFileLabel .= Support::doAttribute('class', 'btn btn-default ' . $uploadClass); + if ($uploadType === UPLOAD_TYPE_V2) { + $htmlInputFile = $this->createUploadElement($formElement, $value, $sipUpload, $sipDownloadKey, $disabled); + } else { + $attribute .= Support::doAttribute('id', $formElement[FE_HTML_ID]); + $attribute .= Support::doAttribute('name', $htmlFormElementName); +// $attribute .= Support::doAttribute('class', 'form-control'); + $attribute .= Support::doAttribute('type', 'file'); + $attribute .= Support::doAttribute('title', $formElement[FE_TOOLTIP]); + $attribute .= Support::doAttribute(FE_FILE_CAPTURE, $formElement[FE_FILE_CAPTURE], true); + $attribute .= HelperFormElement::getAttributeList($formElement, [FE_AUTOFOCUS, FE_FILE_MIME_TYPE_ACCEPT]); + $attribute .= Support::doAttribute('data-load', ($formElement[FE_DYNAMIC_UPDATE] === 'yes') ? 'data-load' : ''); + $attribute .= Support::doAttribute('data-sip', $sipUpload); + $attribute .= Support::doAttribute(ATTRIBUTE_DATA_REFERENCE, $formElement[FE_DATA_REFERENCE]); - Support::setIfNotSet($formElement, FE_FILE_BUTTON_TEXT, FE_FILE_BUTTON_TEXT_DEFAULT); - $htmlInputFile = Support::wrapTag("<label $attributeFileLabel>", $htmlInputFile . $formElement[FE_FILE_BUTTON_TEXT]); + $attribute .= HelperFormElement::getAttributeFeMode($formElement[FE_MODE]); + $attribute .= Support::doAttribute('class', $uploadClass, true); - $disabled = ($formElement[FE_MODE] == FE_MODE_READONLY) ? 'disabled' : ''; +// $htmlInputFile = '<input ' . $attribute . '>' . HelperFormElement::getHelpBlock(); - // Check if a custom text right beside the trash symbol is given. - $trashText = ''; - if (!empty($formElement[FE_FILE_TRASH_TEXT])) { - $trashText = ' ' . $formElement[FE_FILE_TRASH_TEXT]; - } + // <input type="file"> with BS3: https://stackoverflow.com/questions/11235206/twitter-bootstrap-form-file-element-upload-button/25053973#25053973 + $attribute .= Support::doAttribute('style', "display:none;"); + $htmlInputFile = '<input ' . $attribute . '>'; - if (!empty($value) && Support::isEnabled($formElement, FE_FILE_DOWNLOAD_BUTTON)) { - $testValue = file_exists($value); + $attributeFileLabel = Support::doAttribute('for', $formElement[FE_HTML_ID]); + $attributeFileLabel .= Support::doAttribute('class', 'btn btn-default ' . $uploadClass); - // API calls don't recognize paths like '/fileadmin/protected/...' - if (!$testValue && isset($_GET["submit_reason"])) { - $value = $_SERVER["DOCUMENT_ROOT"] . '/' . $value; - $testValue = file_exists($value); + $htmlInputFile = Support::wrapTag("<label $attributeFileLabel>", $htmlInputFile . $formElement[FE_FILE_BUTTON_TEXT]); + + // Check if a custom text right beside the trash symbol is given. + $trashText = ''; + if (!empty($formElement[FE_FILE_TRASH_TEXT])) { + $trashText = ' ' . $formElement[FE_FILE_TRASH_TEXT]; } - if (is_readable($value)) { - $link = new Link($this->sip, $this->dbIndexData); - $value = $link->renderLink($this->evaluate->parse($formElement[FE_FILE_DOWNLOAD_BUTTON]), 's|M:file|d|F:' . $value); - $jsonFormElement[FE_FILE_DOWNLOAD_BUTTON_HTML_INTERNAL] = $value; - } else { - $msg = "Already uploaded file not found."; - // In case debugging is off: showing download button means 'never show the real pathfilename' - if ($this->showDebugInfoFlag) { - $msg .= ' ShowDebugInfo=on >> Missing file is: ' . $value; + if (!empty($value) && Support::isEnabled($formElement, FE_FILE_DOWNLOAD_BUTTON)) { + $testValue = file_exists($value); + + // API calls don't recognize paths like '/fileadmin/protected/...' + if (!$testValue && isset($_GET["submit_reason"])) { + $value = $_SERVER["DOCUMENT_ROOT"] . '/' . $value; + $testValue = file_exists($value); + } + + if (is_readable($value)) { + $link = new Link($this->sip, $this->dbIndexData); + $value = $link->renderLink($this->evaluate->parse($formElement[FE_FILE_DOWNLOAD_BUTTON]), 's|M:file|d|F:' . $value); + $jsonFormElement[FE_FILE_DOWNLOAD_BUTTON_HTML_INTERNAL] = $value; + } else { + $msg = "Already uploaded file not found."; + // In case debugging is off: showing download button means 'never show the real pathfilename' + if ($this->showDebugInfoFlag) { + $msg .= ' ShowDebugInfo=on >> Missing file is: ' . $value; + } + $value = $msg; } - $value = $msg; } + + $deleteButton = ''; + $htmlFilename = Support::wrapTag("<span class='uploaded-file-name'>", $value, false); + + if (($formElement[FE_FILE_TRASH] ?? '1') == '1') { + $deleteButton = Support::wrapTag("<button type='button' class='btn btn-default delete-file $disabled' $disabled data-sip='$sipUpload' name='delete-$htmlFormElementName'>", $this->symbol[SYMBOL_DELETE] . $trashText); + } + $htmlTextDelete = Support::wrapTag("<div class='uploaded-file $textDeleteClass'>", $htmlFilename . ' ' . $deleteButton); + +// <button type="button" class="file-delete" data-sip="571d1fc9e6974"><span class="glyphicon glyphicon-trash"></span></button> } // JSON Build $wrapSetupClass = $this->wrap[WRAP_SETUP_ELEMENT][WRAP_SETUP_CLASS] ?? ''; $json = $this->getFormElementForJson($htmlFormElementName, $jsonValue, $jsonFormElement, $wrapSetupClass); // Below, $formElement[FE_MODE]=FE_MODE_REQUIRED will be changed. Get the JSON unchanged - $deleteButton = ''; - $htmlFilename = Support::wrapTag("<span class='uploaded-file-name'>", $value, false); + $formElement = HelperFormElement::prepareExtraButton($formElement, false); - if (($formElement[FE_FILE_TRASH] ?? '1') == '1') { - $deleteButton = Support::wrapTag("<button type='button' class='btn btn-default delete-file $disabled' $disabled data-sip='$sipUpload' name='delete-$htmlFormElementName'>", $this->symbol[SYMBOL_DELETE] . $trashText); + $htmlOutputElement = $htmlInputFile . $hiddenSipUpload . $formElement[FE_TMP_EXTRA_BUTTON_HTML] . $formElement[FE_INPUT_EXTRA_BUTTON_INFO]; + if ($uploadType === UPLOAD_TYPE_V1) { + $htmlOutputElement = $htmlTextDelete . $htmlInputFile . $hiddenSipUpload . $formElement[FE_TMP_EXTRA_BUTTON_HTML] . $formElement[FE_INPUT_EXTRA_BUTTON_INFO]; } - $htmlTextDelete = Support::wrapTag("<div class='uploaded-file $textDeleteClass'>", $htmlFilename . ' ' . $deleteButton); - -// <button type="button" class="file-delete" data-sip="571d1fc9e6974"><span class="glyphicon glyphicon-trash"></span></button> - $formElement = HelperFormElement::prepareExtraButton($formElement, false); - - return $htmlTextDelete . $htmlInputFile . $hiddenSipUpload . $formElement[FE_TMP_EXTRA_BUTTON_HTML] . $formElement[FE_INPUT_EXTRA_BUTTON_INFO]; + return $htmlOutputElement; } + /** + * Create HTML upload element for filePond instance. + * + * @param array $formElement + * @param string $disabled + * @param string $value + * + */ + public function createUploadElement(array $formElement, string $value, string $sipUpload, string $sipDownloadKey, string $disabled): string { + $defaultText = 'Drag & Drop or <span class="btn btn-default filepond--label-action"> '. $formElement[FE_FILE_BUTTON_TEXT] . ' </span>'; + + // Check for upload type new or old and initialize json config for new upload type + $jsonConfig = array(); + $preloadedFiles = ''; + + $jsonConfig[UPLOAD_MIME_TYPE_ACCEPT] = $formElement[FE_FILE_MIME_TYPE_ACCEPT] ?? null; + $jsonConfig[UPLOAD_MAX_FILE_SIZE] = $arr[FE_FILE_MAX_FILE_SIZE] ?? null; + $jsonConfig[UPLOAD_MULTI_UPLOAD] = false; + $jsonConfig[UPLOAD_DELETE_OPTION] = false; + if (($formElement[FE_FILE_TRASH] ?? '1') === '1' && $disabled === '') { + $jsonConfig[UPLOAD_DELETE_OPTION] = true; + } + $jsonConfig[UPLOAD_IMAGE_EDITOR] = false; + $jsonConfig[UPLOAD_ALLOW] = true; + $jsonConfig[UPLOAD_TEXT] = $defaultText; + $jsonConfig[UPLOAD_MAX_FILES] = null; + $jsonConfig[UPLOAD_ID] = 1; + $jsonConfig[UPLOAD_GROUP_ID] = $groupId ?? 0; + $jsonConfig[UPLOAD_DROP_BACKGROUND] = 'white'; + $jsonConfig[UPLOAD_DOWNLOAD_BUTTON] = substr($this->evaluate->parse($formElement[FE_FILE_DOWNLOAD_BUTTON]), 2); + $jsonConfig[UPLOAD_TYPE_FORM] = true; + $jsonConfig[UPLOAD_FORM_ID] = $formElement[FE_HTML_ID]; + $jsonConfig[UPLOAD_SIP_DOWNLOAD_KEY] = $sipDownloadKey; + + if (!isset($jsonConfig[UPLOAD_PATH_FILE_NAME])) { + $jsonConfig[UPLOAD_PATH_FILE_NAME] = ''; + $jsonConfig[UPLOAD_PATH_DEFAULT] = 1; + } + + $encodedJsonConfig = htmlspecialchars(json_encode($jsonConfig, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + $baseUrl = $this->store::getVar(SYSTEM_BASE_URL, STORE_SYSTEM); + + // Check if fileDestination exists in extra Store + $storeExtra = $this->store::getVar($sipDownloadKey, STORE_EXTRA . STORE_EMPTY); + $sipFileDestination = $storeExtra[FE_FILE_DESTINATION] ?? ''; + if ($value !== '' || $sipFileDestination !== '') { + $path = empty($value) ? $sipFileDestination : $value; + $pathToCheck = $_SERVER["DOCUMENT_ROOT"] . '/' . $path; + if (file_exists($pathToCheck)) { + // Currently no information except path is stored for upload over form. + $preloadedFiles = '[{"id":"1","pathFileName":"'. $_SERVER["DOCUMENT_ROOT"] . '/' . $path . '","size":"null","type":"null"}]'; + $link = new Link($this->sip, $this->dbIndexData); + $sipDownload = $link->renderLink('', 's|M:file|d|r:8|F:' . $path); + $this->store->setVar($sipDownloadKey, array(),STORE_EXTRA); + + // Fill extra store for downloadable upload after save + if ($this->store::getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX) === API_SUBMIT_REASON_SAVE) { + $statusUpload[SIP_DOWNLOAD_PARAMETER] = 'F:' . $path; + $this->store->setVar($sipDownloadKey, $statusUpload, STORE_EXTRA); + } + } + } + + $encodedPreloadFilesConfig = htmlspecialchars($preloadedFiles, ENT_QUOTES, 'UTF-8'); + + $apiUrls['upload'] = $baseUrl . 'typo3conf/ext/qfq/Classes/Api/file.php'; + $apiUrls['download'] = $baseUrl . 'typo3conf/ext/qfq/Classes/Api/download.php'; + $encodedApiUrls = htmlspecialchars(json_encode($apiUrls, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + + $sipValues['download'] = $sipDownload ?? ''; + $sipValues['delete'] = $sipUpload . '&action=delete'; + $sipValues['upload'] = $sipUpload . '&action=upload'; + $encodedSipValues = htmlspecialchars(json_encode($sipValues, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + + //data-class="' . $uploadClass . '" + return '<input class="fileupload" data-preloadedFiles="' . $encodedPreloadFilesConfig . '" data-api-urls="' . $encodedApiUrls . '" data-sips="' . $encodedSipValues . '" data-config="'. $encodedJsonConfig . '" type="file">'; + + } + /** * @param array $formElement * @param $htmlFormElementName @@ -3115,7 +3209,7 @@ abstract class AbstractBuildForm { $html = $this->buildAnnotateCode($formElement, $htmlFormElementName, $value, $json, $mode); break; default: - throw new \UserFormException("Unkown " . FE_ANNOTATE_TYPE . ": '" . $formElement[FE_ANNOTATE_TYPE] . "'", ERROR_UNKNOWN_MODE); + throw new \UserFormException("Unknown " . FE_ANNOTATE_TYPE . ": '" . $formElement[FE_ANNOTATE_TYPE] . "'", ERROR_UNKNOWN_MODE); } return $html; } @@ -3982,23 +4076,30 @@ abstract class AbstractBuildForm { $attribute .= Support::doAttribute('id', $formElement[FE_HTML_ID]); $attribute .= Support::doAttribute('name', $htmlFormElementName); $attribute .= Support::doAttribute('data-load', ($formElement[FE_DYNAMIC_UPDATE] === 'yes') ? 'data-load' : ''); - $attribute .= Support::doAttribute('class', $formElement[F_FE_FIELDSET_CLASS]); + $attribute .= Support::doAttribute('class', [ $formElement[F_FE_FIELDSET_CLASS], ($formElement[FE_MODE] == FE_MODE_HIDDEN) ? FE_MODE_HIDDEN : '']); $attribute .= Support::doAttribute(ATTRIBUTE_DATA_REFERENCE, $formElement[FE_DATA_REFERENCE]); + $attribute .= HelperFormElement::getAttributeFeMode($formElement[FE_MODE], false); // <fieldset> $html = '<fieldset ' . $attribute . '>'; - if ($formElement[FE_LABEL] !== '') { $html .= '<legend>' . $formElement[FE_LABEL] . '</legend>'; } $html .= $this->wrap[WRAP_SETUP_IN_FIELDSET][WRAP_SETUP_START]; - // child FE's + // child FEs $this->feSpecNative = $this->dbArray[$this->dbIndexQfq]->getNativeFormElements(SQL_FORM_ELEMENT_SPECIFIC_CONTAINER, ['yes', $this->formSpec["id"], 'native,container', $formElement[FE_ID]], $this->formSpec); + // child FEs set to required if fieldset is required + if ($formElement[FE_MODE] == FE_MODE_REQUIRED) { + foreach ($this->feSpecNative as $key => $value) { + $this->feSpecNative[$key][FE_MODE] = FE_MODE_REQUIRED; + } + } + $html .= $this->elements($this->store->getVar(SIP_RECORD_ID, STORE_SIP), FORM_ELEMENTS_NATIVE_SUBRECORD, 0, $json); $html .= $this->wrap[WRAP_SETUP_IN_FIELDSET][WRAP_SETUP_END]; @@ -4282,6 +4383,9 @@ EOT; } } + $this->store->setVar(SYSTEM_FORM_ELEMENT, '', STORE_SYSTEM); + $this->store->setVar(SYSTEM_FORM_ELEMENT_ID, 0, STORE_SYSTEM); + return $tgMax; } diff --git a/extension/Classes/Core/Constants.php b/extension/Classes/Core/Constants.php index 0d754b2ccddaba27f622a8d7c75f64d58a91a770..190ee0c4fd205391abdf6472e0b2126e3a328a00 100644 --- a/extension/Classes/Core/Constants.php +++ b/extension/Classes/Core/Constants.php @@ -30,6 +30,7 @@ const SESSION_FE_USER_GROUP = 'feUserGroup'; const SESSION_BE_USER = 'beUser'; const SESSION_PAGE_LANGUAGE = 'pageLanguage'; const SESSION_PAGE_LANGUAGE_PATH = 'pageLanguagePath'; +const SESSION_PAGE_ID = 'pageId'; const TABLE_NAME_FORM = 'Form'; const TABLE_NAME_FORM_ELEMENT = 'FormElement'; const TABLE_NAME_SPLIT = 'Split'; @@ -113,6 +114,7 @@ const ERROR_MESSAGE_TO_DEVELOPER = 'support'; // Message to help the developer t const ERROR_MESSAGE_TO_DEVELOPER_SANITIZE = 'support_sanitize'; // Typically 'true' or missing. If 'false' then content of 'support' won't be html encoded. const ERROR_MESSAGE_OS = 'os'; // Error message from the OS - like 'file not found' or specific SQL problem const ERROR_MESSAGE_HTTP_STATUS = 'httpStatus'; // HTTP Status Code to report +const ERROR_MESSAGE_LOG_ERROR = 'logError'; // Indicates if the error should be logged (boolean) // QFQ Error Codes const ERROR_UNKNOW_SANITIZE_CLASS = 1001; @@ -205,6 +207,8 @@ const ERROR_QFQ_UPDATE_API = 1088; const ERROR_UNPROTECTED_FOLDER = 1089; const ERROR_INVALID_WGET_CMD = 1090; const ERROR_MISSING_KEY_VALUE = 1091; +const ERROR_MISSING_ALERT = 1092; +const ERROR_DOUBLE_USAGE_ALERT_AND_MESSAGE_FAIL = 1093; // Subrecord @@ -385,7 +389,7 @@ const STORE_ADDITIONAL_FORM_ELEMENTS = "A"; // Internal Store to collect FormEle const STORE_BEFORE = "B"; // selected record from primary table before any modifcations. const STORE_CLIENT = "C"; // Client: POST variable, if not found: GET variable const STORE_TABLE_DEFAULT = "D"; // definition of primary table. -const STORE_EMPTY = "E"; // value: '', might helpfull if variable is not defined and should result in an empty string instead of {{...}} (cause not replaced) +const STORE_EMPTY = "E"; // value: '', might helpful if variable is not defined and should result in an empty string instead of {{...}} (cause not replaced) const STORE_FORM = "F"; // form, still not saved in database const STORE_LDAP = "L"; const STORE_TABLE_COLUMN_TYPES = "M"; // column types of primary table. @@ -398,7 +402,7 @@ const STORE_VAR = "V"; // Generic Vars #const STORE_WIPE_SIP = "W"; // Like SIP, but will remove the entry after reading from STORE_SIP as well as from SESSION-SIP table. const STORE_EXTRA = "X"; // Persistent Store: contains arrays! Used by QFQ system - not used by user. const STORE_SYSTEM = "Y"; // various system values like db connection credentials -const STORE_ZERO = "0"; // value: 0, might helpfull if variable is empty but used in an SQL statement, which might produce a SQL error otherwise if substituted with an empty string +const STORE_ZERO = "0"; // value: 0, might helpful if variable is empty but used in an SQL statement, which might produce a SQL error otherwise if substituted with an empty string const STORE_USE_DEFAULT = "FSRVD"; @@ -946,6 +950,7 @@ const API_ELEMENT_CONTENT = 'content'; const API_SUBMIT_REASON = 'submit_reason'; const API_SUBMIT_REASON_SAVE = 'save'; const API_SUBMIT_REASON_SAVE_CLOSE = 'save,close'; +const API_SUBMIT_REASON_SAVE_FORCE = 'save,force'; const API_LOCK_ACTION_LOCK = 'lock'; const API_LOCK_ACTION_EXTEND = 'extend'; @@ -1392,7 +1397,25 @@ const FE_HIGHLIGHT_MATLAB = 'matlab'; const FE_SQL_VALIDATE = 'sqlValidate'; // Action: Query to validate form load const FE_EXPECT_RECORDS = 'expectRecords'; // Action: expected number of rows of FE_SQL_VALIDATE const FE_MESSAGE_FAIL = 'messageFail'; // Action: Message to display, if FE_SQL_VALIDATE fails. -const FE_REQUIRED_LIST = 'requiredList'; // Optional list of FormElements which have to be non empty to make this 'action'-FormElement active. +const FE_REQUIRED_LIST = 'requiredList'; // Optional list of FormElements which have to be non-empty to make this 'action'-FormElement active. +const FE_QFQ_LOG = 'qfqLog'; +const FE_ALERT = 'alert'; // Action: Replacement of messageFail with additional functionality +const FE_ALERT_INDEX_TEXT = 0; +const FE_ALERT_INDEX_LEVEL = 1; +const FE_ALERT_INDEX_BUTTON_OK = 2; +const FE_ALERT_INDEX_BUTTON_FORCE = 3; +const FE_ALERT_INDEX_TIMEOUT = 4; +const FE_ALERT_INDEX_FLAG_MODAL = 5; +const FE_ALERT_TEXT = 'text'; +const FE_ALERT_LEVEL = 'level'; +const FE_ALERT_BUTTON_OK = 'ok'; +const FE_ALERT_BUTTON_FORCE = 'force'; +const FE_ALERT_TIMEOUT = 'timeout'; +const FE_ALERT_FLAG_MODAL = 'flagModal'; +const DEFAULT_ALERT_LEVEL = 'info'; +const DEFAULT_ALERT_BUTTON_OK = 'Ok'; +const DEFAULT_ALERT_TIMEOUT = '0'; +const DEFAULT_ALERT_FLAG_MODAL = '1'; const FE_SLAVE_ID = 'slaveId'; // Action; Value or Query to compute id of slave record. const FE_SQL_AFTER = 'sqlAfter'; // Action: Always fired const FE_SQL_BEFORE = 'sqlBefore'; // Action: Always fired @@ -1647,9 +1670,31 @@ const FILES_FLAG_DELETE = 'flagDelete'; const UPLOAD_CACHED = '.cached'; const FILE_ACTION = 'action'; const FILE_ACTION_UPLOAD = 'upload'; +const FILE_ACTION_UPLOAD_2 = 'upload2'; const FILE_ACTION_DELETE = 'delete'; const FILE_ACTION_IMAGE_UPLOAD = 'imageUpload'; - +const FILE_ACTION_DOWNLOAD = 'download'; +const UPLOAD_ID = 'uploadId'; +const UPLOAD_TEXT = 'text'; +const UPLOAD_MIME_TYPE_ACCEPT = 'accept'; +const UPLOAD_MULTI_UPLOAD = 'multiUpload'; +const UPLOAD_RECORD_DATA = 'recordData'; +const UPLOAD_MAX_FILES = 'maxFiles'; +const UPLOAD_MAX_FILE_SIZE = SYSTEM_FILE_MAX_FILE_SIZE; +const UPLOAD_ALLOW = 'allowUpload'; +const UPLOAD_DELETE_OPTION = 'deleteOption'; +const UPLOAD_GROUP_ID = 'groupId'; +const UPLOAD_SIP_DOWNLOAD_KEY = 'sipDownloadKey'; +const UPLOAD_DROP_BACKGROUND = 'dropBackground'; +const UPLOAD_DOWNLOAD_BUTTON = 'downloadButton'; +const UPLOAD_TYPE_FORM = 'form'; +const UPLOAD_FORM_ID = 'formId'; +const UPLOAD_PATH_FILE_NAME = 'pathFileName'; +const UPLOAD_IMAGE_EDITOR = 'imageEditor'; +const UPLOAD_PATH_DEFAULT = 'pathDefault'; +const UPLOAD_TYPE = 'uploadType'; +const UPLOAD_TYPE_V1 = 'v1'; +const UPLOAD_TYPE_V2 = 'v2'; const PATH_FILE_CONCAT = 'pathFileConcat'; const FILE_PRIORITY = 'filePriority'; @@ -1673,6 +1718,8 @@ 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'; // QuickFormQuery.php @@ -1802,6 +1849,7 @@ const LINE_ALT_INSERT_ID = 'altInsertId'; //Report: Column Token const COLUMN_LINK = 'link'; +const COLUMN_UPLOAD = 'upload'; const COLUMN_EXEC = 'exec'; const COLUMN_THUMBNAIL = 'thumbnail'; const COLUMN_FUNCTION = 'function'; @@ -2106,6 +2154,16 @@ const TOKEN_L_CONTENT_FILE = 'contentFile'; const TOKEN_L_TIMEOUT = 'timeout'; const TOKEN_L_SSL = 'ssl'; +const TOKEN_UPLOAD_ID = UPLOAD_ID; +const TOKEN_UPLOAD_MIME_TYPE_ACCEPT = UPLOAD_MIME_TYPE_ACCEPT; +const TOKEN_SIP_TABLE = SIP_TABLE; +const TOKEN_UPLOAD_MULTI_UPLOAD = 'M'; +const TOKEN_UPLOAD_RECORD_DATA = UPLOAD_RECORD_DATA; +const TOKEN_UPLOAD_MAX_FILES = UPLOAD_MAX_FILES; +const TOKEN_UPLOAD_MAX_FILE_SIZE = UPLOAD_MAX_FILE_SIZE; +const TOKEN_UPLOAD_ALLOW = UPLOAD_ALLOW; +const TOKEN_UPLOAD_DELETE = TOKEN_ACTION_DELETE; +const TOKEN_UPLOAD_IMAGE_EDITOR = UPLOAD_IMAGE_EDITOR; const MONITOR_MODE_APPEND_0 = '0'; const MONITOR_MODE_APPEND_1 = '1'; const MONITOR_SESSION_FILE_SEEK = 'monitor-seek-file'; diff --git a/extension/Classes/Core/Database/Database.php b/extension/Classes/Core/Database/Database.php index d22f9dcb31aa446a3b6dd5e5d0cce091a15a8c5e..bf54df5db85b29201f8020007bb61bc422978509 100644 --- a/extension/Classes/Core/Database/Database.php +++ b/extension/Classes/Core/Database/Database.php @@ -402,15 +402,17 @@ class Database { // Author: Enis Nuredini // Get method from query and remove it from the sql variable to get a valid mysqli_stmt. Store given method in system store - $needle = 'AS _encrypt='; - if (mb_strpos($sql, $needle)) { - $preparedSql = explode('AS _encrypt=', $sql); - $sql = $preparedSql[0] . ' AS _encrypt'; - $encryptionMethod = $preparedSql[1]; - $this->store::setVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, $encryptionMethod, STORE_SYSTEM); - } else if ($this->store::getVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, STORE_SYSTEM, SANITIZE_ALLOW_ALL) !== null) { - $this->store::unsetVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, STORE_SYSTEM); - } + // Krzysztof Putyra: commented out, because it breaks SQL statements. + // If this feature is needed, a smart parser must be used +// $needle = 'AS _encrypt='; +// if (mb_strpos($sql, $needle)) { +// $preparedSql = explode('AS _encrypt=', $sql); +// $sql = $preparedSql[0] . ' AS _encrypt'; +// $encryptionMethod = $preparedSql[1]; +// $this->store::setVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, $encryptionMethod, STORE_SYSTEM); +// } else if ($this->store::getVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, STORE_SYSTEM, SANITIZE_ALLOW_ALL) !== null) { +// $this->store::unsetVar(ENCRYPTION_CIPHER_METHOD_COLUMN_NAME, STORE_SYSTEM); +// } // End from author if (false === ($this->mysqli_stmt = $this->mysqli->prepare($sql))) { @@ -460,6 +462,7 @@ class Database { case 'SHOW': case 'DESCRIBE': case 'EXPLAIN': + case 'WITH': if (false === ($result = $this->mysqli_stmt->get_result())) { throw new \DbException( json_encode([ERROR_MESSAGE_TO_USER => 'Error DB execute', ERROR_MESSAGE_TO_DEVELOPER => '[ mysqli: ' . $this->mysqli_stmt->errno . ' ] ' . $this->mysqli_stmt->error . $specificMessage]), @@ -615,11 +618,15 @@ class Database { ['level', SYSTEM_REPORT_FULL_LEVEL, STORE_SYSTEM], ['form', SIP_FORM, STORE_SIP], ['fslId', EXTRA_FORM_SUBMIT_LOG_ID, STORE_EXTRA], + ['feId', SYSTEM_FORM_ELEMENT_ID, STORE_SYSTEM] ]; $t3msg = ''; foreach ($logArr as $logItem) { $value = $this->store->getVar($logItem[1], $logItem[2]); + if($logItem[0] == 'feId' && empty($value)) { + $value='all'; + } if (!empty($value)) { $t3msg .= $logItem[0] . ":" . $value . ","; } @@ -942,7 +949,7 @@ class Database { // Explode and Do $FormElement.parameter HelperFormElement::explodeParameterInArrayElements($feSpecNative, FE_PARAMETER); - // Check for retype FormElements which have to duplicated. + // Check for retype FormElements which have to be duplicated. $feSpecNative = HelperFormElement::duplicateRetypeElements($feSpecNative); // Copy Attributes to FormElements diff --git a/extension/Classes/Core/Database/DatabaseUpdateData.php b/extension/Classes/Core/Database/DatabaseUpdateData.php index 51e765a4c8adb13864dd96d2948c9dbd2d142e3e..784e50bb3a625bc2a858fd0ff419042dec52c076 100644 --- a/extension/Classes/Core/Database/DatabaseUpdateData.php +++ b/extension/Classes/Core/Database/DatabaseUpdateData.php @@ -227,6 +227,9 @@ $UPDATE_ARRAY = array( "ALTER TABLE `FormSubmitLog` ADD INDEX IF NOT EXISTS `createdFeUserFormId` (`created`, `feUser`, `formId`);", ], + '23.12.0' => [ + "CREATE TABLE IF NOT EXISTS `FileUpload` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `pathFileName` VARCHAR(512) NOT NULL, `grId` INT(11) NOT NULL DEFAULT '0', `xId` INT(11) NOT NULL DEFAULT '0', `uploadId` INT(11) NOT NULL, `size` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT 'Filesize in bytes', `type` VARCHAR(64) NOT NULL DEFAULT '', `ord` INT(11) NOT NULL DEFAULT '0', `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `uploadId` (`uploadId`), KEY `pathFileNameGrIdXidUploadId` (`pathFileName`, `grId`, `xId`, `uploadId`) USING BTREE ) ENGINE = InnoDB DEFAULT CHARSET = utf8 AUTO_INCREMENT = 0;" + ], ); diff --git a/extension/Classes/Core/Evaluate.php b/extension/Classes/Core/Evaluate.php index c82ba2f867dec33185f9746467b5a649851b4237..83305b1e5afe4486e4b2c53d19a1da8b21c86fa8 100644 --- a/extension/Classes/Core/Evaluate.php +++ b/extension/Classes/Core/Evaluate.php @@ -51,7 +51,7 @@ class Evaluate { private $startDelimiterLength = 0; private $endDelimiter = ''; private $endDelimiterLength = 0; - private $sqlKeywords = array('SELECT ', 'INSERT ', 'DELETE ', 'UPDATE ', 'SHOW ', 'REPLACE ', 'TRUNCATE ', 'DESCRIBE ', 'EXPLAIN ', 'SET '); + private $sqlKeywords = array('SELECT ', 'INSERT ', 'DELETE ', 'UPDATE ', 'SHOW ', 'REPLACE ', 'TRUNCATE ', 'DESCRIBE ', 'EXPLAIN ', 'SET ', 'WITH '); private $escapeTypeDefault = ''; private $report = null; diff --git a/extension/Classes/Core/Exception/AbstractException.php b/extension/Classes/Core/Exception/AbstractException.php index 84e905b7000f19d9b6c8fdc3ced586d618b8621c..a028633a481ddd2dbd3b164652aedc08cf5f4dcd 100644 --- a/extension/Classes/Core/Exception/AbstractException.php +++ b/extension/Classes/Core/Exception/AbstractException.php @@ -179,7 +179,7 @@ class AbstractException extends \Exception { $arrMerged[ERROR_MESSAGE_TO_DEVELOPER] .= "<br>(inline report editor not available)"; } - $htmlDebug = OnArray::arrayToHtmlTable( + $htmlDebug = '<div class="qfq-debug-detail qfq-alert-hidden">' . OnArray::arrayToHtmlTable( array_merge($arrForm, $arrMerged), 'Debug', EXCEPTION_TABLE_CLASS); @@ -189,11 +189,11 @@ class AbstractException extends \Exception { $hidden = OnArray::arrayToHtmlTable($arrDebugHiddenClean, 'Details', EXCEPTION_TABLE_CLASS); // Show / hide with just CSS: http://jsfiddle.net/t5Nf8/1/ - $htmlDebug .= "<style>input[type=checkbox]:checked + label + table { display: none; }</style>" . - "<input type='checkbox' checked id='stacktrace'><label for='stacktrace'> Show/hide more details</label>$hidden"; + $htmlDebug .= "<hr>$hidden</div>"; } } + $qfqLog = Path::absoluteQfqLogFile(); $arrDebugHidden[EXCEPTION_STACKTRACE] = PHP_EOL . implode(PHP_EOL, $arrTrace); $arrLogAll = array_merge($arrMsg, $arrShow, $arrDebugShow, $arrDebugHidden); @@ -273,7 +273,7 @@ class AbstractException extends \Exception { */ private function formatMessageUser($arrShow) { - $html = '<p><em>' . $arrShow[EXCEPTION_TIMESTAMP] . ', Reference: ' . $arrShow[EXCEPTION_UNIQID] . '</em></p>'; + $html = '<span class="qfq-alert-timestamp">' . $arrShow[EXCEPTION_TIMESTAMP] . '</span><span class="qfq-alert-reference">' . $arrShow[EXCEPTION_UNIQID] . '</span>'; $html .= '<p>' . $arrShow[EXCEPTION_MESSAGE] . '</p>'; return $html; diff --git a/extension/Classes/Core/Exception/InfoException.php b/extension/Classes/Core/Exception/InfoException.php new file mode 100644 index 0000000000000000000000000000000000000000..68317a1eda4d770530e903f81816f024d614bc29 --- /dev/null +++ b/extension/Classes/Core/Exception/InfoException.php @@ -0,0 +1,56 @@ +<?php + +/** + * Created by PhpStorm. + * User: jhaller + * Date: 09.11.2023 + * Time: 12:47 PM + */ + +use IMATHUZH\Qfq\Core\Exception\AbstractException; + +/** + * Class \InfoException + * + * Thrown by FormElement on User errors + * + * Throw with ONE message + * + * Throw new \InfoException('Failed: sqlValidate()...'); + * + * Throw with custom message for User. + * + * Call + * + * @package Exception + */ +class InfoException extends AbstractException { + + /** + * $this->getMessage() returns a simple string. + * + * There is only one message: shown in the client to the user - no details (timestamp, reference) here!!! + * + * @return string + * @throws \InfoException + */ + public function formatMessage() { + + return $this->formatException(); + } + + public function formatException() { + + // Get exception message and if JSON, decode it. + $msg = $this->getMessage(); + + return $this->formatMessageUser($msg); + } + + private function formatMessageUser($msg) { + + $html = '<p>' . $msg . '</p>'; + + return $html; + } +} \ No newline at end of file diff --git a/extension/Classes/Core/File.php b/extension/Classes/Core/File.php index 947aad7de715acf91172c45d4ed49e314476e2b4..e20684a1459c3c88ef4c336be2f8cd40f4d5050b 100644 --- a/extension/Classes/Core/File.php +++ b/extension/Classes/Core/File.php @@ -9,11 +9,13 @@ namespace IMATHUZH\Qfq\Core; +use IMATHUZH\Qfq\Core\Database\Database; use IMATHUZH\Qfq\Core\Helper\HelperFile; use IMATHUZH\Qfq\Core\Helper\Logger; use IMATHUZH\Qfq\Core\Helper\Path; use IMATHUZH\Qfq\Core\Helper\Sanitize; use IMATHUZH\Qfq\Core\Helper\Support; +use IMATHUZH\Qfq\Core\Report\Link; use IMATHUZH\Qfq\Core\Store\Session; use IMATHUZH\Qfq\Core\Store\Store; @@ -25,6 +27,11 @@ class File { private $uploadErrMsg = array(); public $imageUploadFilePath = null; + public $uniqueFileId = null; + public $groupId = null; + public $sipTmp = null; + public $newFileName = null; + private $db = null; /** * @var Store @@ -52,6 +59,19 @@ class File { $this->store = Store::getInstance('', $phpUnit); $this->qfqLogPathFilenameAbsolute = Path::absoluteQfqLogFile(); + $this->dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM); + $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM); + + // Default is qfq index + $this->dbIndexFilePond = $this->dbIndexQfq; + $dbIndexSip = $this->store->getVar(TOKEN_DB_INDEX, STORE_SIP . STORE_EMPTY); + if (in_array($dbIndexSip, [$this->dbIndexData, $this->dbIndexQfq])) { + $this->dbIndexFilePond = $dbIndexSip; + } + $this->dbArray[$this->dbIndexFilePond] = new Database($this->dbIndexFilePond); + + $this->sip = $this->store->getSipInstance(); + $this->uploadErrMsg = [ UPLOAD_ERR_INI_SIZE => "The uploaded file exceeds the upload_max_filesize directive in php.ini", UPLOAD_ERR_FORM_SIZE => "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form", @@ -69,7 +89,7 @@ class File { */ public function process() { - $action = $this->store->getVar(FILE_ACTION, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX); + $action = $this->store->getVar(FILE_ACTION, STORE_CLIENT . STORE_SIP, SANITIZE_ALLOW_ALNUMX); $sipUpload = $this->store->getVar(SIP_SIP, STORE_SIP); if ($sipUpload === false && $action != FILE_ACTION_IMAGE_UPLOAD) { @@ -91,10 +111,12 @@ class File { if ($statusUpload === false) { $statusUpload = array(); } + $statusUpload[UPLOAD_SIP_DOWNLOAD_KEY] = $this->store->getVar(UPLOAD_SIP_DOWNLOAD_KEY, STORE_SIP); switch ($action) { case FILE_ACTION_UPLOAD: + case FILE_ACTION_UPLOAD_2: $this->doUpload($sipUpload, $statusUpload); break; case FILE_ACTION_DELETE: @@ -121,7 +143,7 @@ class File { // Checked and set during form build. $maxFileSize = $this->store->getVar(FE_FILE_MAX_FILE_SIZE, STORE_SIP . STORE_ZERO); - if ($size >= $maxFileSize) { + if ($size >= $maxFileSize && $maxFileSize != 0) { throw new \UserFormException('File too big. Max allowed size: ' . $maxFileSize . ' Bytes', ERROR_UPLOAD_TOO_BIG); } } @@ -137,6 +159,9 @@ class File { */ private function doUpload($sipUpload, array $statusUpload) { + // Upload client + $action = $this->store->getVar(FILE_ACTION, STORE_SIP, SANITIZE_ALLOW_ALNUMX); + // New upload $newArr = reset($_FILES); // Merge new upload date to existing status information @@ -151,9 +176,11 @@ class File { Logger::logMessageWithPrefix(UPLOAD_LOG_PREFIX . ': File under ' . $statusUpload['tmp_name'], $this->qfqLogPathFilenameAbsolute); - $this->checkMaxFileSize($statusUpload['size']); + if (!empty($sipUpload)) { + $this->checkMaxFileSize($statusUpload[FILES_SIZE]); + } - $accept = $this->store->getVar(FE_FILE_MIME_TYPE_ACCEPT, STORE_SIP); + $accept = $this->store->getVar(FE_FILE_MIME_TYPE_ACCEPT, STORE_SIP . STORE_EMPTY); if ($accept != '' && !HelperFile::checkFileType($statusUpload['tmp_name'], $statusUpload['name'], $accept)) { throw new \UserFormException('Filetype not allowed. Allowed: ' . $accept, ERROR_UPLOAD_FILE_TYPE); } @@ -163,11 +190,87 @@ class File { // Remember: PHP['upload_tmp_dir']='' means '/tmp' AND upload process is CHROOT to /tmp/systemd-private-...-apache2.service-../ error_clear_last(); - if (!move_uploaded_file($newArr[FILES_TMP_NAME], $filenameCached)) { - $msg = error_get_last(); - throw new \UserFormException( - json_encode([ERROR_MESSAGE_TO_USER => 'Upload: Error', ERROR_MESSAGE_TO_DEVELOPER => $msg]), - ERROR_UPLOAD_FILE_TYPE); + if ($action === FILE_ACTION_UPLOAD_2) { + // Generate a unique file ID + $this->uniqueFileId = $_POST[UPLOAD_ID]; + $fullPath = $_POST[UPLOAD_PATH_FILE_NAME]; + $pathDefault = $_POST[UPLOAD_PATH_DEFAULT]; + $groupId = $_POST['groupId']; + $recordData = $this->store->getVar(UPLOAD_RECORD_DATA, STORE_SIP); + $table = $this->store->getVar(SIP_TABLE, STORE_SIP); + $filename = $statusUpload[FILES_NAME]; + + if ($pathDefault === 'undefined') { + $filenameDummy = basename($fullPath); + $fileParts = pathinfo($filenameDummy); + + if (isset($fileParts['extension'])) { + $filename = $filenameDummy; + $fullPath = dirname($fullPath) . '/'; + } else if (substr($fullPath, -1) !== '/'){ + $fullPath .= '/'; + } + } + + if ($this->uniqueFileId == $groupId) { + $this->uniqueFileId = 0; + } + $filename = Sanitize::safeFilename($filename); + + $fullPath = Path::absoluteApp($fullPath); + $changedFileName = HelperFile::getUniqueFileName($fullPath, $filename); + $fullPath = $fullPath . $changedFileName; + + HelperFile::mkDirParent($fullPath); + if (!move_uploaded_file($newArr[FILES_TMP_NAME], $fullPath)) { + $msg = error_get_last(); + throw new \UserFormException( + json_encode([ERROR_MESSAGE_TO_USER => 'Upload: Error', ERROR_MESSAGE_TO_DEVELOPER => $msg]), + ERROR_UPLOAD_FILE_TYPE); + } + + if ($this->uniqueFileId == 0) { + $insertColumns = ''; + $insertValues = []; + $prepareQuestions = ''; + if ($recordData != '') { + $recordData = json_decode($recordData); + foreach ($recordData as $key => $value) { + $insertColumns .= ',' . $key; + $insertValues[] = $value; + $prepareQuestions .= ',?'; + } + } + $params = array_merge([$fullPath, $groupId, $statusUpload[FILES_SIZE], $statusUpload[FILES_TYPE], 0], $insertValues); + // Do database insert with unique file ID and pathFileName + $dbName = $this->store->getVar(SYSTEM_DB_NAME_QFQ, STORE_SYSTEM); + $sql = "INSERT INTO `$dbName`.`$table` (pathFileName, uploadId, size, type, ord $insertColumns) + VALUES (?,?,?,?,? $prepareQuestions)"; + $this->uniqueFileId = $this->dbArray[$this->dbIndexFilePond]->sql($sql, ROW_REGULAR, $params, "Creating FileUpload failed."); + + if ($groupId == 0) { + $groupId = $this->uniqueFileId; + $sqlUpdate = "UPDATE `$dbName`.`$table` SET uploadId = ? WHERE id = ?"; + $this->dbArray[$this->dbIndexFilePond]->sql($sqlUpdate, ROW_REGULAR, [$this->uniqueFileId, $this->uniqueFileId], "Updating FileUpload failed."); + } + } + $this->groupId = $groupId; + $this->logUpload($this->uniqueFileId); + } else { + if (!move_uploaded_file($newArr[FILES_TMP_NAME], $filenameCached)) { + $msg = error_get_last(); + throw new \UserFormException( + json_encode([ERROR_MESSAGE_TO_USER => 'Upload: Error', ERROR_MESSAGE_TO_DEVELOPER => $msg]), + ERROR_UPLOAD_FILE_TYPE); + } + + // Make currently uploaded file downloadable + $link = new Link($this->sip, $this->dbIndexData); + $sipDownload = $link->renderLink('', 's|d:output|M:file|r:8|F:' . $filenameCached); + $this->sipTmp = $sipDownload; + + // Reset sipDownloadKey after fresh upload + $this->store->setVar($statusUpload[UPLOAD_SIP_DOWNLOAD_KEY], array(), STORE_EXTRA); } $this->store->setVar($sipUpload, $statusUpload, STORE_EXTRA); @@ -182,17 +285,52 @@ class File { * @internal param string $keyStoreExtra */ private function doDelete($sipUpload, $statusUpload) { + $action = $this->store::getVar(FILE_ACTION, STORE_SIP . STORE_EMPTY); + + // Handle FilePond delete + if ($action == FILE_ACTION_DELETE) { + $uploadId = $_POST[UPLOAD_ID]; + $table = $this->store::getVar(SIP_TABLE, STORE_SIP . STORE_EMPTY);; + $allowDelete = $this->store::getVar('allowDelete', STORE_SIP . STORE_EMPTY); + $preloadedFileList = $this->store::getVar('preloadedFileIds', STORE_SIP . STORE_EMPTY); + $preloadedFileArray = explode(',', $preloadedFileList); + $pathFileName = ''; + + // Check if uploadId exists in preloaded files and if it is allowed to delete + if ($allowDelete == '0' && in_array($uploadId, $preloadedFileArray)) { + return; + } - if (isset($statusUpload[FILES_TMP_NAME]) && $statusUpload[FILES_TMP_NAME] != '') { - $file = Support::extendFilename($statusUpload[FILES_TMP_NAME], UPLOAD_CACHED); - if (file_exists($file)) { - HelperFile::unlink($file, $this->qfqLogPathFilenameAbsolute); + // Get path from database + $sqlPath = "SELECT pathFileName FROM `$table` WHERE id = ?"; + $result = $this->dbArray[$this->dbIndexFilePond]->sql($sqlPath, ROW_EXPECT_1, [$uploadId],"File not found in database."); + + if (!empty($result[UPLOAD_PATH_FILE_NAME])) { + $pathFileName = $result[UPLOAD_PATH_FILE_NAME]; } - $statusUpload[FILES_TMP_NAME] = ''; - } - $statusUpload[FILES_FLAG_DELETE] = '1'; - $this->store->setVar($sipUpload, $statusUpload, STORE_EXTRA); + // Delete file from database with unique file id + $sqlPath = "DELETE FROM `$table` WHERE id = ?"; + $this->dbArray[$this->dbIndexFilePond]->sql($sqlPath, ROW_EXPECT_1, [$uploadId],"File not deleted from database."); + + // Delete file from server + HelperFile::unlink($pathFileName); + + $this->logUpload($uploadId, true); + + $this->uniqueFileId = 0; + } else { + if (isset($statusUpload[FILES_TMP_NAME]) && $statusUpload[FILES_TMP_NAME] != '') { + $file = Support::extendFilename($statusUpload[FILES_TMP_NAME], UPLOAD_CACHED); + if (file_exists($file)) { + HelperFile::unlink($file, $this->qfqLogPathFilenameAbsolute); + } + $statusUpload[FILES_TMP_NAME] = ''; + } + + $statusUpload[FILES_FLAG_DELETE] = '1'; + $this->store->setVar($sipUpload, $statusUpload, STORE_EXTRA); + } } /** @@ -274,4 +412,31 @@ class File { // Respond to the successful upload with JSON. return $baseUrl . $imageUploadDir . $neededSlash . $changedFileName; } + + private function logUpload($uploadId, $deleteFlag = false, $uploadType = 'report'): void { + $formName = '_uploadInReport'; + if ($uploadType === 'form') { + $formName = '_uploadInForm'; + } + + $recordId = $uploadId; + $pageId = $this->store->getVar(TYPO3_PAGE_ID, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX); + $sessionId = session_id(); + $feUser = $this->store->getVar(TYPO3_FE_USER, STORE_TYPO3, SANITIZE_ALLOW_ALNUMX); + $clientIp = $this->store->getVar(CLIENT_REMOTE_ADDRESS, STORE_CLIENT . STORE_EMPTY); + $userAgent = $this->store->getVar(CLIENT_HTTP_USER_AGENT, STORE_CLIENT . STORE_EMPTY); + $sipData = json_encode($this->store->getStore(STORE_SIP), JSON_UNESCAPED_UNICODE); + $formData = $deleteFlag ? 'deleted' : 'uploaded'; + $formId = 0; + + $sql = "INSERT INTO `FormSubmitLog` (`formData`, `sipData`, `clientIp`, `feUser`, `userAgent`, `formId`, `formName`, `recordId`, `pageId`, `sessionId`, `created`)" . + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())"; + + $params = [$formData, $sipData, $clientIp, $feUser, $userAgent, $formId, $formName, $recordId, $pageId, $sessionId]; + + $this->dbArray[$this->dbIndexFilePond]->sql($sql, ROW_REGULAR, $params); + $formSubmitLogId = $this->dbArray[$this->dbIndexFilePond]->getLastInsertId(); + $this->store::setVar(EXTRA_FORM_SUBMIT_LOG_ID, $formSubmitLogId, STORE_EXTRA); + + } } \ No newline at end of file diff --git a/extension/Classes/Core/Form/FormAction.php b/extension/Classes/Core/Form/FormAction.php index 353844d372e4f7106062c4f214807294c1bbf6ad..c99ccc28a532334b7f78f00e285e213c1eea37da 100644 --- a/extension/Classes/Core/Form/FormAction.php +++ b/extension/Classes/Core/Form/FormAction.php @@ -123,7 +123,6 @@ class FormAction { * @throws \DbException * @throws \DownloadException * @throws \UserFormException - * @throws \UserReportException * @throws \PhpOffice\PhpSpreadsheet\Exception * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception @@ -212,7 +211,10 @@ class FormAction { $this->store->setStore($arr, STORE_LDAP, true); } - HelperFormElement::sqlValidate($this->evaluate, $fe); + // sqlValidate should not be checked if submit_reason = "save,force" + if (API_SUBMIT_REASON_SAVE_FORCE !== $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX )) { + HelperFormElement::sqlValidate($this->evaluate, $fe); + } if ($fe[FE_TYPE] === FE_TYPE_SENDMAIL) { $this->doSendMail($fe); @@ -228,6 +230,8 @@ class FormAction { break; } } + $this->store->setVar(SYSTEM_FORM_ELEMENT, '', STORE_SYSTEM); + $this->store->setVar(SYSTEM_FORM_ELEMENT_ID, 0, STORE_SYSTEM); return $rc; } diff --git a/extension/Classes/Core/Helper/HelperFormElement.php b/extension/Classes/Core/Helper/HelperFormElement.php index e2a485b63559a18842c492cb1fc40f092dd5044a..bdd88c9ee2015bd350449e71bfcf46b740379b06 100644 --- a/extension/Classes/Core/Helper/HelperFormElement.php +++ b/extension/Classes/Core/Helper/HelperFormElement.php @@ -11,7 +11,6 @@ namespace IMATHUZH\Qfq\Core\Helper; use IMATHUZH\Qfq\Core\Evaluate; use IMATHUZH\Qfq\Core\Store\Store; - /** * Class HelperFormElement * @package qfq @@ -86,6 +85,11 @@ class HelperFormElement { // Something to explode? if (isset($element[$keyName]) && $element[$keyName] !== '') { + + self::$store = Store::getInstance(); + self::$store->setVar(SYSTEM_FORM_ELEMENT_ID, $element[FE_ID], STORE_SYSTEM); + self::$store->setVar(FORM_NAME_FORM_ELEMENT, $element[FE_ID] . ' / ' . $element[FE_NAME] . ' / ', STORE_SYSTEM); + // Explode $arr = KeyValueStringParser::parse($element[$keyName], "=", "\n"); @@ -97,7 +101,7 @@ class HelperFormElement { self::$store = Store::getInstance(); self::$store->setVar(SYSTEM_FORM_ELEMENT, Logger::formatFormElementName($element), STORE_SYSTEM); self::$store->setVar(SYSTEM_FORM_ELEMENT_COLUMN, $keyName, STORE_SYSTEM); - throw new \UserFormException("Found reserved keyname '$checkKey'", ERROR_RESERVED_KEY_NAME); + throw new \UserFormException("Found reserved key name '$checkKey'", ERROR_RESERVED_KEY_NAME); } } } @@ -167,7 +171,7 @@ class HelperFormElement { /** * Build the FE id: <$formId>-<$formElementId>-<$formElementCopy> - * Attention: Radio's get's an additional index count as fourth parameter (not here). + * Attention: Radio's gets an additional index count as fourth parameter (not here). * * @param $formId * @param $formElementId @@ -208,7 +212,7 @@ class HelperFormElement { } /** - * Checkboxen, belonging to one element, grouped together by name: <fe>_<field>_<index> + * Checkboxes, belonging to one element, grouped together by name: <fe>_<field>_<index> * * @param string $field * @param string $index @@ -263,7 +267,7 @@ class HelperFormElement { unset($fe[FE_RETYPE_NOTE]); } - $fe[FE_TG_INDEX] = 1; // Not sure if this is helpfull in case of dynamic update - but it will make the element uniqe. + $fe[FE_TG_INDEX] = 1; // Not sure if this is helpful in case of dynamic update - but it will make the element unique. unset($fe[FE_RETYPE]); $arr[] = $fe; @@ -315,7 +319,7 @@ class HelperFormElement { public static function initActionFormElement(array $fe) { $list = [FE_TYPE, FE_SQL_VALIDATE, FE_SLAVE_ID, FE_SQL_BEFORE, FE_SQL_INSERT, FE_SQL_UPDATE, FE_SQL_DELETE, - FE_SQL_AFTER, FE_EXPECT_RECORDS, FE_REQUIRED_LIST, FE_MESSAGE_FAIL, FE_SENDMAIL_TO, FE_SENDMAIL_CC, + FE_SQL_AFTER, FE_EXPECT_RECORDS, FE_REQUIRED_LIST, FE_ALERT, FE_QFQ_LOG, FE_MESSAGE_FAIL, FE_SENDMAIL_TO, FE_SENDMAIL_CC, FE_SENDMAIL_BCC, FE_SENDMAIL_FROM, FE_SENDMAIL_SUBJECT, FE_SENDMAIL_REPLY_TO, FE_SENDMAIL_FLAG_AUTO_SUBMIT, FE_SENDMAIL_GR_ID, FE_SENDMAIL_X_ID, FE_SENDMAIL_X_ID2, FE_SENDMAIL_X_ID3, FE_SENDMAIL_BODY_MODE, FE_SENDMAIL_BODY_HTML_ENTITY, FE_SENDMAIL_SUBJECT_HTML_ENTITY]; @@ -552,7 +556,7 @@ EOF; $classArr[FE_NOTE] = CSS_REQUIRED_RIGHT; break; default: - throw new \UserFormException('Unkown value for ' . F_FE_REQUIRED_POSITION . ': ' . $requiredPosition, ERROR_INVALID_VALUE); + throw new \UserFormException('Unknown value for ' . F_FE_REQUIRED_POSITION . ': ' . $requiredPosition, ERROR_INVALID_VALUE); } return $classArr; @@ -601,7 +605,7 @@ EOF; self::$store = Store::getInstance(); - // Call getItemsForEnumOrSet() only if there a corresponding column really exist. + // Call getItemsForEnumOrSet() only if a corresponding column really exists. if (false !== self::$store->getVar($formElement[FE_NAME], STORE_TABLE_COLUMN_TYPES)) { $itemValue = self::getItemsForEnumOrSet($formElement[FE_NAME], $fieldType); } @@ -884,6 +888,7 @@ EOF; * @throws \DbException * @throws \UserFormException * @throws \UserReportException + * @throws \InfoException */ public static function sqlValidate(Evaluate $evaluate, array $fe) { @@ -897,10 +902,28 @@ EOF; } $expect = $evaluate->parse($fe[FE_EXPECT_RECORDS]); - if ($fe[FE_MESSAGE_FAIL] === '') { - throw new \UserFormException("Missing parameter '" . FE_MESSAGE_FAIL . "'", ERROR_MISSING_MESSAGE_FAIL); + // ToDo: how to go about the change from messageFail to alert? messageFail is still supported at the moment. + if ($fe[FE_ALERT] === '' && $fe[FE_MESSAGE_FAIL] === '') { + throw new \UserFormException("Missing parameter '" . FE_ALERT . "'", ERROR_MISSING_ALERT); + } else if ($fe[FE_ALERT] !== '' && $fe[FE_MESSAGE_FAIL] !== '') { + throw new \UserFormException("Simultaneous use of parameter '" . FE_ALERT . "' and '" . FE_MESSAGE_FAIL . "'. It is recommended to use '" . FE_ALERT . "', '" . FE_MESSAGE_FAIL . "' will no longer be maintained.", ERROR_DOUBLE_USAGE_ALERT_AND_MESSAGE_FAIL); } + // Replace possible dynamic parts + $alert = $evaluate->parse($fe[FE_ALERT]); + + $arr = OnArray::explodeWithoutEscaped(':', $alert); + $arr = array_merge($arr, ['', '', '', '', '', '']); + + $text = ($arr[FE_ALERT_INDEX_TEXT] === '') ? $fe[FE_MESSAGE_FAIL] : $arr[FE_ALERT_INDEX_TEXT]; + $level = ($arr[FE_ALERT_INDEX_LEVEL] === '') ? DEFAULT_ALERT_LEVEL : $arr[FE_ALERT_INDEX_LEVEL]; + $ok = ($arr[FE_ALERT_INDEX_BUTTON_OK] === '') ? DEFAULT_ALERT_BUTTON_OK : $arr[FE_ALERT_INDEX_BUTTON_OK]; + $force = $arr[FE_ALERT_INDEX_BUTTON_FORCE]; + $timeout = ($arr[FE_ALERT_INDEX_TIMEOUT] === '') ? DEFAULT_ALERT_TIMEOUT : $arr[FE_ALERT_INDEX_TIMEOUT] * 1000; + $flagModalStatus = ($arr[FE_ALERT_INDEX_FLAG_MODAL] === '') ? DEFAULT_ALERT_FLAG_MODAL : $arr[FE_ALERT_INDEX_FLAG_MODAL]; + $flagModal = $flagModalStatus === '1'; + $qfqLog = $fe[FE_QFQ_LOG] !== '0'; + // Do the check $result = $evaluate->parse($fe[FE_SQL_VALIDATE], ROW_REGULAR); if (!is_array($result)) { @@ -915,12 +938,28 @@ EOF; } } - $msg = $evaluate->parse($fe[FE_MESSAGE_FAIL]); // Replace possible dynamic parts + $msg = $evaluate->parse($text); // Replace possible dynamic parts in case messageFail is used + + self::$store = Store::getInstance(); + self::$store->setVar(FE_ALERT_TEXT, $text, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_LEVEL, $level, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_BUTTON_OK, $ok, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_BUTTON_FORCE, $force, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_TIMEOUT, $timeout, STORE_SYSTEM); + self::$store->setVar(FE_ALERT_FLAG_MODAL, $flagModal, STORE_SYSTEM); + self::$store->setVar(SYSTEM_SHOW_DEBUG_INFO, SYSTEM_SHOW_DEBUG_INFO_AUTO, STORE_SYSTEM); // No debug info // Throw user error message - throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => $msg - , ERROR_MESSAGE_TO_DEVELOPER => "validate() failed.\nSQL Raw: " . $fe[FE_SQL_VALIDATE]]) - , ERROR_REPORT_FAILED_ACTION); + if ($qfqLog) { + // Error including timestamp and reference (logged) + throw new \UserFormException(json_encode([ERROR_MESSAGE_TO_USER => $msg + , ERROR_MESSAGE_TO_DEVELOPER => "validate() failed.\nSQL Raw: " . $fe[FE_SQL_VALIDATE]]) + , ERROR_REPORT_FAILED_ACTION); + } else { + + // Plain message (not logged) + throw new \InfoException($msg); + } } } \ No newline at end of file diff --git a/extension/Classes/Core/Helper/OnArray.php b/extension/Classes/Core/Helper/OnArray.php index 9191ff6b8b4f82012b67fdd4c4a9d4201ff7856d..1625ffdd0fb9e95c1682fcd3dbda0be349abf237 100644 --- a/extension/Classes/Core/Helper/OnArray.php +++ b/extension/Classes/Core/Helper/OnArray.php @@ -138,7 +138,9 @@ class OnArray { */ public static function htmlentitiesOnArray(array $arr) { foreach ($arr as $key => $value) { - $arr[$key] = htmlentities($arr[$key], ENT_QUOTES); + + // Error message stays the same + if ($key !== EXCEPTION_MESSAGE) $arr[$key] = htmlentities($arr[$key], ENT_QUOTES); } return $arr; diff --git a/extension/Classes/Core/QuickFormQuery.php b/extension/Classes/Core/QuickFormQuery.php index 34855b931a7e55ab27ea89beb3d3540be76a4495..23dfb9cf438294fcd224f07a9af4bb6068c28010 100644 --- a/extension/Classes/Core/QuickFormQuery.php +++ b/extension/Classes/Core/QuickFormQuery.php @@ -520,7 +520,7 @@ class QuickFormQuery { } } - // Check 'session expire' happens quite late, cause it can be configured per form. + // Check 'session expire' happens quite late, because it can be configured per form. if ($formName !== false) { Session::checkSessionExpired($this->formSpec[F_SESSION_TIMEOUT_SECONDS]); } @@ -723,7 +723,7 @@ class QuickFormQuery { $getJson = true; if (0 == $this->store->getVar(SIP_RECORD_ID, STORE_SIP) && (($this->formSpec[F_FORWARD_MODE] == F_FORWARD_MODE_AUTO - && API_SUBMIT_REASON_SAVE == $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX) + && (API_SUBMIT_REASON_SAVE == $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX) || API_SUBMIT_REASON_SAVE_FORCE == $this->store->getVar(API_SUBMIT_REASON, STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX)) ) || $this->formSpec[F_FORWARD_MODE] == F_FORWARD_MODE_NO)) { $this->formSpec = $this->buildNSetReloadUrl($this->formSpec, $rc); $getJson = false; @@ -1388,7 +1388,7 @@ class QuickFormQuery { // Explode and Do $FormElement.parameter HelperFormElement::explodeParameterInArrayElements($feSpecNative, FE_PARAMETER); - // Check for retype FormElements which have to duplicated. + // Check for retype FormElements which have to be duplicated. $feSpecNative = HelperFormElement::duplicateRetypeElements($feSpecNative); // Check for templateGroup Elements to explode them diff --git a/extension/Classes/Core/Report/Download.php b/extension/Classes/Core/Report/Download.php index cdc56ca88f73c81a1b6caf7f34b8d8c9ee802185..3337fa71df95b2f7cba152c205d502c12c2f2f5c 100644 --- a/extension/Classes/Core/Report/Download.php +++ b/extension/Classes/Core/Report/Download.php @@ -90,6 +90,16 @@ class Download { $this->db = new Database(); $this->html2pdf = new Html2Pdf($this->store->getStore(STORE_SYSTEM), $phpUnit); + // Filepond uses per default qfq index db or a custom one. + $this->dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM); + $this->dbIndexQfq = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM); + $this->dbIndexFilePond = $this->dbIndexQfq; + $dbIndexSip = $this->store->getVar(TOKEN_DB_INDEX, STORE_SIP . STORE_EMPTY); + if (in_array($dbIndexSip, [$this->dbIndexData, $this->dbIndexQfq])) { + $this->dbIndexFilePond = $dbIndexSip; + } + $this->dbArray[$this->dbIndexFilePond] = new Database($this->dbIndexFilePond); + if (Support::findInSet(SYSTEM_SHOW_DEBUG_INFO_DOWNLOAD, $this->store->getVar(SYSTEM_SHOW_DEBUG_INFO, STORE_SYSTEM))) { $this->downloadDebugLogAbsolute = Path::absoluteSqlLogFile(); } @@ -704,14 +714,24 @@ class Download { * @throws \UserReportException */ private function doElements(array $vars, $outputMode) { - + $action = $this->store::getVar(FILE_ACTION, STORE_SIP); $srcFiles = array(); $filesCleanLater = array(); $workDir = Path::absoluteApp(); - HelperFile::chdir($workDir); - $downloadMode = $vars[DOWNLOAD_MODE]; + if ($action == FILE_ACTION_DOWNLOAD) { + $downloadMode = DOWNLOAD_MODE_FILE; + $table = $this->store::getVar(SIP_TABLE, STORE_SIP); + $uploadId = $_GET[UPLOAD_ID]; + // Get pathFileName from database + $sql = "SELECT pathFileName FROM `$table` WHERE id = ?"; + $result = $this->dbArray[$this->dbIndexFilePond]->sql($sql, ROW_EXPECT_1, [$uploadId]); + $vars[SIP_DOWNLOAD_PARAMETER] = 'F:' . $result[UPLOAD_PATH_FILE_NAME]; + } else { + $downloadMode = $vars[DOWNLOAD_MODE]; + HelperFile::chdir($workDir); + } if ($downloadMode == DOWNLOAD_MODE_MONITOR) { $monitor = new Monitor(); @@ -843,7 +863,7 @@ class Download { break; default: - throw new \CodeException('Unkown mode: ' . $outputMode, ERROR_UNKNOWN_MODE); + throw new \CodeException('Unknown mode: ' . $outputMode, ERROR_UNKNOWN_MODE); } HelperFile::cleanTempFiles($filesCleanLater); @@ -962,6 +982,14 @@ class Download { $vars = $this->store->getStore(STORE_SIP); + // Check if something exists in store extra for new upload path after save form + $sipDownloadKey = $this->store->getVar(UPLOAD_SIP_DOWNLOAD_KEY, STORE_CLIENT, SANITIZE_ALLOW_ALNUMX); + $storeExtra = $this->store->getVar($sipDownloadKey, STORE_EXTRA); + if (isset($storeExtra[SIP_DOWNLOAD_PARAMETER])) { + $vars[SIP_DOWNLOAD_PARAMETER] = $storeExtra[SIP_DOWNLOAD_PARAMETER]; + $vars[DOWNLOAD_EXPORT_FILENAME] = ''; + } + if ($vars === array()) { // No SIP >> this seems to be a DirectDownloadMode $vars = $this->getDirectDownloadModeDetails(); diff --git a/extension/Classes/Core/Report/Report.php b/extension/Classes/Core/Report/Report.php index ce3b53ea6aa9d562efaae067468b4751d2cad035..60c1f9ac3c8cbe2337dcad58668dfa12fc7f8a45 100644 --- a/extension/Classes/Core/Report/Report.php +++ b/extension/Classes/Core/Report/Report.php @@ -853,10 +853,11 @@ class Report { // Author: Enis Nuredini // Split and get given encryption method from column name + // Fixed temporarily by Krzysztof Putyra $encryptionMethod = null; $key = $arr[0]; $keyArray = explode('=', $key, 2); - if (isset($keyArray[1]) && $keyArray[1] !== '' && $keyArray[0] === COLUMN_ENCRYPT) { + if (isset($keyArray[1]) && $keyArray[1] !== '' && $keyArray[0] === TOKEN_COLUMN_CTRL . COLUMN_ENCRYPT) { $encryptionMethod = $keyArray[1]; $arr[0] = $keyArray[0]; } @@ -1441,6 +1442,10 @@ class Report { } break; // End from author + + case COLUMN_UPLOAD: + $content .= $this->createInlineUpload($columnValue); + break; default : $flagOutput = false; @@ -1857,4 +1862,159 @@ class Report { return false; } + /** + * @param $columnValue + * + * @return string + */ + private function createInlineUpload($columnValue) { + $uploadId = 0; + $table = ''; + $recordData = ''; + $acceptType = ''; + $dbIndexParam = ''; + $dbIndex = $this->store->getVar(SYSTEM_DB_INDEX_QFQ, STORE_SYSTEM); + $dbIndexData = $this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM); + $maxFileSize = 0; + $multiUpload = 0; + $defaultText = 'Drag & Drop or <span class="btn btn-default filepond--label-action"> Browse </span>'; + $sipValuePreloadedFiles = '&allowDelete=none'; + + $defaultPath = 'fileadmin/protected/upload/'. date("Y") . '/'; + + $baseUrl = $this->store::getVar(SYSTEM_BASE_URL, STORE_SYSTEM); + $apiUrls['upload'] = $baseUrl . 'typo3conf/ext/qfq/Classes/Api/file.php'; + $apiUrls['download'] = $baseUrl . 'typo3conf/ext/qfq/Classes/Api/download.php'; + $encodedApiUrls = htmlspecialchars(json_encode($apiUrls, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + + $jsonConfig = array(); + $preloadedFiles = ''; + $jsonConfig[UPLOAD_TEXT] = $defaultText; + + // Define defaults + $defaultValues = 'x:1|table:FileUpload|M:0|maxFileSize:null|accept:null|maxFiles:null|allowUpload:true'; + $assocDefault = KeyValueStringParser::explodeKvpSimple($defaultValues, PARAM_TOKEN_DELIMITER, PARAM_DELIMITER); + + // Return an assoc array like [ 'd' => 'file.pdf', 'p' => 'content', ... ] + $assocGiven = KeyValueStringParser::explodeKvpSimple($columnValue, PARAM_TOKEN_DELIMITER, PARAM_DELIMITER); + $finalArray = array_merge($assocDefault, $assocGiven); + + foreach ($finalArray as $token => $value) { + switch ($token) { + case TOKEN_UPLOAD_ID: + $uploadId = $value; + $jsonConfig[UPLOAD_ID] = $value; + break; + case TOKEN_FILE: + $jsonConfig[UPLOAD_PATH_FILE_NAME] = $value; + break; + case TOKEN_UPLOAD_MIME_TYPE_ACCEPT: + $jsonConfig[UPLOAD_MIME_TYPE_ACCEPT] = $value; + if ($value != 'null') { + $acceptType = '&accept=' . $value; + } + break; + case TOKEN_UPLOAD_DELETE: + if ($value == '1' || $value == '') { + $value = true; + } else { + $value = false; + } + $jsonConfig[UPLOAD_DELETE_OPTION] = $value; + break; + case TOKEN_SIP_TABLE: + $jsonConfig[SIP_TABLE] = $value; + $table = $value; + break; + case TOKEN_UPLOAD_RECORD_DATA: + $jsonConfig[UPLOAD_RECORD_DATA] = KeyValueStringParser::explodeKvpSimple(trim($value), PARAM_TOKEN_DELIMITER, ','); + $recordData = '&recordData=' . json_encode($jsonConfig[UPLOAD_RECORD_DATA]); + break; + case TOKEN_UPLOAD_MAX_FILE_SIZE: + $jsonConfig[UPLOAD_MAX_FILE_SIZE] = $value; + if ($value != '0') { + $maxFileSize = '&maxFileSize=' . $value; + } + break; + case TOKEN_UPLOAD_MULTI_UPLOAD: + if ($value == '' || $value == 1) { + $value = true; + $multiUpload = 1; + } + $jsonConfig[UPLOAD_MULTI_UPLOAD] = $value; + break; + // currently not used. Maybe useful for later implementation. + case TOKEN_UPLOAD_IMAGE_EDITOR: + if ($value == '' || $value == 1) { + $value = true; + } + $jsonConfig[UPLOAD_IMAGE_EDITOR] = $value; + break; + case TOKEN_UPLOAD_ALLOW: + if ($value == "0") { + $value = false; + } + $jsonConfig[UPLOAD_ALLOW] = $value; + break; + case TOKEN_TEXT: + $jsonConfig[UPLOAD_TEXT] = $value; + break; + case TOKEN_UPLOAD_MAX_FILES: + $jsonConfig[UPLOAD_MAX_FILES] = $value; + break; + case TOKEN_DB_INDEX: + $dbIndexParam = '&dbIndex=' . $value; + if (in_array($value, [$dbIndexData, $this->dbIndexQfq])) { + $dbIndex = $value; + } + break; + } + } + + if (!isset($jsonConfig[UPLOAD_PATH_FILE_NAME])) { + $jsonConfig[UPLOAD_PATH_FILE_NAME] = $defaultPath; + $jsonConfig[UPLOAD_PATH_DEFAULT] = 1; + } + + // Get all records from given $recordId and $table + if ($uploadId != 0) { + if ($multiUpload) { + $sqlPath = 'SELECT id, pathFileName, size, type FROM '. $table . ' WHERE uploadId = ? ORDER BY created DESC'; + $groupId = $uploadId; + } else { + $sqlPath = 'SELECT id, pathFileName, size, type FROM '. $table . ' WHERE id = ? ORDER BY created DESC'; + $sqlUploadId = 'SELECT uploadId FROM '. $table . ' WHERE id = ?'; + } + $result = $this->dbArr[$dbIndex]->sql($sqlPath, ROW_EXPECT_GE_1, [$uploadId],"File not found in database."); + + $idList = array_map(function ($item) { + return $item['id']; + }, $result); + $idListString = implode(',', $idList); + + // Save result in array notation like this {"id":"recordId", "pathFileName":"/path/dir/file.png"}, {"id":"recordId", "pathFileName":"/path/dir/file.png"} + $preloadedFiles = json_encode($result, JSON_UNESCAPED_SLASHES); + $sipValuePreloadedFiles = '&allowDelete=' . $jsonConfig[UPLOAD_DELETE_OPTION] . '&preloadedFileIds=' . $idListString; + } else { + $sqlUploadId = 'SELECT MAX(uploadId) AS uploadId FROM '. $table . ' WHERE id = ? LIMIT 1'; + } + + if ($uploadId == 0 || !$multiUpload) { + $resultUploadId = $this->dbArr[$dbIndex]->sql($sqlUploadId, ROW_EXPECT_GE_1, [$uploadId],"File not found in database."); + $groupId = $resultUploadId[0][COLUMN_UPLOAD_ID] ?? 0; + } + + $jsonConfig[UPLOAD_GROUP_ID] = $groupId ?? 0; + + // Create sip token + $sipValues['download'] = $this->sip->queryStringToSip('action=download&table=' . $table . $dbIndexParam, RETURN_SIP); + $sipValues['upload'] = $this->sip->queryStringToSip('action=upload2&table=' . $table . $acceptType . $maxFileSize . $recordData . $dbIndexParam, RETURN_SIP); + $sipValues['delete'] = $this->sip->queryStringToSip('action=delete&table=' . $table . $sipValuePreloadedFiles . $dbIndexParam, RETURN_SIP); + + $encodedSipValues = htmlspecialchars(json_encode($sipValues, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + $encodedJsonConfig = htmlspecialchars(json_encode($jsonConfig, JSON_UNESCAPED_SLASHES), ENT_QUOTES, 'UTF-8'); + $encodedPreloadFilesConfig = htmlspecialchars($preloadedFiles, ENT_QUOTES, 'UTF-8'); + + return '<input class="fileupload" data-api-urls="' . $encodedApiUrls . '" data-sips="' . $encodedSipValues . '" data-config="'. $encodedJsonConfig . '" data-preloadedFiles="' . $encodedPreloadFilesConfig . '" type="file" />'; + } } diff --git a/extension/Classes/Core/Save.php b/extension/Classes/Core/Save.php index 4bc1a8ebc9e4986620589e95c52ea868480c92ab..a47aa3a2d42c39aae87ee01129c4739e5ade90a0 100644 --- a/extension/Classes/Core/Save.php +++ b/extension/Classes/Core/Save.php @@ -644,9 +644,14 @@ class Save { $this->store->setVar(SYSTEM_FORM_ELEMENT, Logger::formatFormElementName($fe), STORE_SYSTEM); $this->store->setVar(SYSTEM_FORM_ELEMENT_ID, $fe[FE_ID], STORE_SYSTEM); + $this->formAction->doSqlBeforeSlaveAfter($fe, $recordId, false); $this->typeAheadDoTagGlue($fe); } + + $this->store->setVar(SYSTEM_FORM_ELEMENT, '', STORE_SYSTEM); + $this->store->setVar(SYSTEM_FORM_ELEMENT_ID, 0, STORE_SYSTEM); + } /** @@ -910,6 +915,10 @@ class Save { } } + $this->store->setVar(SYSTEM_FORM_ELEMENT, '', STORE_SYSTEM); + $this->store->setVar(SYSTEM_FORM_ELEMENT_ID, 0, STORE_SYSTEM); + + // Clean up HelperFile::chdir($cwd); @@ -1081,13 +1090,29 @@ class Save { } } - if ($mode == FE_MODE_REQUIRED && empty($clientValues[$formElement[FE_NAME]])) { + // Required fieldset is skipped (only child elements are checked) + if ($mode == FE_MODE_REQUIRED && empty($clientValues[$formElement[FE_NAME]]) && $formElement[FE_TYPE] != FE_TYPE_FIELDSET) { $flagAllRequiredGiven = 0; if ($reportRequiredFailed) { $name = ($formElement[FE_LABEL] == '') ? $formElement[FE_NAME] : $formElement[FE_LABEL]; throw new \UserFormException("Missing required value: $name", ERROR_REQUIRED_VALUE_EMPTY); } + + // Check if mode = required was inherited + } else if (empty($clientValues[$formElement[FE_NAME]]) && $formElement[FE_TYPE] != FE_TYPE_FIELDSET) { + $name = ($formElement[FE_LABEL] == '') ? $formElement[FE_NAME] : $formElement[FE_LABEL]; + + // Check if FE is nested + $feParent = OnArray::filter($this->feSpecNativeRaw, FE_ID, $formElement[FE_ID_CONTAINER]); + + // Check if parent FE is required fieldset + // Only reached if JS-required-check is bypassed/skipped + if (!empty($feParent) && $feParent[0][FE_TYPE] == FE_TYPE_FIELDSET && $feParent[0][FE_MODE] == FE_MODE_REQUIRED) { + $parentName = $feParent[0][FE_NAME]; + + throw new \UserFormException("Missing required value: $name (mode inherited from $parentName)", ERROR_REQUIRED_VALUE_EMPTY); + } } if ($mode == FE_MODE_HIDDEN) { @@ -1096,6 +1121,9 @@ class Save { } } + $this->store->setVar(SYSTEM_FORM_ELEMENT, '', STORE_SYSTEM); + $this->store->setVar(SYSTEM_FORM_ELEMENT_ID, 0, STORE_SYSTEM); + // Save 'allRequiredGiven in STORE_VAR $this->store::setVar(VAR_ALL_REQUIRED_GIVEN, $flagAllRequiredGiven, STORE_VAR, true); } @@ -1237,6 +1265,11 @@ class Save { if (!isset($formElement[FE_IMPORT_TO_TABLE]) || isset($formElement[FE_FILE_DESTINATION])) { $pathFileName = $this->copyUploadFile($formElement, $statusUpload); + // Save final pathFileNames after form has been saved. Makes uploaded files downloadable without page reload. + $sipDownloadParams = $this->store::getVar($statusUpload[UPLOAD_SIP_DOWNLOAD_KEY], STORE_EXTRA); + $sipDownloadParams[FE_FILE_DESTINATION] = $pathFileName; + $this->store::setVar($statusUpload[UPLOAD_SIP_DOWNLOAD_KEY], $sipDownloadParams, STORE_EXTRA); + $msg = UPLOAD_LOG_PREFIX . ': '; $msg .= ($pathFileName == '') ? 'Remove old upload / no new upload' : 'File "' . $statusUpload[FILES_TMP_NAME] . '" >> "' . $pathFileName . '"'; Logger::logMessageWithPrefix($msg, $this->qfqLogFilename); diff --git a/extension/Classes/Core/Store/Session.php b/extension/Classes/Core/Store/Session.php index b6f046b744d7979801bcb3360ba72be93435bbc4..ab4378c0465f514fde2e353c455ea9b8191a0acb 100644 --- a/extension/Classes/Core/Store/Session.php +++ b/extension/Classes/Core/Store/Session.php @@ -217,6 +217,7 @@ class Session { $beUser = $GLOBALS["BE_USER"]->user["username"] ?? false; $languageId = T3Info::getLanguageId() ?? false; $languagePath = T3Info::getLanguagePath($languageId) ?? false; + $pageId = $GLOBALS["TSFE"]->id ?? 0; // Cookie identifier $cookieFe = ($_COOKIE['fe_typo_user']) ?? false; @@ -241,6 +242,7 @@ class Session { // page language should be saved in session even fe user is not logged in. Session::set(SESSION_PAGE_LANGUAGE, $languageId); Session::set(SESSION_PAGE_LANGUAGE_PATH, $languagePath); + Session::set(SESSION_PAGE_ID, $pageId); } else { // If we are called through API there is no T3 environment. Assume nothing has changed, and fake the following check to always 'no change'. $feUidLoggedIn = $feUserUidSession; diff --git a/extension/Classes/Core/Store/Store.php b/extension/Classes/Core/Store/Store.php index 37b8cf83c3075747291700d0eedc38d29a1bdb06..64a051647e1890e989994a81b5f9097d22c2df6d 100644 --- a/extension/Classes/Core/Store/Store.php +++ b/extension/Classes/Core/Store/Store.php @@ -383,7 +383,7 @@ class Store { } else { // No T3 environment (called by API): restore from SESSION - foreach ([SESSION_FE_USER, SESSION_FE_USER_UID, SESSION_FE_USER_GROUP, SESSION_BE_USER, SESSION_PAGE_LANGUAGE, SESSION_PAGE_LANGUAGE_PATH] as $key) { + foreach ([SESSION_FE_USER, SESSION_FE_USER_UID, SESSION_FE_USER_GROUP, SESSION_BE_USER, SESSION_PAGE_LANGUAGE, SESSION_PAGE_LANGUAGE_PATH, SESSION_PAGE_ID] as $key) { if (isset($_SESSION[SESSION_NAME][$key])) { $arr[$key] = $_SESSION[SESSION_NAME][$key]; } diff --git a/extension/Classes/Sql/qfqDefaultTables.sql b/extension/Classes/Sql/qfqDefaultTables.sql index 8af9654cedf97b27d520aa8a32b18af61c1aeac1..42564a4bfbe0fda0045faddf934678c6a21822b6 100644 --- a/extension/Classes/Sql/qfqDefaultTables.sql +++ b/extension/Classes/Sql/qfqDefaultTables.sql @@ -249,8 +249,8 @@ CREATE TABLE IF NOT EXISTS `Cron` PRIMARY KEY (`id`) ) ENGINE = InnoDB - AUTO_INCREMENT = 0 - DEFAULT CHARSET = utf8; + DEFAULT CHARSET = utf8 + AUTO_INCREMENT = 0; CREATE TABLE IF NOT EXISTS `Split` @@ -265,8 +265,8 @@ CREATE TABLE IF NOT EXISTS `Split` PRIMARY KEY (`id`) ) ENGINE = InnoDB - AUTO_INCREMENT = 0 - DEFAULT CHARSET = utf8; + DEFAULT CHARSET = utf8 + AUTO_INCREMENT = 0; # Used to save tablesorter.js column selection settings. Free to use for other settings as well. CREATE TABLE IF NOT EXISTS `Setting` @@ -284,6 +284,28 @@ CREATE TABLE IF NOT EXISTS `Setting` PRIMARY KEY (`id`), KEY `name` (`name`), KEY `typeFeUserUidTableIdPublic` (`type`, `feUser`, `tableId`, `public`) USING BTREE -) ENGINE = InnoDB - DEFAULT CHARSET = utf8mb4; +) + ENGINE = InnoDB + DEFAULT CHARSET = utf8 + AUTO_INCREMENT = 0; +# Used to save uploads per default. +CREATE TABLE IF NOT EXISTS `FileUpload` +( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `pathFileName` VARCHAR(512) NOT NULL, + `grId` INT(11) NOT NULL DEFAULT '0', + `xId` INT(11) NOT NULL DEFAULT '0', + `uploadId` INT(11) NOT NULL, + `size` VARCHAR(32) NOT NULL DEFAULT '0' COMMENT 'Filesize in bytes', + `type` VARCHAR(64) NOT NULL DEFAULT '', + `ord` INT(11) NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `uploadId` (`uploadId`), + KEY `pathFileNameGrIdXidUploadId` (`pathFileName`, `grId`, `xId`, `uploadId`) USING BTREE +) + ENGINE = InnoDB + DEFAULT CHARSET = utf8 + AUTO_INCREMENT = 0; diff --git a/extension/Resources/Public/scripts/weblinks_check.py b/extension/Resources/Public/scripts/weblinks_check.py new file mode 100644 index 0000000000000000000000000000000000000000..3abd43e3d409291379cecbac79b427b9f3eb2373 --- /dev/null +++ b/extension/Resources/Public/scripts/weblinks_check.py @@ -0,0 +1,395 @@ +import csv +import json +import os +import pandas as pd +import requests +import sys +import argparse +import datetime +import urllib3 +from selenium import webdriver +from urllib.parse import urlparse, urljoin +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.chrome.options import Options +# Initialize the Selenium webdriver +from selenium.webdriver.common.by import By +from bs4 import BeautifulSoup +# Configure Chrome options to clear cache and disable extensions +chrome_options = Options() +chrome_options.add_argument('--headless') +chrome_options.add_argument('--ignore-certificate-errors') +chrome_options.add_argument('--disable-web-security') +chrome_options.add_argument('--disable-extensions') +chrome_options.add_argument('--allow-running-insecure-content') + +# Set the download directory for PDFs +download_directory = "/home/a/zhoujl/Downloads/all_web_crawler_pdf" # Replace with the actual directory +prefs = {"download.default_directory": download_directory} +chrome_options.add_experimental_option("prefs", prefs) +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +successed_links = {} +visited_links = set() +broken_links = {} +denied_links = {} +broken_image = {} +redirect_to_home = {} +successed_image = set() +merged_broken_links = {} + +max_depth = 0 +input_url = '' +main_domain = '' +target_path = '' + +class SimpleLink: + href = "" + css_class = "" + link_text = "" + def __init__(self, href, css_class, link_text): + self.href = href + self.css_class = css_class + self.link_text = link_text +# Function to configure and retrieve WebDriver with authenticated session +def get_authenticated_driver(username, password, login_url): + driver = webdriver.Chrome(options=chrome_options) + + # Navigate to the login page + driver.get(login_url) + if username != '' or password != '': + # Fill in and submit the login form + username_field = driver.find_element(By.NAME, 'user') + password_field = driver.find_element(By.NAME, 'pass') + login_button = driver.find_element(By.XPATH, '//input[@type="submit"]') + + username_field.send_keys(username) + password_field.send_keys(password) + login_button.click() + + # Wait for the login to complete, you might need to adjust this + driver.implicitly_wait(2000) + + # Retrieve and return the cookies + cookies = driver.get_cookies() + return driver, cookies + +def check_webpage(url, target_string, session, cookies=None): + try: + response = session.get(url, cookies=cookies, verify=False, timeout=15) + response.raise_for_status() # Raise an exception for bad status codes + content = response.text + + if target_string in content: + return True, content + else: + return False, content + except requests.exceptions.RequestException as e: + return False, str(e) + +# Set the main domain +def is_internal_link(link): + parsed_link = urlparse(link) + + return parsed_link.netloc == main_domain and parsed_link.path.startswith(target_path) or parsed_link.netloc == "" + + +def find_broken_links(driver, url, current_depth, cookies, session, requests_cookies, target_string): + global successed_links + global visited_links + global broken_links + global denied_links + global broken_image + global successed_image + global redirect_to_home + simpleLinkList = [] + + + if current_depth == 0: + return "done" + + try: + driver.get(url) + except WebDriverException as e: + print(f"Error accessing URL {url}: {e}") + if url.endswith(".pdf") or url in broken_links or "type=1" in url or "download.php" in url: + driver.back() + else: + link_elements = driver.find_elements(By.TAG_NAME, "a") + images = [img.get_attribute("src") for img in driver.find_elements(By.TAG_NAME, "img")] + for link_element in link_elements: + simple_link = SimpleLink(link_element.get_attribute("href"), link_element.get_attribute("class"), link_element.text) + simpleLinkList.append(simple_link) + #links = [a for a in driver.find_elements(By.TAG_NAME, "a")] + for simple_link in simpleLinkList: + link = simple_link.href + cssClass = simple_link.css_class + link_text = simple_link.link_text + if link == 'https://www.math.uzh.ch/compmath/en/konferenzdetails0?L=1&key1=755': + + pass + try:#links = [a.get_attribute("href") for a in driver.find_elements(By.TAG_NAME, "a")] + # link = link_element.get_attribute("href") + # link_class = link_element.get_attribute("class") + #for link in links: + + checkBool, content = check_webpage(link, target_string, session, cookies=requests_cookies) + if checkBool and link not in visited_links: + visited_links.add(link) + response = requests.head(link, verify=False) + redirect_to_home.setdefault(link, [None, None, None, None, None])[0] = link + redirect_to_home[link][1] = response.status_code + redirect_to_home[link][2] = str(checkBool) + redirect_to_home[link][3] = url + redirect_to_home[link][4] = link_text + if link and is_internal_link(link) and not link.startswith("mailto:"): + full_link = urljoin(url, link) + if full_link not in visited_links: + visited_links.add(full_link) + response = requests.head(full_link) + #checkBool, content = check_webpage(full_link, target_string, session, cookies=requests_cookies) + if response.status_code == 404: + broken_links.setdefault(full_link, [None, None, None, None, None])[0] = full_link + broken_links[full_link][1] = response.status_code + broken_links[full_link][2] = str(checkBool) + broken_links[full_link][3] = url + broken_links[full_link][4] = link_text + # print(f"Broken link: {full_link}") + if response.status_code == 403: + denied_links.setdefault(full_link, [None, None, None, None, None])[0] = full_link + denied_links[full_link][1] = response.status_code + denied_links[full_link][2] = str(checkBool) + denied_links[full_link][3] = url + denied_links[full_link][4] = link_text + else: + find_broken_links(driver, full_link, current_depth -1, cookies, session, requests_cookies, target_string) + if full_link not in broken_links: + successed_links.setdefault(full_link, [None, None, None, None, None])[0] = full_link + successed_links[full_link][1] = response.status_code + successed_links[full_link][2] = str(checkBool) + successed_links[full_link][3] = url + # print(full_link)# Recursively check this link + # print("keine fehler") + # print('current_depth is:', current_depth) + #if checkBool == True: + # print('Path redirects to home') + # print("---------------------------------------") + except Exception as e: + print(f"Error checking link {full_link}: {e}") + + for image in images: + if image and is_internal_link(image): + full_image_link = urljoin(url, image) + if full_image_link not in visited_links: + visited_links.add(full_image_link) + try: + response = requests.head(full_image_link) + if response.status_code == 404: + broken_image.setdefault(full_image_link, [None, None, None, None, None])[0] = full_image_link + broken_image[full_image_link][1] = response.status_code + broken_image[full_image_link][2] = '' + broken_image[full_image_link][3] = url + broken_image[full_image_link][4] = "text" + #print(f"Broken image: {full_image_link}") + else: + successed_image.add(full_image_link) + except Exception as e: + print(f"Error checking image {full_image_link}: {e}") + pass + + + +def print_header(log_file, blacklists): + current_datetime = datetime.datetime.now() + if log_file.endswith("detail.log"): + with open(log_file, "a+") as file: + file.write(f"\nSkript : {__file__}") + file.write("\nDetails Log " + current_datetime.strftime("%Y-%m-%d %H:%M")) + file.write("\nInput URL:" + input_url) + file.write("\nDOMAIN:" + main_domain) + file.write("\n-------------------------------------\n") + file.write("\nBlacklist : \n") + for blacklist in blacklists: + file.write(f"\"{blacklist}\"\n") + file.write("-------------------------------------\n") + elif log_file.endswith("summary.log"): + with open(log_file, "a+") as file: + file.write(f"\nSkript : {__file__}") + file.write("\nSummary Log " + current_datetime.strftime("%Y-%m-%d %H:%M")) + file.write("\nInput URL:" + input_url) + file.write("\nDOMAIN:" + main_domain) + file.write("\n-------------------------------------\n") + else: + return print("No summary.log or detail.log are found") + pass + + + +def print_detail_log(links, title, detail_log_file): + with open(detail_log_file, "a") as file: + file.write("\n" + title + ":\n\n") + if len(links) == 0: + file.write("Nothing found.\n---------------------------------------\n") + for full_link, details in links.items(): + target_link = details[0] + response = details[1] # assuming the status_code is at index 0 + redirect = details[2] # assuming the redirect flag is at index 1 + url = details[3] + if title != 'Successed links': + link_text = details[4] + file.write("Page URL: " + url + "\n") + file.write("Target URL: " + target_link + "\n") + file.write("RESPONSE: " + str(response) + "\n") + if title != 'Successed links': + file.write("Link Text: " + link_text + "\n") + if title != 'Broken images' and redirect == True: + file.write("REDIRECT_TO_HOME: " + str(redirect) + "\n") + file.write("---------------------------------------\n") + pass + +def print_summary(redirect_to_home,checked_links,broken_links,checked_image,broken_image, summary_log_file, title): + with open(summary_log_file, "a") as file: + file.write(f"\n Redirect to Home: {len(redirect_to_home)}") + file.write(f"\n Checked links: {len(checked_links)+len(broken_links)}") + file.write(f"\n Broken links: {len(broken_links)}") + file.write(f"\n Checked image: {len(checked_image)+len(broken_image)}") + file.write(f"\n Broken image: {len(broken_image)}") + file.write("\n---------------------------------------") + if len(broken_links) > 0 or len(broken_image) > 0: + file.write(f"\nSome issue in \"{title}\"") + else: + file.write("\nEverything is ok\n\n") + pass + +def write_broken_links_to_csv(broken_links, csv_file): + header = ["Response Code", "PAGE URL", "Link Text", "Target URL"] + + with open(csv_file, "a", newline="", encoding="utf-8") as file: + # Check if the file is empty + + is_empty = file.tell() == 0 + writer = csv.writer(file) + + if is_empty: + writer.writerow(header) + + for link, details in broken_links.items(): + target_url = details[0] + response_code = details[1] + url = details[3] + link_text = details[4] + writer.writerow([response_code, url, link_text, target_url]) + +def clear_arrays(): + successed_links.clear() + visited_links.clear() + broken_links.clear() + denied_links.clear() + broken_image.clear() + successed_image.clear() + merged_broken_links.clear() + redirect_to_home.clear() + pass + +def process_site(selected_site): + global main_domain + global target_path + global visited_links + global input_url + + max_depth = selected_site.get("max_depth") + input_url = selected_site.get("startUrl") + main_domain = urlparse(input_url).netloc + target_path = selected_site.get("target_path") + + # Load the blacklist URLs from the JSON data + blacklist = selected_site.get("blacklist", []) + + # Initialize the visited_links set with the URLs from the blacklist + visited_links = set(blacklist) + + # Login credentials + login_url = selected_site.get("login_url") or input_url + username = selected_site.get("username") + password = selected_site.get("password") + target_string = selected_site.get("target_string") + + session = requests.Session() + # target_string = 'Login/out' + driver, cookies = get_authenticated_driver(username, password, login_url) + requests_cookies = {cookie['name']: cookie['value'] for cookie in cookies} + # current_datetime = datetime.datetime.now() + # formatted_datetime = current_datetime.strftime("%Y-%m-%d %H:%M") + + parsed_url = urlparse(input_url) + detail_file_name = "log_files/" + parsed_url.netloc + parsed_url.path.replace("/", "_") + "_detail.log" + detail_log_file = os.path.join(os.getcwd(), detail_file_name) + + summary_log_file = "log_files/" + parsed_url.netloc + parsed_url.path.replace("/", "_") + "_summary.log" + title_summary = parsed_url.netloc + parsed_url.path + + # print header in files + print_header(detail_log_file, blacklist) + print_header(summary_log_file, blacklist) + # function find_broken_links + find_broken_links(driver, input_url, max_depth, cookies, session, requests_cookies, target_string) + merged_broken_links = {**broken_links, **broken_image} + + # Clean up + driver.quit() + + print_summary(redirect_to_home, successed_links, broken_links, successed_image, broken_image, summary_log_file, + title_summary) + print_detail_log(redirect_to_home, 'Redirect to Home', detail_log_file) + print_detail_log(broken_links, 'Broken links', detail_log_file) + print_detail_log(broken_image, 'Broken images', detail_log_file) + print_detail_log(denied_links, 'Denied links', detail_log_file) + print_detail_log(successed_links, 'Successed links', detail_log_file) + file_name = parsed_url.netloc + parsed_url.path.replace("/", "_") + file_path = f"/home/a/zhoujl/Documents/broken_link_{file_name}.csv" + write_broken_links_to_csv(merged_broken_links, file_path) + print(f"Broken links found in {input_url}: {len(broken_links)}") + clear_arrays() + + +def main(): + # if len(sys.argv) < 3 or not sys.argv[1].strip(): + # print("Usage:python script.py conf.json <index or all>") + # sys.exit(1) + # + # parser = argparse.ArgumentParser(description='Web Crawling.') + # parser.add_argument('conf_file', type=str, help='Path to the conf.json file') + # #parser.add_argument('element_index', type=int, help='Index of the element to check in allSites') + # args = parser.parse_args() + + conf_file_path = "conf.json"#args.conf_file + + with open(conf_file_path, "r") as conf_file: + conf_data = json.load(conf_file) + + all_sites = conf_data.get("allSites", []) + selected_arg = "9" #sys.argv[3] # Get the third argument + + try: + if selected_arg == "all": + for selected_site in conf_data.get("allSites", []): + process_site(selected_site) + else: + selected_index = int(selected_arg) + if selected_index >= 0 and selected_index < len(conf_data.get("allSites", [])): + selected_site = conf_data["allSites"][selected_index] + process_site(selected_site) + else: + print("Invalid index.") + sys.exit(1) + except Exception as e: + print(e) + +if __name__ == "__main__": + main() + + + + + + diff --git a/extension/Tests/Unit/Core/Database/DatabaseUpdateTest.php b/extension/Tests/Unit/Core/Database/DatabaseUpdateTest.php index f57018cf9dbd9bf44f6779b81f17740a42a207ac..465977e5e647eefd7695c087391020335a4102c3 100644 --- a/extension/Tests/Unit/Core/Database/DatabaseUpdateTest.php +++ b/extension/Tests/Unit/Core/Database/DatabaseUpdateTest.php @@ -35,7 +35,7 @@ class DatabaseUpdateTest extends AbstractDatabaseTest { public function testCheckNupdate() { // $countQfqTables = 9; - $countQfqTables = 10; + $countQfqTables = 11; $store = Store::getInstance(); diff --git a/extension/Tests/Unit/Core/Helper/HelperFormElementTest.php b/extension/Tests/Unit/Core/Helper/HelperFormElementTest.php index f8c552f052321554cc8480e2dacee83894fa939a..8ac295a49da39a0a92a01ca751c49737c879ced1 100644 --- a/extension/Tests/Unit/Core/Helper/HelperFormElementTest.php +++ b/extension/Tests/Unit/Core/Helper/HelperFormElementTest.php @@ -119,7 +119,7 @@ class HelperFormElementTest extends TestCase { public function testInitActionFormElement() { $list = [FE_TYPE, FE_SLAVE_ID, FE_SQL_VALIDATE, FE_SQL_BEFORE, FE_SQL_INSERT, FE_SQL_UPDATE, FE_SQL_DELETE, - FE_SQL_AFTER, FE_EXPECT_RECORDS, FE_REQUIRED_LIST, FE_MESSAGE_FAIL, FE_SENDMAIL_TO, FE_SENDMAIL_CC, + FE_SQL_AFTER, FE_EXPECT_RECORDS, FE_REQUIRED_LIST, FE_ALERT, FE_QFQ_LOG, FE_MESSAGE_FAIL, FE_SENDMAIL_TO, FE_SENDMAIL_CC, FE_SENDMAIL_BCC, FE_SENDMAIL_FROM, FE_SENDMAIL_SUBJECT, FE_SENDMAIL_REPLY_TO, FE_SENDMAIL_FLAG_AUTO_SUBMIT, FE_SENDMAIL_GR_ID, FE_SENDMAIL_X_ID, FE_SENDMAIL_X_ID2, FE_SENDMAIL_X_ID3, FE_SENDMAIL_BODY_MODE, FE_SENDMAIL_BODY_HTML_ENTITY, FE_SENDMAIL_SUBJECT_HTML_ENTITY]; diff --git a/extension/composer.json b/extension/composer.json index be32013f560e6fa6bf04c5bedcc9ae2fdf879032..8824686e2ad8f659fff4475fb59276db055c2dfc 100644 --- a/extension/composer.json +++ b/extension/composer.json @@ -19,7 +19,8 @@ "Classes/Core/Exception/DownloadException.php", "Classes/Core/Exception/ShellException.php", "Classes/Core/Exception/UserFormException.php", - "Classes/Core/Exception/UserReportException.php" + "Classes/Core/Exception/UserReportException.php", + "Classes/Core/Exception/InfoException.php" ] }, "autoload-dev": { diff --git a/javascript/build/copy.js b/javascript/build/copy.js index ee8e9c56826bebec5bef876b10a9fd0ca15022fa..4fe12050d189eefe93e5cea90e3a09970f49763e 100644 --- a/javascript/build/copy.js +++ b/javascript/build/copy.js @@ -152,7 +152,20 @@ const todos = [ to: target.css } ] - } + },{ + name: "filepond", + js: "node_modules/filepond/dist/", + css: "node_modules/filepond/dist/", + custom: [ + { + from: "node_modules/filepond-plugin-file-validate-type/dist/filepond-plugin-file-validate-type.js", + to: target.js + },{ + from: "node_modules/filepond-plugin-file-validate-size/dist/filepond-plugin-file-validate-size.js", + to: target.js + }, + ] + }, ] const types = ["js", "css", "font"] diff --git a/javascript/src/Alert.js b/javascript/src/Alert.js index 77461e1490bc036fa2320d4003afd5b8f6a62e29..a965139499fee10606415f33a491b3561a0e3beb 100644 --- a/javascript/src/Alert.js +++ b/javascript/src/Alert.js @@ -116,6 +116,15 @@ var QfqNS = QfqNS || {}; this.identifier = false; this.eventEmitter = new EventEmitter(); + + if (this.message.indexOf('qfq-debug-detail') >= 0) { + this.buttons.push({ label: 'Debug info', eventName: 'show-debug' }); + setTimeout(function() { + this.on('alert.show-debug', function() { + $('.qfq-debug-detail').toggleClass('qfq-alert-hidden'); + }); + }.bind(this), 100); + } }; n.Alert.prototype.on = n.EventEmitter.onMixin; @@ -342,7 +351,7 @@ var QfqNS = QfqNS || {}; * @private */ n.Alert.prototype.buttonHandler = function (event) { - this.removeAlert(); + if(event.data.eventName !== "show-debug") this.removeAlert(); this.eventEmitter.emitEvent('alert.' + event.data.eventName, n.EventEmitter.makePayload(this, null)); }; diff --git a/javascript/src/Form.js b/javascript/src/Form.js index ba2a1a1f1ab864a23dad9e51b747c544558f6054..dfbc9e44596df9d0eb82673ee47cce9b5ca611d2 100644 --- a/javascript/src/Form.js +++ b/javascript/src/Form.js @@ -53,10 +53,16 @@ var QfqNS = QfqNS || {}; this.$form.find("input, textarea").on("input paste", this.inputAndPasteHandler.bind(this)); // Fire handler while using dateTimePickerType qfq - function getDatetimePickerChanges() { + function getDatetimePickerChanges(element) { $('div tbody').on('click', 'td.day:not(.disabled)', formObject.inputAndPasteHandler.bind(formObject)); var timepickerElements = 'td a.btn[data-action="incrementHours"], td a.btn[data-action="incrementMinutes"], td a.btn[data-action="incrementSeconds"], td a.btn[data-action="decrementHours"], td a.btn[data-action="decrementMinutes"], td a.btn[data-action="decrementSeconds"]'; $('div table').on('click', 'td.hour, td.minute, td a[data-action="clear"], '+timepickerElements, formObject.inputAndPasteHandler.bind(formObject)); + + element.addEventListener('keydown', function(event) { + if (event.key === 'Delete') { + formObject.inputAndPasteHandler(event); + } + }); } // Function to trigger onfocus event again while element is already focused @@ -81,7 +87,7 @@ var QfqNS = QfqNS || {}; // Open datetimepicker over click event even if first element is already focused and get all changes of datetimepicker for dirty lock this.$form.find(".qfq-datepicker").on("click", function(){ triggerFocus(this); - getDatetimePickerChanges(); + getDatetimePickerChanges(this); }); // Fire handler while using dateTimePickerType browser @@ -90,7 +96,6 @@ var QfqNS = QfqNS || {}; // Use ctrl+alt+s for saving form document.addEventListener('keydown', function(event) { if (event.ctrlKey && event.altKey && event.key === 's') { - console.log("submit"); $("#save-button-" + this.formId + ":not([disabled=disabled])").click(); } }); diff --git a/javascript/src/Helper/filePond.js b/javascript/src/Helper/filePond.js new file mode 100644 index 0000000000000000000000000000000000000000..5fbc52b5dc315d19c15248e02641812a418fa2da --- /dev/null +++ b/javascript/src/Helper/filePond.js @@ -0,0 +1,419 @@ +/** + * Qfq Namespace + * + * @namespace QfqNS + */ + +var QfqNS = QfqNS || {}; + +(function (n) { + + n.filePond = function createFileUpload(inputElement) { + // Retrieve all needed data and configurations + this.inputElement = inputElement; + this.pond = null; + + const configData = inputElement.getAttribute('data-config'); + this.configuration = configData ? JSON.parse(configData) : []; + this.normalizeConfiguration(); + + const apiUrls = inputElement.getAttribute('data-api-urls'); + this.apiUrls = apiUrls ? JSON.parse(apiUrls) : []; + + const sipValues = inputElement.getAttribute('data-sips'); + this.sipValues = sipValues ? JSON.parse(sipValues) : []; + + //Initialize existing preloaded files + this.filePondFiles = this.getPreloadedFiles(this.inputElement); + + // Initialize flags + this.lastUploadId = null; + this.currentFieldId = false; + this.deletedFileId = true; + this.lastSipTmp = false; + }; + + n.filePond.prototype.createFilePondObject = function() { + // Create the FilePond instance + const pond = FilePond.create(this.inputElement, { + allowMultiple: this.configuration.multiUpload, + allowRemove: this.configuration.deleteOption, + allowRevert: true, + maxFileSize: this.configuration.maxFileSize, + allowFileSizeValidation: this.configuration.activeSizeValidation, + acceptedFileTypes: this.configuration.accept, + allowFileTypeValidation: this.configuration.activeTypeValidation, + allowImagePreview: this.configuration.imageEditor, + allowImageEdit: this.configuration.imageEditor, + allowDrop: this.configuration.allowUpload, + allowBrowse: this.configuration.allowUpload, + labelIdle: this.configuration.text, + maxFiles: this.configuration.maxFiles, + allowReorder: false, + imagePreviewMaxHeight: 150, + styleButtonRemoveItemPosition: 'right', + credits: false, + dropValidation: true, + maxParallelUploads: 1, + files: this.filePondFiles, + iconRemove: '<i class="fas fa-trash" style="color: white;"></i>', + server: { + process: { + url: this.apiUrls.upload + "?s=" + this.sipValues.upload, + method: 'POST', + withCredentials: false, + headers: {}, + ondata: (formData) => { + return this.setOnData(formData); + }, + onload: (response) => { + // response is the JSON string returned by the server + const res = JSON.parse(response); + if (this.lastUploadId === null) { + this.lastUploadId = res.groupId; + + } + // Here you can handle the unique file ID as needed + console.log('File uploaded successfully:', res.uniqueFileId); + console.log('Upload Id:', res.groupId); + console.log('sipTmp:', res.sipTmp); + this.lastSipTmp = res.sipTmp; + + return res.uniqueFileId; // Must return the unique file ID to FilePond + }, + onerror: (response) => { + // Handle error here + console.error('Error during upload:', response); + } + }, + revert: (uniqueFileId, load, error) => { + this.setRevert(uniqueFileId, load, error); + }, + remove: (uniqueFileId, load, error) => { + this.setRemove(uniqueFileId, load, error); + }, + load: (source, load, error, progress, abort, headers) => { + console.log('loaded'); + } + }, + onprocessfile: (error, fileItem) => { + if (error) { + console.error('Error processing file:', error); + return; + } + + setTimeout(() => { + const foundIndicators = this.findFalseIndicators(); + // You can now do something with the indicators, like logging them or changing their styles + foundIndicators.forEach(indicator => { + indicator.style.opacity = 0; // Or apply styles or other changes + }); + }, 1000); + }, + onremovefile: (error, file) => { + if (error) { + console.error('Error removing file:', error); + return; + } + this.deletedFileId = true; // Reset fileId when a file is removed + }, + onaddfile: (err, fileItem) => { + if (err) { + console.error('Error adding file:', err); + return; + } + + // Wait for the file item to be added to the DOM + setTimeout(() => { + // Access the file item's element using FilePond's internal API + const item = pond.getFile(fileItem.id); + if (!item || !item.file || !item.id) { + console.error('The file item is missing information.'); + return; + } + + // If it's a form-element and downloadButton is not given then there is no download button needed. + if (!this.configuration.form || this.configuration.form && this.configuration.downloadButton !== false) { + this.createDownloadButton(fileItem, item); + } + }, 100); + }, + oninit: () => { + // Change the styling for filePond uploads in form + const rootElement = pond.element; + const dropLabel = rootElement.querySelector('.filepond--drop-label'); + const listElement = rootElement.querySelector('.filepond--list'); + if (this.configuration.form) { + rootElement.id = this.configuration.formId; + if (listElement) { + listElement.classList.add('filepond--list-form'); + } + } + + if (dropLabel && this.configuration.dropBackground !== undefined) { + dropLabel.classList.add('filepond--drop-label-form'); + } + + if (this.configuration.form && this.configuration.downloadButton === false) { + const sizeInfo = rootElement.querySelector('.filepond--file-info-sub'); + if (sizeInfo !== null) { + sizeInfo.style.display = 'none'; + } + } + } + }); + + this.pond = pond; + }; + + // This function will return an array of all processing complete indicators + // that have a sibling with the revert button processing class. + n.filePond.prototype.getPreloadedFiles = function () { + const preloadedData = this.inputElement.getAttribute('data-preloadedFiles'); + const preloadedFiles = preloadedData ? JSON.parse(preloadedData) : []; + + return preloadedFiles.length > 0 ? preloadedFiles.map(file => ({ + source: file.id, + options: { + type: 'local', + file: { + name: file.pathFileName.split('/').pop(), + size: file.size, + type: file.type + }, + metadata: { + poster: file.pathFileName + } + } + })) : []; + }; + + // This function will return an array of all processing complete indicators + // that have a sibling with the revert button processing class. + n.filePond.prototype.findFalseIndicators = function () { + const indicatorsWithRevertSibling = []; + + // Select all processing complete indicators + const indicators = document.querySelectorAll('.filepond--processing-complete-indicator'); + + indicators.forEach(indicator => { + // Check if the revert button processing class exists as a sibling + const revertButton = indicator.closest('.filepond--item').querySelector('.filepond--file-action-button.filepond--action-revert-item-processing'); + if (revertButton) { + indicatorsWithRevertSibling.push(indicator); + } + }); + + return indicatorsWithRevertSibling; + }; + + n.filePond.prototype.setOnData = function (formData) { + if (this.lastUploadId) { + formData.append('groupId', this.lastUploadId); + } + // Add your own variables here + formData.append('pathFileName', this.configuration.pathFileName); + formData.append('pathDefault', this.configuration.pathDefault); + formData.append('recordData', this.configuration.recordData); + if (this.lastUploadId == null) { + formData.append('groupId', this.configuration.groupId); + } + if (this.deletedFileId) { + formData.append('uploadId', 0); + } else { + formData.append('uploadId', this.configuration.uploadId); + } + formData.append('table', this.configuration.table); + + // Return the modified FormData object + return formData; + }; + + n.filePond.prototype.setRevert = function (uniqueFileId, load, error) { + const formData = new FormData(); + formData.append('uploadId', uniqueFileId); + formData.append('table', this.configuration.table); + + // The uniqueFileId parameter is the ID returned by the server during the 'process' call + // This ID can be used to identify and delete the file on the server + const xhr = new XMLHttpRequest(); + xhr.open('POST', this.apiUrls.upload + `?s=${this.sipValues.delete}`); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.onload = () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + // Update the new fileId from the response, if necessary + //this.currentFieldId = response.uniqueFileId; + load(); + } else { + error('oh no'); + } + }; + xhr.send(formData); + }; + + n.filePond.prototype.setRemove = function (uniqueFileId, load, error) { + const formData = new FormData(); + formData.append('uploadId', uniqueFileId); + formData.append('table', this.configuration.table); + + // The uniqueFileId parameter is the ID returned by the server during the 'process' call + // This ID can be used to identify and delete the file on the server + const xhr = new XMLHttpRequest(); + xhr.open('POST', this.apiUrls.upload + `?s=${this.sipValues.delete}`); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.onload = () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + // Update the new fileId from the response, if necessary + //this.currentFieldId = response.uniqueFileId; + load(); + } else { + error('oh no'); + } + }; + xhr.send(formData); + }; + + // Trying to load the pictures for preview for preloaded files. Currently not working. Maybe not needed if ImageEditor is implemented. + n.filePond.prototype.setServerLoad = function (source, load, error, progress, abort, headers) { + if (this.allowImagePreview) { + const fetchRequest = new Request(this.apiUrls.download + `?type=preview&fileId=` + source); + fetch(fetchRequest).then(response => { + if (response.ok && response.headers.get('Content-Length') > 0) { + response.blob().then(blob => { + if (blob.size > 0) { + load(blob); + } else { + load(''); + } + }); + } else { + load(''); + } + }).catch(err => { + // Log the error but don't trigger FilePond's error state + console.error(err.message); + load(''); + }); + return { + abort: () => { + } + }; + } + + }; + + // Create new downloadButton and append it to existing upload element. + // + n.filePond.prototype.createDownloadButton = function (fileItem, item) { + // Create a download button + const downloadButton = document.createElement('button'); + + // Find the filepond--file-wrapper element for this file item + const fileElementPanel = this.getFilePondElementPanel(item); + + if (fileElementPanel) { + let removeButton = fileElementPanel.querySelector('.filepond--file-action-button.filepond--action-remove-item'); + const fileInfo = fileElementPanel.querySelector('.filepond--file-info'); + + downloadButton.classList.add('filepond--file-action-button'); + downloadButton.classList.add('filepond--action-download-item'); + downloadButton.type = 'button'; + downloadButton.setAttribute('data-align', 'right'); + + if (removeButton && window.getComputedStyle(removeButton).visibility === 'hidden') { + downloadButton.style.marginRight = '110px'; + } else { + downloadButton.style.marginRight = '30px'; // or set to some default value + } + + // Create an icon element for the downward arrow + const icon = document.createElement('i'); + icon.classList.add('fas', 'fa-arrow-down'); + icon.style.color = 'white'; + + // Append the icon to the button + downloadButton.appendChild(icon); + + // Append download button text if downloadButton config given. Only in case of form-element possible. + if (this.configuration.downloadButton !== false && this.configuration.downloadButton !== undefined) { + const downloadButtonText = document.createElement('span'); + downloadButtonText.innerText = this.configuration.downloadButton; + downloadButton.appendChild(downloadButtonText); + + downloadButtonText.style.width = 'fit-content'; + downloadButtonText.style.height = 'fit-content'; + downloadButtonText.style.position = 'unset'; + downloadButtonText.style.marginLeft = '5px'; + downloadButtonText.style.marginRight = '5px'; + downloadButtonText.style.clipPath = 'inset(100%)'; + + icon.style.marginLeft = '4px'; + downloadButton.style.left = '5px'; + downloadButton.style.marginRight = 'unset'; + downloadButton.style.display = 'flex'; + downloadButton.style.alignItems = 'center'; + downloadButton.style.width = 'auto'; + downloadButton.style.borderRadius = '6px'; + + fileInfo.style.display = 'none'; + } + + // Append the download button to the filepond--file element + fileElementPanel.appendChild(downloadButton); + } + + // Set up the click event listener to trigger the download + downloadButton.addEventListener('click', () => { + let sipParameter = this.sipValues.download; + if (this.lastSipTmp !== false && this.lastSipTmp !== undefined) { + sipParameter = this.lastSipTmp; + } + + // Implement the download action here + const downloadUrl = this.apiUrls.download + `?s=${sipParameter}&sipDownloadKey=${this.configuration.sipDownloadKey}&uploadId=${fileItem.serverId}`; + window.open(downloadUrl, '_blank'); + }); + }; + + // Get fileElement panel to customize. + // + n.filePond.prototype.getFilePondElementPanel = function (item) { + const fileElement = document.querySelector(`#filepond--item-${item.id}`); + const fileWrapper = fileElement.querySelector(`.filepond--file-wrapper`); + if (fileWrapper) { + const fileElementPanel = fileWrapper.querySelector('.filepond--file'); + if (fileElementPanel) { + return fileElementPanel; + } else { + console.error('The filepond--file element was not found.'); + } + } else { + console.error('The filepond--file-wrapper element was not found.'); + } + }; + + // Normalize 'null' string values to actual nulls and string 'true' to boolean + // + n.filePond.prototype.normalizeConfiguration = function () { + Object.keys(this.configuration).forEach(key => { + if (this.configuration[key] === 'null') { + this.configuration[key] = null; + } else if (this.configuration[key] === 'true') { + this.configuration[key] = true; + } else if (this.configuration[key] === 'false') { + this.configuration[key] = false; + } + }); + + if (this.configuration.recordData === undefined) { + this.configuration.recordData = ''; + } + + this.configuration.activeTypeValidation = this.configuration.accept !== null; + this.configuration.activeSizeValidation = this.configuration.maxFileSize !== null; + }; + +})(QfqNS); + diff --git a/javascript/src/Main.js b/javascript/src/Main.js index 3a8e3bb3594558e81b4758bec659ebab3493331a..c01bcbe92306fd428521c7dd187a219620484a54 100644 --- a/javascript/src/Main.js +++ b/javascript/src/Main.js @@ -14,32 +14,35 @@ var QfqNS = QfqNS || {}; $(document).ready( function () { (function (n) { - + n.form = ''; try { var tablesorterController = new n.TablesorterController(); $('.tablesorter').each(function (i) { tablesorterController.setup($(this), i); }); // end .each() - $('.tablesorter-filter').addClass('qfq-skip-dirty'); - $('select.qfq-tablesorter-menu-item').addClass('qfq-skip-dirty'); - $('.tablesorter-column-selector>label>input').addClass('qfq-skip-dirty'); - // This is needed because after changing table-view, class of input field is empty again - $('button.qfq-column-selector').click(function () { + $('.tablesorter-filter').addClass('qfq-skip-dirty'); + $('select.qfq-tablesorter-menu-item').addClass('qfq-skip-dirty'); $('.tablesorter-column-selector>label>input').addClass('qfq-skip-dirty'); - }); + // This is needed because after changing table-view, class of input field is empty again + $('button.qfq-column-selector').click(function () { + $('.tablesorter-column-selector>label>input').addClass('qfq-skip-dirty'); + }); + var collection = document.getElementsByClassName("qfq-form"); + var qfqPages = []; + for (const form of collection) { + const page = new n.QfqPage(form.dataset); + qfqPages.push(page); + } - var collection = document.getElementsByClassName("qfq-form"); - var qfqPages = []; - for (const form of collection) { - const page = new n.QfqPage(form.dataset); - qfqPages.push(page); + // Get form object for later manipulations (example: filePond objects) + if (qfqPages[0] !== undefined) { + n.form = qfqPages[0].qfqForm.form; + } + } catch (e) { + console.log(e); } - - } catch (e) { - console.log(e); - } $('.qfq-auto-grow').each(function() { var minHeight = $(this).attr("rows") * 14 + 18; @@ -202,10 +205,69 @@ $(document).ready( function () { }); }; + n.initializeIgnoreHistoryBtn = function () { + // Attaching the event listener to the document + document.addEventListener('click', function(event) { + var element = event.target; + + // Traverse up to find the element with 'data-ignore-history' + while (element && !element.hasAttribute('data-ignore-history')) { + element = element.parentNode; + if (element === document) { + return; // Exit if reached the document without finding the target + } + } + + if (element) { + handleIgnoreHistoryClick(event, element); + } + }); + }; + + function handleIgnoreHistoryClick(event, element) { + event.preventDefault(); + let alertButton = document.querySelector('.alert-interactive .btn-group button:first-child'); + let url = element.href; + + if (alertButton) { + alertButton.onclick = function() { + if (url) { + window.location.replace(url); + } + }; + } else { + window.location.replace(url); + } + } + n.initializeQfqClearMe(); n.initializeDatetimepicker(); + n.initializeIgnoreHistoryBtn(); n.Helper.calendar(); + FilePond.registerPlugin(FilePondPluginFileValidateSize); + FilePond.registerPlugin(FilePondPluginFileValidateType); + FilePond.registerPlugin(FilePondPluginImagePreview); + FilePond.registerPlugin(FilePondPluginImageEdit); + + // Get a reference to the file input element + const inputElements = document.querySelectorAll('input[type="file"].fileupload'); + + // Iterate over the NodeList and create a FilePond instance for each element + inputElements.forEach((inputElement, index) => { + let fileObject = new n.filePond(inputElement); + fileObject.createFilePondObject(); + + // Call the form change after file remove + if (n.form !== '') { + fileObject.pond.on('removefile', function(file) { + const element = fileObject.pond.element; + n.form.inputAndPasteHandlerCalled = true; + n.form.markChanged(element); + }); + } + }); + })(QfqNS); }); diff --git a/javascript/src/QfqForm.js b/javascript/src/QfqForm.js index fae0ac550e00509376f3e34c188b987b99d30fec..e292a30b5a4819d9700216d32e6bb5cb0992d3a9 100644 --- a/javascript/src/QfqForm.js +++ b/javascript/src/QfqForm.js @@ -588,7 +588,13 @@ var QfqNS = QfqNS || {}; * @private */ n.QfqForm.prototype.handleSaveClick = function () { - this.lastButtonPress = "save"; + + // "save,force" if sqlValidate() should be ignored, default is "save" + this.lastButtonPress = this.getSaveButton().attr('data-save-force') || "save" ; + + // Remove attribute + this.getSaveButton().removeAttr('data-save-force'); + n.Log.debug("save click"); this.checkHiddenRequired(); this.getSaveButton().removeClass('btn-info'); @@ -702,6 +708,11 @@ var QfqNS = QfqNS || {}; "submit_reason": this.lastButtonPress === "close" ? "save,close" : this.lastButtonPress }; + // Change to "save" for following actions + if (this.lastButtonPress === "save,force") { + this.lastButtonPress = "save"; + } + submitQueryParameters = $.extend({}, queryParameters, submitReason); this.form.submitTo(this.submitTo, submitQueryParameters); console.log("Submitting with", submitQueryParameters); @@ -1021,16 +1032,38 @@ var QfqNS = QfqNS || {}; if (!data.message) { throw Error("Status is 'error' but required 'message' attribute is missing."); } - this._createError(data.message); - if (data["field-name"] && this.bsTabs) { - var tabId = this.bsTabs.getContainingTabIdForFormControl(data["field-name"]); - if (tabId) { - this.bsTabs.activateTab(tabId); - } + // Alert with force save option: data.text will be set for sqlValidate() + if (data.text) { + + var forceButton = (data.force) ? { label: data.force, eventName: 'save-force' } : ''; + + var alert = new n.Alert({ + message: data.text, + type: data.level, + buttons: [ { label: data.ok, eventName: 'ok' } ], + modal: data.flagModal, + timeout: data.timeout + }); - this.setValidationState(data["field-name"], "error"); - this.setHelpBlockValidationMessage(data["field-name"], data["field-message"]); + if (forceButton) alert.buttons.unshift(forceButton); + alert.on('alert.save-force', function () { + $("#save-button-" + form.formId).attr('data-save-force', 'save,force'); + $("#save-button-" + form.formId).click(); + }.bind(form.formId)); + alert.show(); + + } else { + this._createError(data.message); + if (data["field-name"] && this.bsTabs) { + var tabId = this.bsTabs.getContainingTabIdForFormControl(data["field-name"]); + if (tabId) { + this.bsTabs.activateTab(tabId); + } + + this.setValidationState(data["field-name"], "error"); + this.setHelpBlockValidationMessage(data["field-name"], data["field-message"]); + } } }; diff --git a/less/qfq-bs.css.less b/less/qfq-bs.css.less index a941cd8f755342cf20e72d08afb861b01c6c0250..f33c79badb831bf2308272ba241c42a5990c114d 100644 --- a/less/qfq-bs.css.less +++ b/less/qfq-bs.css.less @@ -851,22 +851,55 @@ span.qfq-typeahead-tag { .alert-interactive { position: fixed; - display: box; + display: block; left: 50%; transform: translate(-50%,0); top: 200px; max-height: 60%; - padding: 20px; - color: #d0d0d0; - min-width: 24%; + padding: 30px 20px 20px; + color: #333; + min-width: 300px; max-width: 90%; - border-left: 5px solid; - background-color: #333; + border: 1px solid; + border-left: 10px solid; + border-radius: 5px; + background-color: #fff; + box-shadow: 10px 10px 10px #ccc; overflow-y: auto; overflow-x: hidden; } -.alert-interactive tr td { +.darkmode .alert-interactive { + color: #d0d0d0; + border-left: 5px solid; + background-color: #333; + } + +.alert-interactive .qfq-alert-reference { + position: absolute; + right: 10px; + top: 3px; + font-size: .7em; + color: #888; +} + +.alert-interactive .qfq-alert-timestamp { + position: absolute; + left: 10px; + top: 3px; + font-size: .7em; + color: #888; +} + +.alert-warning .qfq-alert-timestamp::after { + content: ", "; +} + +.alert-interactive .qfq-alert-hidden { + display: none; +} + +.darkmode .alert-interactive tr td { color: #d0d0d0; } @@ -882,29 +915,70 @@ span.qfq-typeahead-tag { .alert-side { position: fixed; - display: box; + display: block; right: 0px; top: 20px; padding: 20px; + color: #333; + border-top: 1px solid; + border-bottom: 1px solid; + border-left: 10px solid; + background-color: #fff; + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + z-index: 10000; /* Always on top */ +} + +.darkmode .alert-side { color: #d0d0d0; - border-left: 5px solid; background-color: #333; - z-index: 10000; /* Always on top */ } .border-success { - border-color: #5cb85c; + border-color: #79AC78; +} + +.border-success .qfq-alert-reference, .border-success .qfq-alert-timestamp { + color: #79AC78; } .border-error { - border-color: #fb4f4f; + border-color: #FF8080; +} + +.border-error .qfq-alert-reference, .border-error .qfq-alert-timestamp { + color: #FF8080; } .border-warning { - border-color: #fbb64f; + border-color: #F9B572; +} + +.border-warning .qfq-alert-reference, .border-warning .qfq-alert-timestamp { + color: #F9B572; } .border-info { + border-color: #7286D3; +} + +.border-info .qfq-alert-reference, .border-info .qfq-alert-timestamp { + color: #7286D3; +} + +.darkmode .border-success { + border-color: #5cb85c; +} + +.darkmode .border-error { + border-color: #fb4f4f; +} + +.darkmode .border-warning { + border-color: #fbb64f; +} + +.darkmode .border-info { border-color: #25adf1; } @@ -1350,6 +1424,7 @@ thead.qfq-sticky td { .dropdown-menu { z-index: 991; + min-width: max-content; } // No colorized badges in BS3: make our own @@ -1489,4 +1564,46 @@ input.qfq-password { .dropdown-menu>li>a { margin: -3px -20px; color: #333; -} \ No newline at end of file +} + +// FilePond Upload label color +.filepond--item[data-filepond-item-state="idle"] .filepond--item-panel { + background-color:#d7d7d7 !important; +} + +// Filepond css adjustments +.filepond--file-info > .filepond--file-info-sub { + opacity: 0.5 !important; +} + +// Clean filename position +.filepond--file > .filepond--file-info { + transform: translate3d(0px, 0px, 0px) !important; + color: black; +} + +// Text mute for upload elements +label[id^="filepond--drop-label-"] { + color: #6c757d !important; +} + +// Hide complete indicator because its never needed +.filepond--processing-complete-indicator { + opacity: 0 !important; +} + +// Uploaded file background +.filepond--drip{ + background-color:#EDEDED !important; + opacity: 1 !important; +} + +// FilePond upload styling for form +.filepond--drop-label-form { + background-color: white; + border-radius: 5px; +} +.filepond--list-form { + width: 100%; + left: -3px !important; +} diff --git a/package.json b/package.json index cc20139228993cfa2cc1cc3688b00ff7d7040341..655410b8f8cae664501a398d230faecf02c67a16 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,14 @@ "bootstrap-datetimepicker": "0.0.7", "bootstrap-validator": "^0.11.5", "chart.js": "^2.9.4", - "codemirror": "^5.65.16", + "codemirror": "^5.65.15", + "filepond": "latest", + "filepond-plugin-file-validate-type": "latest", + "filepond-plugin-file-validate-size": "latest", + "filepond-plugin-image-preview": "latest", + "filepond-plugin-image-edit": "latest", "concat": "^1.0.3", - "corejs-typeahead": "^1.3.3", + "corejs-typeahead": "^1.3.1", "fullcalendar": "^3.10.2", "jquery": "latest", "jqwidgets-framework": "4.2.1", @@ -19,7 +24,7 @@ "moment": "latest", "ncp": "^2.0.0", "popper.js": "^1.16.1", - "selenium-webdriver": "^4.16.0", + "selenium-webdriver": "^4.14.0", "should": "^11.2.1", "tablesorter": "^2.31.3", "terser": "latest",