From fdf854bd81d9e42df2d8672d49a0b7fcdb7443a5 Mon Sep 17 00:00:00 2001 From: =?utf8?q?M=C3=A9ven=20Car?= Date: Sun, 27 Oct 2024 17:20:40 +0000 Subject: [PATCH] ViewProperties: Store view properties in extended file attributes Existing settings are converted. Works on most FS except FAT/exFAT which fallback to .directory files. If the extended file attributes (in ADS in Windows) can't be saved, they are saved to file as before. BUG: 322922 You can see file xattr using for instance for Unix filesystems: getfattr -d /home/meven --- src/CMakeLists.txt | 2 +- ...dolphin_directoryviewpropertysettings.kcfg | 2 +- .../viewmodes/generalviewsettingspage.cpp | 4 +- src/tests/CMakeLists.txt | 2 +- src/tests/viewpropertiestest.cpp | 192 +++++++++++++++++- src/views/dolphinview.cpp | 3 +- src/views/viewproperties.cpp | 188 +++++++++++++++-- src/views/viewproperties.h | 15 +- 8 files changed, 371 insertions(+), 37 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ce5b54613..7d1206e48 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -217,6 +217,7 @@ target_link_libraries( KF6::WidgetsAddons KF6::Codecs KF6::KCMUtils + KF6::FileMetaData ${FTS_LIB} ) @@ -224,7 +225,6 @@ target_link_libraries( if(HAVE_BALOO) target_link_libraries( dolphinprivate PUBLIC - KF6::FileMetaData KF6::Baloo KF6::BalooWidgets ) diff --git a/src/settings/dolphin_directoryviewpropertysettings.kcfg b/src/settings/dolphin_directoryviewpropertysettings.kcfg index f4d288369..e0c8aa1cc 100644 --- a/src/settings/dolphin_directoryviewpropertysettings.kcfg +++ b/src/settings/dolphin_directoryviewpropertysettings.kcfg @@ -24,7 +24,7 @@ This option controls the style of the view. Currently supported values include icons (0), details (1) and column (2) views. - DolphinView::IconsView + DolphinView::IconsView diff --git a/src/settings/viewmodes/generalviewsettingspage.cpp b/src/settings/viewmodes/generalviewsettingspage.cpp index 51ab664f1..c518147dd 100644 --- a/src/settings/viewmodes/generalviewsettingspage.cpp +++ b/src/settings/viewmodes/generalviewsettingspage.cpp @@ -40,7 +40,9 @@ GeneralViewSettingsPage::GeneralViewSettingsPage(const QUrl &url, QWidget *paren globalViewPropsLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); m_localViewProps = new QRadioButton(i18nc("@option:radio", "Remember display style for each folder")); - QLabel *localViewPropsLabel = new QLabel(i18nc("@info", "Dolphin will create a hidden .directory file in each folder you change view properties for.")); + QLabel *localViewPropsLabel = new QLabel(i18nc("@info", + "Dolphin will add file system metadata to folders you change view properties for. If that is not possible, " + "a hidden .directory file is created instead.")); localViewPropsLabel->setFont(QFontDatabase::systemFont(QFontDatabase::SmallestReadableFont)); localViewPropsLabel->setWordWrap(true); localViewPropsLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 20b682fe0..1ef82e3d9 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -59,7 +59,7 @@ endif() # ViewPropertiesTest ecm_add_test(viewpropertiestest.cpp testdir.cpp TEST_NAME viewpropertiestest -LINK_LIBRARIES dolphinprivate dolphinstatic Qt6::Test) +LINK_LIBRARIES dolphinprivate dolphinstatic Qt6::Test KF6::FileMetaData) # DolphinMainWindowTest ecm_add_test(dolphinmainwindowtest.cpp testdir.cpp ${CMAKE_SOURCE_DIR}/src/dolphin.qrc diff --git a/src/tests/viewpropertiestest.cpp b/src/tests/viewpropertiestest.cpp index 7b30203d2..9c2c9466b 100644 --- a/src/tests/viewpropertiestest.cpp +++ b/src/tests/viewpropertiestest.cpp @@ -8,6 +8,9 @@ #include "dolphin_generalsettings.h" #include "testdir.h" +#include + +#include #include class ViewPropertiesTest : public QObject @@ -20,7 +23,11 @@ private Q_SLOTS: void cleanup(); void testReadOnlyBehavior(); + void testReadOnlyDirectory(); void testAutoSave(); + void testParamMigrationToFileAttr(); + void testParamMigrationToFileAttrKeepDirectory(); + void testExtendedAttributeFull(); private: bool m_globalViewProps; @@ -73,6 +80,45 @@ void ViewPropertiesTest::testReadOnlyBehavior() QVERIFY(!QFile::exists(dotDirectoryFile)); } +void ViewPropertiesTest::testReadOnlyDirectory() +{ + auto localFolder = m_testDir->url().toLocalFile(); + QString dotDirectoryFile = localFolder + "/.directory"; + QVERIFY(!QFile::exists(dotDirectoryFile)); + + // restrict write permissions + QVERIFY(QFile(localFolder).setPermissions(QFileDevice::ReadOwner)); + + QScopedPointer props(new ViewProperties(m_testDir->url())); + QVERIFY(props->isAutoSaveEnabled()); + props->setSortRole("someNewSortRole"); + props.reset(); + + const auto destinationDir = props->destinationDir(QStringLiteral("local")) + localFolder; + qDebug() << destinationDir; + QVERIFY(QDir(destinationDir).exists()); + + QVERIFY(!QFile::exists(dotDirectoryFile)); + KFileMetaData::UserMetaData metadata(localFolder); + auto viewProperties = metadata.attribute(QStringLiteral("kde.fm.viewproperties#1")); + QVERIFY(viewProperties.isEmpty()); + + props.reset(new ViewProperties(m_testDir->url())); + QVERIFY(props->isAutoSaveEnabled()); + QCOMPARE(props->sortRole(), "someNewSortRole"); + props.reset(); + + metadata = KFileMetaData::UserMetaData(destinationDir); + if (metadata.isSupported()) { + QVERIFY(metadata.hasAttribute("kde.fm.viewproperties#1")); + } else { + QVERIFY(QFile::exists(destinationDir + "/.directory")); + } + + // un-restrict write permissions + QFile(localFolder).setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner); +} + void ViewPropertiesTest::testAutoSave() { QString dotDirectoryFile = m_testDir->url().toLocalFile() + "/.directory"; @@ -83,7 +129,151 @@ void ViewPropertiesTest::testAutoSave() props->setSortRole("someNewSortRole"); props.reset(); - QVERIFY(QFile::exists(dotDirectoryFile)); + KFileMetaData::UserMetaData metadata(m_testDir->url().toLocalFile()); + if (metadata.isSupported()) { + auto viewProperties = metadata.attribute(QStringLiteral("kde.fm.viewproperties#1")); + QVERIFY(!viewProperties.isEmpty()); + QVERIFY(!QFile::exists(dotDirectoryFile)); + } else { + QVERIFY(QFile::exists(dotDirectoryFile)); + } +} + +void ViewPropertiesTest::testParamMigrationToFileAttr() +{ + QString dotDirectoryFilePath = m_testDir->url().toLocalFile() + "/.directory"; + QVERIFY(!QFile::exists(dotDirectoryFilePath)); + + const char *settingsContent = R"SETTINGS(" +[Dolphin] +Version=4 +ViewMode=1 +Timestamp=2023,12,29,10,44,15.793 +VisibleRoles=text,CustomizedDetails,Details_text,Details_modificationtime,Details_type + +[Settings] +HiddenFilesShown=true)SETTINGS"; + auto dotDirectoryFile = QFile(dotDirectoryFilePath); + QVERIFY(dotDirectoryFile.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)); + QTextStream out(&dotDirectoryFile); + out << settingsContent; + dotDirectoryFile.close(); + + KFileMetaData::UserMetaData metadata(m_testDir->url().toLocalFile()); + { + QScopedPointer props(new ViewProperties(m_testDir->url())); + QCOMPARE(props->viewMode(), DolphinView::Mode::DetailsView); + QVERIFY(props->hiddenFilesShown()); + props->save(); + + if (metadata.isSupported()) { + auto viewProperties = metadata.attribute(QStringLiteral("kde.fm.viewproperties#1")); + QVERIFY(!viewProperties.isEmpty()); + QVERIFY(!QFile::exists(dotDirectoryFilePath)); + } else { + QVERIFY(QFile::exists(dotDirectoryFilePath)); + } + } + + ViewProperties props(m_testDir->url()); + QCOMPARE(props.viewMode(), DolphinView::Mode::DetailsView); + QVERIFY(props.hiddenFilesShown()); +} + +void ViewPropertiesTest::testParamMigrationToFileAttrKeepDirectory() +{ + QString dotDirectoryFilePath = m_testDir->url().toLocalFile() + "/.directory"; + QVERIFY(!QFile::exists(dotDirectoryFilePath)); + + const char *settingsContent = R"SETTINGS(" +[Dolphin] +Version=4 +ViewMode=1 +Timestamp=2023,12,29,10,44,15.793 +VisibleRoles=text,CustomizedDetails,Details_text,Details_modificationtime,Details_type + +[Settings] +HiddenFilesShown=true + +[Other] +ThoseShouldBeKept=true +)SETTINGS"; + auto dotDirectoryFile = QFile(dotDirectoryFilePath); + QVERIFY(dotDirectoryFile.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)); + QTextStream out(&dotDirectoryFile); + out << settingsContent; + dotDirectoryFile.close(); + + KFileMetaData::UserMetaData metadata(m_testDir->url().toLocalFile()); + { + QScopedPointer props(new ViewProperties(m_testDir->url())); + QCOMPARE(props->viewMode(), DolphinView::Mode::DetailsView); + QVERIFY(props->hiddenFilesShown()); + props->save(); + + if (metadata.isSupported()) { + auto viewProperties = metadata.attribute(QStringLiteral("kde.fm.viewproperties#1")); + QVERIFY(!viewProperties.isEmpty()); + } + + QVERIFY(QFile::exists(dotDirectoryFilePath)); + KConfig directorySettings(dotDirectoryFilePath, KConfig::SimpleConfig); + QCOMPARE(directorySettings.groupList(), {"Other"}); + } + + ViewProperties props(m_testDir->url()); + QVERIFY(props.hiddenFilesShown()); + QCOMPARE(props.viewMode(), DolphinView::Mode::DetailsView); + + QVERIFY(QFile::exists(dotDirectoryFilePath)); +} + +void ViewPropertiesTest::testExtendedAttributeFull() +{ +#ifndef Q_OS_UNIX + QSKIP("Only unix is supported, for this test"); +#endif + QString dotDirectoryFile = m_testDir->url().toLocalFile() + "/.directory"; + QVERIFY(!QFile::exists(dotDirectoryFile)); + + KFileMetaData::UserMetaData metadata(m_testDir->url().toLocalFile()); + if (!metadata.isSupported()) { + QSKIP("need extended attribute/filesystem metadata to be usefull"); + } + + QStorageInfo storageInfo(m_testDir->url().toLocalFile()); + auto blockSize = storageInfo.blockSize(); + + KFileMetaData::UserMetaData::Error result; + // write a close to block size theorical maximum size for attributes in Linux for ext4 + // and btrfs (4Kib typically) when ReiserFS/XFS allow XATTR_SIZE_MAX (64Kib) + result = metadata.setAttribute("data", QString(blockSize - 50, 'a')); + if (result != KFileMetaData::UserMetaData::NoSpace) { + QSKIP("File system supports metadata bigger than file system block size"); + } + + // write a close to 4k attribute, maximum size in Linux for ext4 + // so next writing the file metadata fails + result = metadata.setAttribute("data", QString(blockSize - 60, 'a')); + QCOMPARE(result, KFileMetaData::UserMetaData::NoError); + + QScopedPointer props(new ViewProperties(m_testDir->url())); + QVERIFY(props->isAutoSaveEnabled()); + props->setSortRole("someNewSortRole"); + props.reset(); + + if (metadata.isSupported()) { + auto viewProperties = metadata.attribute(QStringLiteral("kde.fm.viewproperties#1")); + QVERIFY(viewProperties.isEmpty()); + QVERIFY(QFile::exists(dotDirectoryFile)); + + QFile dotDirectory(dotDirectoryFile); + KConfig viewSettings(dotDirectoryFile, KConfig::SimpleConfig); + QCOMPARE(viewSettings.groupList(), {"Dolphin"}); + QCOMPARE(viewSettings.group("Dolphin").readEntry("SortRole"), "someNewSortRole"); + } else { + QVERIFY(QFile::exists(dotDirectoryFile)); + } } QTEST_GUILESS_MAIN(ViewPropertiesTest) diff --git a/src/views/dolphinview.cpp b/src/views/dolphinview.cpp index f9f32d35c..0c5ebb1df 100644 --- a/src/views/dolphinview.cpp +++ b/src/views/dolphinview.cpp @@ -375,9 +375,8 @@ void DolphinView::setGroupedSorting(bool grouped) ViewProperties props(viewPropertiesUrl()); props.setGroupedSorting(grouped); - props.save(); - m_container->controller()->model()->setGroupedSorting(grouped); + m_model->setGroupedSorting(grouped); Q_EMIT groupedSortingChanged(grouped); } diff --git a/src/views/viewproperties.cpp b/src/views/viewproperties.cpp index d65572f21..0536e028d 100644 --- a/src/views/viewproperties.cpp +++ b/src/views/viewproperties.cpp @@ -12,8 +12,10 @@ #include "dolphindebug.h" #include +#include #include +#include namespace { @@ -31,6 +33,68 @@ const char CustomizedDetailsString[] = "CustomizedDetails"; const char ViewPropertiesFileName[] = ".directory"; } +ViewPropertySettings *ViewProperties::loadProperties(const QString &folderPath) const +{ + const QString settingsFile = folderPath + QDir::separator() + ViewPropertiesFileName; + + KFileMetaData::UserMetaData metadata(folderPath); + if (!metadata.isSupported()) { + return new ViewPropertySettings(KSharedConfig::openConfig(settingsFile, KConfig::SimpleConfig)); + } + + QTemporaryFile tempFile; + tempFile.setAutoRemove(false); + if (!tempFile.open()) { + qCWarning(DolphinDebug) << "Could not open temp file"; + return nullptr; + } + + if (QFile::exists(settingsFile)) { + // copy settings to tempfile to load them separately + QFile::remove(tempFile.fileName()); + QFile::copy(settingsFile, tempFile.fileName()); + + auto config = KConfig(tempFile.fileName(), KConfig::SimpleConfig); + // ignore settings that are outside of dolphin scope + if (config.hasGroup("Dolphin") || config.hasGroup("Settings")) { + const auto groupList = config.groupList(); + for (const auto &group : groupList) { + if (group != QStringLiteral("Dolphin") && group != QStringLiteral("Settings")) { + config.deleteGroup(group); + } + } + return new ViewPropertySettings(KSharedConfig::openConfig(tempFile.fileName(), KConfig::SimpleConfig)); + + } else if (!config.groupList().isEmpty()) { + // clear temp file content + QFile::remove(tempFile.fileName()); + } + } + + // load from metadata + const QString viewPropertiesString = metadata.attribute(QStringLiteral("kde.fm.viewproperties#1")); + if (!viewPropertiesString.isEmpty()) { + // load view properties from xattr to temp file then loads into ViewPropertySettings + // clear the temp file + QFile outputFile(tempFile.fileName()); + outputFile.open(QIODevice::WriteOnly); + outputFile.write(viewPropertiesString.toUtf8()); + outputFile.close(); + } + return new ViewPropertySettings(KSharedConfig::openConfig(tempFile.fileName(), KConfig::SimpleConfig)); +} + +ViewPropertySettings *ViewProperties::defaultProperties() const +{ + auto props = loadProperties(destinationDir(QStringLiteral("global"))); + if (props == nullptr) { + qCWarning(DolphinDebug) << "Could not load default global viewproperties"; + props = new ViewPropertySettings; + } + + return props; +} + ViewProperties::ViewProperties(const QUrl &url) : m_changedProps(false) , m_autoSave(true) @@ -90,14 +154,22 @@ ViewProperties::ViewProperties(const QUrl &url) m_filePath = destinationDir(QStringLiteral("remote")) + m_filePath; } - const QString file = m_filePath + QDir::separator() + ViewPropertiesFileName; - m_node = new ViewPropertySettings(KSharedConfig::openConfig(file)); + m_node = loadProperties(m_filePath); + + bool useDefaultSettings = useGlobalViewProps || + // If the props timestamp is too old, + // use default values instead. + (m_node != nullptr && (!useGlobalViewProps || useSearchView || useTrashView || useRecentDocumentsView || useDownloadsView) + && m_node->timestamp() < settings->viewPropsTimestamp()); - // If the .directory file does not exist or the timestamp is too old, - // use default values instead. - const bool useDefaultProps = (!useGlobalViewProps || useSearchView || useTrashView || useRecentDocumentsView || useDownloadsView) - && (!QFile::exists(file) || (m_node->timestamp() < settings->viewPropsTimestamp())); - if (useDefaultProps) { + if (m_node == nullptr) { + // no settings found for m_filepath, load defaults + m_node = defaultProperties(); + useDefaultSettings = true; + } + + // default values for special directories + if (useDefaultSettings) { if (useSearchView) { const QString path = url.path(); @@ -132,14 +204,6 @@ ViewProperties::ViewProperties(const QUrl &url) setSortRole(QByteArrayLiteral("modificationtime")); } } else { - // The global view-properties act as default for directories without - // any view-property configuration. Constructing a ViewProperties - // instance for an empty QUrl ensures that the global view-properties - // are loaded. - QUrl emptyUrl; - ViewProperties defaultProps(emptyUrl); - setDirProperties(defaultProps); - m_changedProps = false; } } @@ -172,6 +236,11 @@ ViewProperties::~ViewProperties() save(); } + if (!m_node->config()->name().endsWith(ViewPropertiesFileName)) { + // remove temp file + QFile::remove(m_node->config()->name()); + } + delete m_node; m_node = nullptr; } @@ -412,17 +481,94 @@ void ViewProperties::update() void ViewProperties::save() { qCDebug(DolphinDebug) << "Saving view-properties to" << m_filePath; + + auto cleanDotDirectoryFile = [this]() { + const QString settingsFile = m_filePath + QDir::separator() + ViewPropertiesFileName; + if (QFile::exists(settingsFile)) { + qCDebug(DolphinDebug) << "cleaning .directory" << settingsFile; + KConfig cfg(settingsFile, KConfig::OpenFlag::SimpleConfig); + const auto groupList = cfg.groupList(); + for (const auto &group : groupList) { + if (group == QStringLiteral("Dolphin") || group == QStringLiteral("Settings")) { + cfg.deleteGroup(group); + } + } + if (cfg.groupList().isEmpty()) { + QFile::remove(settingsFile); + } else if (cfg.isDirty()) { + cfg.sync(); + } + } + }; + + // ensures the destination dir exists, in case we don't write metadata directly on the folder + QDir destinationDir(m_filePath); + if (!destinationDir.exists() && !destinationDir.mkpath(m_filePath)) { + qCWarning(DolphinDebug) << "Could not create fake directory to store metadata"; + } + + KFileMetaData::UserMetaData metaData(m_filePath); + if (metaData.isSupported()) { + const auto metaDataKey = QStringLiteral("kde.fm.viewproperties#1"); + + const auto items = m_node->items(); + const bool allDefault = std::all_of(items.cbegin(), items.cend(), [this](const KConfigSkeletonItem *item) { + return item->name() == "Timestamp" || (item->name() == "Version" && m_node->version() == CurrentViewPropertiesVersion) || item->isDefault(); + }); + if (allDefault) { + if (metaData.hasAttribute(metaDataKey)) { + qCDebug(DolphinDebug) << "clearing extended attributes for " << m_filePath; + const auto result = metaData.setAttribute(metaDataKey, QString()); + if (result != KFileMetaData::UserMetaData::NoError) { + qCWarning(DolphinDebug) << "could not clear extended attributes for " << m_filePath << "error:" << result; + } + } + cleanDotDirectoryFile(); + return; + } + + // save config to disk + if (!m_node->save()) { + qCWarning(DolphinDebug) << "could not save viewproperties" << m_node->config()->name(); + return; + } + + QFile configFile(m_node->config()->name()); + if (!configFile.open(QIODevice::ReadOnly)) { + qCWarning(DolphinDebug) << "Could not open readonly config file" << m_node->config()->name(); + } else { + // load config from disk + const QString viewPropertiesString = configFile.readAll(); + + // save to xattr + const auto result = metaData.setAttribute(metaDataKey, viewPropertiesString); + if (result != KFileMetaData::UserMetaData::NoError) { + if (result == KFileMetaData::UserMetaData::NoSpace) { + // copy settings to dotDirectory file as fallback + if (!configFile.copy(m_filePath + QDir::separator() + ViewPropertiesFileName)) { + qCWarning(DolphinDebug) << "could not write viewproperties to .directory for dir " << m_filePath; + } + // free the space used by viewproperties from the file metadata + metaData.setAttribute(metaDataKey, ""); + } else { + qCWarning(DolphinDebug) << "could not save viewproperties to extended attributes for dir " << m_filePath << "error:" << result; + } + // keep .directory file + return; + } + cleanDotDirectoryFile(); + } + + m_changedProps = false; + return; + } + QDir dir; dir.mkpath(m_filePath); m_node->setVersion(CurrentViewPropertiesVersion); m_node->save(); - m_changedProps = false; -} -bool ViewProperties::exist() const -{ - const QString file = m_filePath + QDir::separator() + ViewPropertiesFileName; - return QFile::exists(file); + m_changedProps = false; } QString ViewProperties::destinationDir(const QString &subDir) const diff --git a/src/views/viewproperties.h b/src/views/viewproperties.h index 29827c38b..44c703482 100644 --- a/src/views/viewproperties.h +++ b/src/views/viewproperties.h @@ -108,15 +108,6 @@ public: */ void save(); - /** - * @return True if properties for the given URL exist: - * As soon as the properties for an URL have been saved with - * ViewProperties::save(), true will be returned. If false is - * returned, the default view-properties are used. - */ - bool exist() const; - -private: /** * Returns the destination directory path where the view * properties are stored. \a subDir specifies the used sub @@ -124,6 +115,7 @@ private: */ QString destinationDir(const QString &subDir) const; +private: /** * Returns the view-mode prefix when storing additional properties for * a view-mode. @@ -161,6 +153,11 @@ private: */ static QString directoryHashForUrl(const QUrl &url); + /** @returns a ViewPropertySettings object with properties loaded for the directory at @param filePath. Ownership is returned to the caller. */ + ViewPropertySettings *loadProperties(const QString &folderPath) const; + /** @returns a ViewPropertySettings object with the globally configured default values. Ownership is returned to the caller. */ + ViewPropertySettings *defaultProperties() const; + Q_DISABLE_COPY(ViewProperties) private: -- 2.47.3