diff --git a/resources.qrc b/resources.qrc index fdd4bcf570e63..7a53e12e65106 100644 --- a/resources.qrc +++ b/resources.qrc @@ -59,5 +59,6 @@ src/gui/ResolveConflictsDialog.qml src/gui/ConflictDelegate.qml src/gui/ConflictItemFileInfo.qml + src/gui/filedetails/NCInputDateField.qml diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 0a89c6d307afc..fdcde5d6f32bd 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -190,6 +190,8 @@ set(client_SRCS syncconflictsmodel.cpp fileactivitylistmodel.h fileactivitylistmodel.cpp + filedetails/datefieldbackend.h + filedetails/datefieldbackend.cpp filedetails/filedetails.h filedetails/filedetails.cpp filedetails/sharemodel.h diff --git a/src/gui/filedetails/NCInputDateField.qml b/src/gui/filedetails/NCInputDateField.qml new file mode 100644 index 0000000000000..4c0ea491c7fe4 --- /dev/null +++ b/src/gui/filedetails/NCInputDateField.qml @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2022 by Claudio Cambra + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import com.nextcloud.desktopclient 1.0 + +NCInputTextField { + id: root + + signal userAcceptedDate + + function updateText() { + text = backend.dateString; + } + + DateFieldBackend { + id: backend + onDateStringChanged: if (!root.activeFocus) root.updateText() + } + + property alias date: backend.date + property alias dateInMs: backend.dateMsecs + property alias minimumDate: backend.minimumDate + property alias minimumDateMs: backend.minimumDateMsecs + property alias maximumDate: backend.maximumDate + property alias maximumDateMs: backend.maximumDateMsecs + + inputMethodHints: Qt.ImhDate + validInput: backend.validDate + text: backend.dateString + onTextChanged: backend.dateString = text + + onAccepted: { + backend.dateString = text; + root.userAcceptedDate(); + } +} \ No newline at end of file diff --git a/src/gui/filedetails/ShareDetailsPage.qml b/src/gui/filedetails/ShareDetailsPage.qml index 527f48de8402e..c09f42abd6dc2 100644 --- a/src/gui/filedetails/ShareDetailsPage.qml +++ b/src/gui/filedetails/ShareDetailsPage.qml @@ -64,7 +64,7 @@ Page { readonly property string passwordPlaceholder: "●●●●●●●●●●" readonly property var expireDate: shareModelData.expireDate // Don't use int as we are limited - readonly property var maximumExpireDate: shareModelData.enforcedMaximumExpireDate + readonly property var maximumExpireDate: shareModelData.enforcedMaximumExpireDate // msecs epoch readonly property string linkShareLabel: shareModelData.linkShareLabel ?? "" @@ -127,7 +127,7 @@ Page { // // So to ensure that the text of the spin box is correctly updated, force an update of the // contents of the expire date text field. - expireDateSpinBox.updateText(); + expireDateField.updateText(); waitingForExpireDateChange = false; } @@ -731,149 +731,29 @@ Page { sourceSize.height: scrollContentsColumn.rowIconWidth } - // QML dates are essentially JavaScript dates, which makes them very finicky and unreliable. - // Instead, we exclusively deal with msecs from epoch time to make things less painful when editing. - // We only use the QML Date when showing the nice string to the user. - SpinBox { - id: expireDateSpinBox - - function updateText() { - expireDateSpinBoxTextField.text = textFromValue(value, locale); - } - - // Work arounds the limitations of QML's 32 bit integer when handling msecs from epoch - // Instead, we handle everything as days since epoch - readonly property int dayInMSecs: 24 * 60 * 60 * 1000 - readonly property int expireDateReduced: Math.floor(root.expireDate / dayInMSecs) - // Reset the model data after binding broken on user interact - onExpireDateReducedChanged: { - value = expireDateReduced; - updateText(); - } - - // We can't use JS's convenient Infinity or Number.MAX_VALUE as - // JS Number type is 64 bits, whereas QML's int type is only 32 bits - readonly property IntValidator intValidator: IntValidator {} - readonly property int maximumExpireDateReduced: root.expireDateEnforced ? - Math.floor(root.maximumExpireDate / dayInMSecs) : - intValidator.top - readonly property int minimumExpireDateReduced: { - const currentDate = new Date(); - const minDateUTC = new Date(Date.UTC(currentDate.getFullYear(), - currentDate.getMonth(), - currentDate.getDate() + 1)); - return Math.floor(minDateUTC / dayInMSecs) // Start of day at 00:00:0000 UTC - } - - // Taken from Kalendar 22.08 - // https://invent.kde.org/pim/kalendar/-/blob/release/22.08/src/contents/ui/KalendarUtils/dateutils.js - function parseDateString(dateString) { - function defaultParse() { - const defaultParsedDate = Date.fromLocaleDateString(Qt.locale(), dateString, Locale.NarrowFormat); - // JS always generates date in system locale, eliminate timezone difference to UTC - const msecsSinceEpoch = defaultParsedDate.getTime() - (defaultParsedDate.getTimezoneOffset() * 60 * 1000); - return new Date(msecsSinceEpoch); - } - - const dateStringDelimiterMatches = dateString.match(/\D/); - if(dateStringDelimiterMatches.length === 0) { - // Let the date method figure out this weirdness - return defaultParse(); - } - - const dateStringDelimiter = dateStringDelimiterMatches[0]; - - const localisedDateFormatSplit = Qt.locale().dateFormat(Locale.NarrowFormat).split(dateStringDelimiter); - const localisedDateDayPosition = localisedDateFormatSplit.findIndex((x) => /d/gi.test(x)); - const localisedDateMonthPosition = localisedDateFormatSplit.findIndex((x) => /m/gi.test(x)); - const localisedDateYearPosition = localisedDateFormatSplit.findIndex((x) => /y/gi.test(x)); - - let splitDateString = dateString.split(dateStringDelimiter); - let userProvidedYear = splitDateString[localisedDateYearPosition] - - const dateNow = new Date(); - const stringifiedCurrentYear = dateNow.getFullYear().toString(); - - // If we have any input weirdness, or if we have a fully-written year - // (e.g. 2022 instead of 22) then use default parse - if(splitDateString.length === 0 || - splitDateString.length > 3 || - userProvidedYear.length >= stringifiedCurrentYear.length) { - - return defaultParse(); - } - - let fullyWrittenYear = userProvidedYear.split(""); - const digitsToAdd = stringifiedCurrentYear.length - fullyWrittenYear.length; - for(let i = 0; i < digitsToAdd; i++) { - fullyWrittenYear.splice(i, 0, stringifiedCurrentYear[i]) - } - fullyWrittenYear = fullyWrittenYear.join(""); - - const fixedYearNum = Number(fullyWrittenYear); - const monthIndexNum = Number(splitDateString[localisedDateMonthPosition]) - 1; - const dayNum = Number(splitDateString[localisedDateDayPosition]); - - console.log(dayNum, monthIndexNum, fixedYearNum); - - // Modification: return date in UTC - return new Date(Date.UTC(fixedYearNum, monthIndexNum, dayNum)); - } + NCInputDateField { + id: expireDateField Layout.fillWidth: true height: visible ? implicitHeight : 0 - // We want all the internal benefits of the spinbox but don't actually want the - // buttons, so set an empty item as a dummy - up.indicator: Item {} - down.indicator: Item {} - - padding: 0 - background: null - contentItem: NCInputTextField { - id: expireDateSpinBoxTextField - - validInput: { - const value = expireDateSpinBox.valueFromText(text); - return value >= expireDateSpinBox.from && value <= expireDateSpinBox.to; - } - - text: expireDateSpinBox.textFromValue(expireDateSpinBox.value, expireDateSpinBox.locale) - readOnly: !expireDateSpinBox.editable - validator: expireDateSpinBox.validator - inputMethodHints: Qt.ImhFormattedNumbersOnly - onAccepted: { - expireDateSpinBox.value = expireDateSpinBox.valueFromText(text, expireDateSpinBox.locale); - expireDateSpinBox.valueModified(); - } - } - - value: expireDateReduced - from: minimumExpireDateReduced - to: maximumExpireDateReduced - - textFromValue: (value, locale) => { - const dateFromValue = new Date(value * dayInMSecs); - return dateFromValue.toLocaleDateString(Qt.locale(), Locale.NarrowFormat); - } - valueFromText: (text, locale) => { - const dateFromText = parseDateString(text); - return Math.floor(dateFromText.getTime() / dayInMSecs); + dateInMs: root.expireDate + maximumDateMs: root.maximumExpireDate + minimumDateMs: { + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth(); + const currentMonthDay = currentDate.getDate(); + // Start of day at 00:00:0000 UTC + return Date.UTC(currentYear, currentMonth, currentMonthDay + 1); } - editable: true - inputMethodHints: Qt.ImhDate | Qt.ImhFormattedNumbersOnly - enabled: root.expireDateEnabled && !root.waitingForExpireDateChange && !root.waitingForExpireDateEnabledChange - onValueModified: { - if (!enabled || !activeFocus) { - return; - } - - root.setExpireDate(value * dayInMSecs); + onUserAcceptedDate: { + root.setExpireDate(dateInMs); root.waitingForExpireDateChange = true; } diff --git a/src/gui/filedetails/datefieldbackend.cpp b/src/gui/filedetails/datefieldbackend.cpp new file mode 100644 index 0000000000000..036ee7ee8302d --- /dev/null +++ b/src/gui/filedetails/datefieldbackend.cpp @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2023 by Claudio Cambra + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#include "datefieldbackend.h" + +#include +#include +#include + +namespace OCC +{ +namespace Quick +{ + +DateFieldBackend::DateFieldBackend(QObject *const parent) + : QObject(parent) +{ + _dateFormat = QLocale::system().dateFormat(QLocale::ShortFormat); + + // Ensure the date format is for a full year. QLocale::ShortFormat often + // provides a short year format that is only two years, which is an absolute + // pain to work with -- ensure instead we have the full, unambiguous year. + // Check for specifically two y's, no more and no fewer, within format date + const QRegularExpression yearRe("(? 0) { + valid &= _date >= _minimumDate; + } + + if (_maximumDate.isValid() && maximumDateMsecs() > 0) { + valid &= _date <= _maximumDate; + } + + return valid; +} +} +} \ No newline at end of file diff --git a/src/gui/filedetails/datefieldbackend.h b/src/gui/filedetails/datefieldbackend.h new file mode 100644 index 0000000000000..c9e275dd04243 --- /dev/null +++ b/src/gui/filedetails/datefieldbackend.h @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2023 by Claudio Cambra + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#pragma once + +#include +#include + +class TestDateFieldBackend; + +namespace OCC +{ +namespace Quick +{ + +class DateFieldBackend : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QDate date READ date WRITE setDate NOTIFY dateChanged) + Q_PROPERTY(qint64 dateMsecs READ dateMsecs WRITE setDateMsecs NOTIFY dateMsecsChanged) + Q_PROPERTY(QString dateString READ dateString WRITE setDateString NOTIFY dateStringChanged) + + Q_PROPERTY(QDate minimumDate READ minimumDate WRITE setMinimumDate NOTIFY minimumDateChanged) + Q_PROPERTY(qint64 minimumDateMsecs READ minimumDateMsecs WRITE setMinimumDateMsecs NOTIFY minimumDateMsecsChanged) + + Q_PROPERTY(QDate maximumDate READ maximumDate WRITE setMaximumDate NOTIFY maximumDateChanged) + Q_PROPERTY(qint64 maximumDateMsecs READ maximumDateMsecs WRITE setMaximumDateMsecs NOTIFY maximumDateMsecsChanged) + + Q_PROPERTY(bool validDate READ validDate NOTIFY validDateChanged) + +public: + explicit DateFieldBackend(QObject *const parent = nullptr); + + [[nodiscard]] QDate date() const; + [[nodiscard]] qint64 dateMsecs() const; + [[nodiscard]] QString dateString() const; + + [[nodiscard]] QDate minimumDate() const; + [[nodiscard]] qint64 minimumDateMsecs() const; + + [[nodiscard]] QDate maximumDate() const; + [[nodiscard]] qint64 maximumDateMsecs() const; + + [[nodiscard]] bool validDate() const; + +public slots: + void setDate(const QDate &date); + void setDateMsecs(const qint64 dateMsecs); + void setDateString(const QString &dateString); + + void setMinimumDate(const QDate &minimumDate); + void setMinimumDateMsecs(const qint64 minimumDateMsecs); + + void setMaximumDate(const QDate &maximumDate); + void setMaximumDateMsecs(const qint64 maximumDateMsecs); + +signals: + void dateChanged(); + void dateMsecsChanged(); + void dateStringChanged(); + + void minimumDateChanged(); + void minimumDateMsecsChanged(); + + void maximumDateChanged(); + void maximumDateMsecsChanged(); + + void validDateChanged(); + +private: + friend class ::TestDateFieldBackend; + + QDate _date = QDate::currentDate(); + QDate _minimumDate; + QDate _maximumDate; + + QString _dateFormat; + QString _leadingZeroMonthDateFormat; +}; + +} // namespace Quick +} // namespace OCC diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 9d1d5856f3405..6749df8a6e91b 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -32,6 +32,7 @@ #include "theme.h" #include "wheelhandler.h" #include "syncconflictsmodel.h" +#include "filedetails/datefieldbackend.h" #include "filedetails/filedetails.h" #include "filedetails/shareemodel.h" #include "filedetails/sharemodel.h" @@ -118,6 +119,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SortedActivityListModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "WheelHandler"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "CallStateChecker"); + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "DateFieldBackend"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileDetails"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareeModel"); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 69ebc08d518b4..e1ea80801ded0 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -74,6 +74,7 @@ nextcloud_add_test(SortedShareModel) nextcloud_add_test(SecureFileDrop) nextcloud_add_test(FileTagModel) nextcloud_add_test(SyncConflictsModel) +nextcloud_add_test(DateFieldBackend) target_link_libraries(SecureFileDropTest PRIVATE Nextcloud::sync) configure_file(fake2eelocksucceeded.json "${PROJECT_BINARY_DIR}/bin/fake2eelocksucceeded.json" COPYONLY) diff --git a/test/testdatefieldbackend.cpp b/test/testdatefieldbackend.cpp new file mode 100644 index 0000000000000..c80aa3a2db2e4 --- /dev/null +++ b/test/testdatefieldbackend.cpp @@ -0,0 +1,152 @@ +/* + * Copyright (C) by Claudio Cambra + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#include "gui/filedetails/datefieldbackend.h" + +#include +#include + +using namespace OCC; + +class TestDateFieldBackend : public QObject +{ + Q_OBJECT + +private: + static constexpr auto dateStringFormat = "dd/MM/yyyy"; + +private slots: + void testDefaultBehaviour() + { + Quick::DateFieldBackend backend; + backend._dateFormat = dateStringFormat; + + const auto currentDate = QDate::currentDate(); + const auto currentDateMSecs = currentDate.startOfDay(Qt::UTC).toMSecsSinceEpoch(); + const auto currentDateString = currentDate.toString(dateStringFormat); + + QCOMPARE(backend.date(), currentDate); + QCOMPARE(backend.dateMsecs(), currentDateMSecs); + QCOMPARE(backend.dateString(), currentDateString); + } + + void testDateBoundaries() + { + Quick::DateFieldBackend backend; + + QSignalSpy minimumDateChangedSpy(&backend, &Quick::DateFieldBackend::minimumDateChanged); + QSignalSpy maximumDateChangedSpy(&backend, &Quick::DateFieldBackend::maximumDateChanged); + QSignalSpy minimumDateMsecsChangedSpy(&backend, &Quick::DateFieldBackend::minimumDateMsecsChanged); + QSignalSpy maximumDateMsecsChangedSpy(&backend, &Quick::DateFieldBackend::maximumDateMsecsChanged); + QSignalSpy validDateChangedSpy(&backend, &Quick::DateFieldBackend::validDateChanged); + + const auto minDate = QDate::currentDate().addDays(-5); + const auto maxDate = QDate::currentDate().addDays(5); + const auto minDateMs = minDate.startOfDay(Qt::UTC).toMSecsSinceEpoch(); + const auto maxDateMs = maxDate.startOfDay(Qt::UTC).toMSecsSinceEpoch(); + const auto invalidMinDate = minDate.addDays(-1); + const auto invalidMaxDate = maxDate.addDays(1); + + // Set by QDate + backend.setMinimumDate(minDate); + backend.setMaximumDate(maxDate); + + QCOMPARE(backend.minimumDate(), minDate); + QCOMPARE(backend.maximumDate(), maxDate); + QCOMPARE(backend.minimumDateMsecs(), minDateMs); + QCOMPARE(backend.maximumDateMsecs(), maxDateMs); + + QCOMPARE(minimumDateChangedSpy.count(), 1); + QCOMPARE(maximumDateChangedSpy.count(), 1); + QCOMPARE(minimumDateMsecsChangedSpy.count(), 1); + QCOMPARE(maximumDateMsecsChangedSpy.count(), 1); + QCOMPARE(validDateChangedSpy.count(), 2); // Changes per each min/max date set + + // Reset and try when setting by MSecs + backend.setMinimumDate({}); + backend.setMaximumDate({}); + backend.setMinimumDateMsecs(minDateMs); + backend.setMaximumDateMsecs(maxDateMs); + + QCOMPARE(backend.minimumDate(), minDate); + QCOMPARE(backend.maximumDate(), maxDate); + QCOMPARE(backend.minimumDateMsecs(), minDateMs); + QCOMPARE(backend.maximumDateMsecs(), maxDateMs); + + QCOMPARE(minimumDateChangedSpy.count(), 3); + QCOMPARE(maximumDateChangedSpy.count(), 3); + QCOMPARE(minimumDateMsecsChangedSpy.count(), 3); + QCOMPARE(maximumDateMsecsChangedSpy.count(), 3); + QCOMPARE(validDateChangedSpy.count(), 6); + + // Since we default to the current date, the date should be valid + QVERIFY(backend.validDate()); + + // Now try with invalid dates + backend.setDate(invalidMinDate); + QVERIFY(!backend.validDate()); + QCOMPARE(validDateChangedSpy.count(), 7); + + backend.setDate(invalidMaxDate); + QVERIFY(!backend.validDate()); + QCOMPARE(validDateChangedSpy.count(), 8); + } + + void testDateSettingMethods() + { + Quick::DateFieldBackend backend; + backend._dateFormat = dateStringFormat; + + QSignalSpy dateChangedSpy(&backend, &Quick::DateFieldBackend::dateChanged); + QSignalSpy dateMsecsChangedSpy(&backend, &Quick::DateFieldBackend::dateMsecsChanged); + QSignalSpy dateStringChangedSpy(&backend, &Quick::DateFieldBackend::dateStringChanged); + + const auto testDate = QDate::currentDate().addDays(800); + const auto testDateMsecs = testDate.startOfDay(Qt::UTC).toMSecsSinceEpoch(); + const auto testDateString = testDate.toString(dateStringFormat); + + backend.setDate(testDate); + QCOMPARE(backend.date(), testDate); + QCOMPARE(dateChangedSpy.count(), 1); + QCOMPARE(dateMsecsChangedSpy.count(), 1); + QCOMPARE(dateStringChangedSpy.count(), 1); + + backend.setDate({}); + QVERIFY(backend.date() != testDate); + QCOMPARE(dateChangedSpy.count(), 2); + QCOMPARE(dateMsecsChangedSpy.count(), 2); + QCOMPARE(dateStringChangedSpy.count(), 2); + + backend.setDateMsecs(testDateMsecs); + QCOMPARE(backend.date(), testDate); + QCOMPARE(dateChangedSpy.count(), 3); + QCOMPARE(dateMsecsChangedSpy.count(), 3); + QCOMPARE(dateStringChangedSpy.count(), 3); + + backend.setDate({}); + QVERIFY(backend.date() != testDate); + QCOMPARE(dateChangedSpy.count(), 4); + QCOMPARE(dateMsecsChangedSpy.count(), 4); + QCOMPARE(dateStringChangedSpy.count(), 4); + + backend.setDateString(testDateString); + QCOMPARE(backend.date(), testDate); + QCOMPARE(dateChangedSpy.count(), 5); + QCOMPARE(dateMsecsChangedSpy.count(), 5); + QCOMPARE(dateStringChangedSpy.count(), 5); + } +}; + +QTEST_MAIN(TestDateFieldBackend) +#include "testdatefieldbackend.moc"