File indexing completed on 2024-05-19 04:29:54

0001 /* This file is part of the KDE project
0002    SPDX-FileCopyrightText: 2013-2014 Yue Liu <yue.liu@mail.com>
0003 
0004    SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "KoFileDialog.h"
0008 #include <QDebug>
0009 #include <QFileDialog>
0010 #include <KisPreviewFileDialog.h>
0011 #include <QApplication>
0012 #include <QImageReader>
0013 #include <QClipboard>
0014 #include <QInputDialog>
0015 #include <QMessageBox>
0016 
0017 #include <kconfiggroup.h>
0018 #include <ksharedconfig.h>
0019 #include <klocalizedstring.h>
0020 #include <kstandardguiitem.h>
0021 
0022 #include <KisMimeDatabase.h>
0023 #include <KoJsonTrader.h>
0024 #include "WidgetUtilsDebug.h"
0025 
0026 #include <kis_assert.h>
0027 
0028 #ifdef Q_OS_MACOS
0029 #include "KisMacosSecurityBookmarkManager.h"
0030 #endif
0031 
0032 class Q_DECL_HIDDEN KoFileDialog::Private
0033 {
0034 public:
0035     Private(QWidget *parent_,
0036             KoFileDialog::DialogType dialogType_,
0037             const QString caption_,
0038             const QString defaultDir_,
0039             const QString dialogName_)
0040         : parent(parent_)
0041         , type(dialogType_)
0042         , dialogName(dialogName_)
0043         , caption(caption_)
0044         , defaultDirectory(defaultDir_)
0045         , filterList(QStringList())
0046         , defaultFilter(QString())
0047     {
0048     }
0049 
0050     ~Private()
0051     {
0052     }
0053 
0054     QWidget *parent;
0055     KoFileDialog::DialogType type;
0056     QString dialogName;
0057     QString caption;
0058     QString defaultDirectory;
0059     QString proposedFileName;
0060     QUrl defaultUri;
0061     QStringList filterList;
0062     QMap<QString, QString> suffixes; // Filter description to extension, may lack some entries
0063     QString defaultFilter;
0064     QScopedPointer<KisPreviewFileDialog> fileDialog;
0065     QString mimeType;
0066 };
0067 
0068 KoFileDialog::KoFileDialog(QWidget *parent,
0069                            KoFileDialog::DialogType type,
0070                            const QString &dialogName)
0071     : d(new Private(parent, type, "", getUsedDir(dialogName), dialogName))
0072 {
0073 }
0074 
0075 KoFileDialog::~KoFileDialog()
0076 {
0077     delete d;
0078 }
0079 
0080 void KoFileDialog::setCaption(const QString &caption)
0081 {
0082     d->caption = caption;
0083 }
0084 
0085 void KoFileDialog::setDefaultDir(const QString &defaultDir, bool force)
0086 {
0087     if (!defaultDir.isEmpty()) {
0088         if (d->defaultDirectory.isEmpty() || force) {
0089             QFileInfo f(defaultDir);
0090             if (f.isDir()) {
0091                 d->defaultDirectory = defaultDir;
0092             }
0093             else {
0094                 d->defaultDirectory = f.absolutePath();
0095             }
0096         }
0097         if (!QFileInfo(defaultDir).isDir()) {
0098             d->proposedFileName = QFileInfo(defaultDir).fileName();
0099         }
0100     }
0101 }
0102 
0103 void KoFileDialog::setDirectoryUrl(const QUrl &defaultUri)
0104 {
0105     d->defaultUri = defaultUri;
0106 }
0107 
0108 void KoFileDialog::setImageFilters()
0109 {
0110     QStringList imageFilters;
0111     // add filters for all formats supported by QImage
0112     Q_FOREACH (const QByteArray &format, QImageReader::supportedImageFormats()) {
0113         imageFilters << QLatin1String("image/") + format;
0114     }
0115     setMimeTypeFilters(imageFilters);
0116 }
0117 
0118 QString KoFileDialog::selectedNameFilter() const
0119 {
0120     return d->fileDialog->selectedNameFilter();
0121 }
0122 
0123 QString KoFileDialog::selectedMimeType() const
0124 {
0125     return d->mimeType;
0126 }
0127 
0128 void KoFileDialog::createFileDialog()
0129 {
0130     d->fileDialog.reset(new KisPreviewFileDialog(d->parent, d->caption, d->defaultDirectory + "/" + d->proposedFileName));
0131     if (!d->defaultUri.isEmpty()) {
0132         d->fileDialog->setDirectoryUrl(d->defaultUri);
0133     }
0134     connect(d->fileDialog.get(), SIGNAL(filterSelected(const QString&)), this, SLOT(onFilterSelected(const QString&)));
0135 
0136 #ifdef Q_OS_MACOS
0137     KisMacosSecurityBookmarkManager *bookmarkmngr = KisMacosSecurityBookmarkManager::instance();
0138     if(bookmarkmngr->isSandboxed()) {
0139         connect(d->fileDialog.get(), SIGNAL(urlSelected  (const QUrl&)), bookmarkmngr, SLOT(addBookmarkAndCheckParentDir(const QUrl&)));
0140     }
0141 #endif
0142 
0143     KConfigGroup group = KSharedConfig::openConfig()->group("File Dialogs");
0144 
0145     bool dontUseNative = true;
0146 #ifdef Q_OS_ANDROID
0147     dontUseNative = false;
0148 #endif
0149 #ifdef Q_OS_UNIX
0150     if (qgetenv("XDG_CURRENT_DESKTOP") == "KDE") {
0151         dontUseNative = false;
0152     }
0153 #endif
0154 #ifdef Q_OS_MACOS
0155     dontUseNative = false;
0156 #endif
0157 #ifdef Q_OS_WIN
0158     dontUseNative = false;
0159 #endif
0160 
0161     bool optionDontUseNative;
0162     if (!qEnvironmentVariable("APPIMAGE").isEmpty()) {
0163         // AppImages don't have access to platform plugins. BUG: 447805
0164         optionDontUseNative = false;
0165     } else {
0166         optionDontUseNative = group.readEntry("DontUseNativeFileDialog", dontUseNative);
0167     }
0168 
0169     d->fileDialog->setOption(QFileDialog::DontUseNativeDialog, optionDontUseNative);
0170     d->fileDialog->setOption(QFileDialog::DontConfirmOverwrite, false);
0171     d->fileDialog->setOption(QFileDialog::HideNameFilterDetails, dontUseNative ? true : false);
0172 
0173 
0174 #ifdef Q_OS_MACOS
0175     QList<QUrl> urls = d->fileDialog->sidebarUrls();
0176     QUrl volumes = QUrl::fromLocalFile("/Volumes");
0177     if (!urls.contains(volumes)) {
0178         urls.append(volumes);
0179     }
0180 
0181     d->fileDialog->setSidebarUrls(urls);
0182 #endif
0183 
0184     if (d->type == SaveFile) {
0185         d->fileDialog->setAcceptMode(QFileDialog::AcceptSave);
0186         d->fileDialog->setFileMode(QFileDialog::AnyFile);
0187     }
0188     else { // open / import
0189 
0190         d->fileDialog->setAcceptMode(QFileDialog::AcceptOpen);
0191 
0192         if (d->type == ImportDirectory || d->type == OpenDirectory) {
0193             d->fileDialog->setFileMode(QFileDialog::Directory);
0194             d->fileDialog->setOption(QFileDialog::ShowDirsOnly, true);
0195         }
0196         else { // open / import file(s)
0197             if (d->type == OpenFile || d->type == ImportFile)
0198             {
0199                 d->fileDialog->setFileMode(QFileDialog::ExistingFile);
0200             }
0201             else { // files
0202                 d->fileDialog->setFileMode(QFileDialog::ExistingFiles);
0203             }
0204         }
0205     }
0206 
0207 #ifndef Q_OS_ANDROID
0208     d->fileDialog->setNameFilters(d->filterList);
0209 
0210     if (!d->proposedFileName.isEmpty()) {
0211         QString mime = KisMimeDatabase::mimeTypeForFile(d->proposedFileName, d->type == KoFileDialog::SaveFile ? false : true);
0212 
0213         QString description = KisMimeDatabase::descriptionForMimeType(mime);
0214         Q_FOREACH(const QString &filter, d->filterList) {
0215             if (filter.startsWith(description)) {
0216                 d->fileDialog->selectNameFilter(filter);
0217                 break;
0218             }
0219         }
0220     }
0221     else if (!d->defaultFilter.isEmpty()) {
0222         d->fileDialog->selectNameFilter(d->defaultFilter);
0223     }
0224 #endif
0225 
0226     if (d->type == ImportDirectory ||
0227             d->type == ImportFile || d->type == ImportFiles ||
0228             d->type == SaveFile) {
0229 
0230         bool allowModal = true;
0231 // MacOS do not declare native file dialog as modal BUG:413241.
0232 #ifdef Q_OS_MACOS
0233         allowModal = optionDontUseNative;
0234 //        if ( d->proposedFileName.isEmpty() ) {
0235 //            d->fileDialog->selectFile("untitled.kra");
0236 //        } else {
0237 //            d->fileDialog->selectFile(d->proposedFileName);
0238 //        }
0239 //        qDebug() << d->proposedFileName.isEmpty() << d->proposedFileName << d->defaultDirectory;
0240 #endif
0241         if (allowModal) {
0242             d->fileDialog->setWindowModality(Qt::WindowModal);
0243         }
0244     }
0245     d->fileDialog->resetIconProvider();
0246 
0247     // QFileDialog::filterSelected is not emitted with the initial value
0248     onFilterSelected(d->fileDialog->selectedNameFilter());
0249 }
0250 
0251 QString KoFileDialog::filename()
0252 {
0253     QString url;
0254     createFileDialog();
0255 
0256 #ifdef Q_OS_ANDROID
0257     if (d->type == SaveFile) {
0258         QString extension = ".kra";
0259         QInputDialog mimeSelector;
0260         mimeSelector.setLabelText(i18n("Save As:"));
0261         mimeSelector.setComboBoxItems(d->filterList);
0262         mimeSelector.setOkButtonText(KStandardGuiItem::ok().text());
0263         mimeSelector.setCancelButtonText(KStandardGuiItem::cancel().text());
0264         // combobox as they stand, are very hard to scroll on a touch device
0265         mimeSelector.setOption(QInputDialog::UseListViewForComboBoxItems);
0266 
0267         if (mimeSelector.exec() == QDialog::Accepted) {
0268             const QString selectedFilter = mimeSelector.textValue();
0269             int start = selectedFilter.indexOf("*.") + 1;
0270             int end = selectedFilter.indexOf(" ", start);
0271             int n = end - start;
0272             extension = selectedFilter.mid(start, n);
0273             if (!extension.startsWith(".")) {
0274                 extension = "." + extension;
0275             }
0276             d->fileDialog->selectNameFilter(selectedFilter);
0277 
0278             const QString proposedFileBaseName = QFileInfo(d->proposedFileName).baseName();
0279             // HACK: discovered by looking into the code
0280             d->fileDialog->setWindowTitle(proposedFileBaseName.isEmpty() ? QString("Untitled" + extension)
0281                                                                          : proposedFileBaseName + extension);
0282         } else {
0283             return url;
0284         }
0285     }
0286 #endif
0287 
0288     bool retryNeeded;
0289     do {
0290         retryNeeded = false;
0291         if (d->fileDialog->exec() == QDialog::Accepted) {
0292             url = d->fileDialog->selectedFiles().first();
0293         } else {
0294             url = QString();
0295             break;
0296         }
0297 
0298         // The Android native file selector does not know to add the .kra
0299         // extension (MIME type not registered), so just skip the whole file
0300         // suffix check for Android.
0301 #ifndef Q_OS_ANDROID
0302         const QString suffix = QFileInfo(url).suffix();
0303         bool isValidSuffix = true;
0304         if (KisMimeDatabase::mimeTypeForSuffix(suffix).isEmpty()) {
0305             warnWidgetUtils << "Selected file name suffix" << suffix << "does not match known MIME types";
0306             isValidSuffix = false;
0307         }
0308 
0309         if (d->type == SaveFile && (suffix.isEmpty() || !isValidSuffix)) {
0310             QString extension;
0311             if (d->suffixes.contains(d->fileDialog->selectedNameFilter())) {
0312                 extension = d->suffixes[d->fileDialog->selectedNameFilter()];
0313                 if (!extension.isEmpty()) {
0314                     // Append the default file extension to the file name before
0315                     // relaunching the file selector. We do _not_ just append
0316                     // the extension and return the new file name because:
0317                     //  * it bypasses the file overwrite prompt provided by the
0318                     //    file selector.
0319                     //  * doing so will break sandboxed macOS and Android,
0320                     //    because access to user files is restricted and must
0321                     //    be done through the native file selector.
0322                     url.append('.').append(extension);
0323                     d->fileDialog->selectFile(url);
0324                 }
0325             }
0326             if (extension.isEmpty()) {
0327                 // Use the first extension of the selected filter just as a suggestion
0328                 QString selectedFilter;
0329                 // skip index 0 which is "All supported formats"
0330                 for (int i = 1; i < d->filterList.size(); ++i) {
0331                     if (d->filterList[i].startsWith(d->fileDialog->selectedNameFilter())) {
0332                         selectedFilter = d->filterList[i];
0333                         break;
0334                     }
0335                 }
0336                 int start = selectedFilter.indexOf("*.") + 2;
0337                 int end = selectedFilter.indexOf(" ", start);
0338                 if (start != -1 + 2 && end != -1) {
0339                     extension = selectedFilter.mid(start, end - start);
0340                 }
0341             }
0342             QMessageBox::warning(d->parent, d->caption,
0343                 i18n("The selected file name does not have a file extension that Krita understands.\n"
0344                      "Make sure the file name ends in '.%1' for example.", extension));
0345             retryNeeded = true;
0346 
0347 // We can only write to the Uri that was returned, we don't have permission to change the Uri.
0348 #if !(defined(Q_OS_MACOS) || defined(Q_OS_ANDROID))
0349             url = url + extension;
0350 #endif
0351         }
0352 #endif
0353     } while (retryNeeded);
0354 
0355     if (!url.isEmpty()) {
0356         d->mimeType = KisMimeDatabase::mimeTypeForFile(url, d->type == KoFileDialog::SaveFile ? false : true);
0357         saveUsedDir(url, d->dialogName);
0358     }
0359     return url;
0360 }
0361 
0362 QStringList KoFileDialog::filenames()
0363 {
0364     QStringList urls;
0365 
0366     createFileDialog();
0367     if (d->fileDialog->exec() == QDialog::Accepted) {
0368         urls = d->fileDialog->selectedFiles();
0369     }
0370     if (urls.size() > 0) {
0371         saveUsedDir(urls.first(), d->dialogName);
0372     }
0373     return urls;
0374 }
0375 
0376 QStringList KoFileDialog::splitNameFilter(const QString &nameFilter, QStringList *mimeList)
0377 {
0378     Q_ASSERT(mimeList);
0379 
0380     QStringList filters;
0381     QString description;
0382 
0383     if (nameFilter.contains("(")) {
0384         description = nameFilter.left(nameFilter.indexOf("(") -1).trimmed();
0385     }
0386 
0387 #if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
0388     QStringList entries = nameFilter.mid(nameFilter.indexOf("(") + 1).split(" ", Qt::SkipEmptyParts);
0389 #else
0390     QStringList entries = nameFilter.mid(nameFilter.indexOf("(") + 1).split(" ", QString::SkipEmptyParts);
0391 #endif
0392 
0393     entries.sort();
0394     Q_FOREACH (QString entry, entries) {
0395 
0396         entry = entry.remove("*");
0397         entry = entry.remove(")");
0398 
0399         QString mimeType = KisMimeDatabase::mimeTypeForSuffix(entry);
0400         if (mimeType != "application/octet-stream") {
0401             if (!mimeList->contains(mimeType)) {
0402                 mimeList->append(mimeType);
0403                 filters.append(KisMimeDatabase::descriptionForMimeType(mimeType) + " ( *" + entry + " )");
0404             }
0405         }
0406         else {
0407             filters.append(entry.remove(".").toUpper() + " " + description + " ( *." + entry + " )");
0408         }
0409     }
0410     return filters;
0411 }
0412 
0413 void KoFileDialog::setMimeTypeFilters(const QStringList &mimeTypeList, QString defaultMimeType)
0414 {
0415     constexpr bool withAllSupportedEntry = true;
0416     QStringList mimeSeen;
0417 
0418     struct FilterData
0419     {
0420         QString descriptionOnly;
0421         QString fullLine;
0422         QString defaultSuffix;
0423     };
0424 
0425     FilterData defaultFilter {};
0426     // 1
0427     QString allSupported;
0428     // 2
0429     FilterData kritaNative {};
0430     // 3
0431     FilterData ora {};
0432     // remaining
0433     QVector<FilterData> otherFileTypes;
0434     // All files
0435     bool hasAllFilesFilter = false;
0436 
0437     QStringList mimeList = mimeTypeList;
0438     mimeList.sort();
0439 
0440     Q_FOREACH(const QString &mimeType, mimeList) {
0441         if (!mimeSeen.contains(mimeType)) {
0442             if (mimeType == QLatin1String("application/octet-stream")) {
0443                 // QFileDialog uses application/octet-stream for the
0444                 // "All files (*)" filter. We can do the same here.
0445                 hasAllFilesFilter = true;
0446                 mimeSeen << mimeType;
0447                 continue;
0448             }
0449             QString description = KisMimeDatabase::descriptionForMimeType(mimeType);
0450             if (description.isEmpty() && !mimeType.isEmpty()) {
0451                 description = mimeType.split("/")[1];
0452                 if (description.startsWith("x-")) {
0453                     description = description.remove(0, 2);
0454                 }
0455             }
0456 
0457 
0458             QString oneFilter;
0459             const QStringList suffixes = KisMimeDatabase::suffixesForMimeType(mimeType);
0460             KIS_SAFE_ASSERT_RECOVER(!suffixes.isEmpty()) {
0461                 warnWidgetUtils << "KoFileDialog: Found no suffixes for mime type" << mimeType;
0462                 continue;
0463             }
0464 
0465             Q_FOREACH(const QString &suffix, suffixes) {
0466                 const QString glob = QStringLiteral("*.") + suffix;
0467                 oneFilter.append(glob + " ");
0468                 if (withAllSupportedEntry) {
0469                     allSupported.append(glob + " ");
0470                 }
0471 #ifdef Q_OS_LINUX
0472                 if (qgetenv("XDG_CURRENT_DESKTOP") == "GNOME") {
0473                     oneFilter.append(glob.toUpper() + " ");
0474                     if (withAllSupportedEntry) {
0475                         allSupported.append(glob.toUpper() + " ");
0476                     }
0477                 }
0478 #endif
0479             }
0480 
0481             Q_ASSERT(!description.isEmpty());
0482 
0483             FilterData filterData {};
0484             filterData.descriptionOnly = description;
0485             filterData.fullLine = description + " ( " + oneFilter + ")";
0486             filterData.defaultSuffix = suffixes.first();
0487 
0488             if (mimeType == QLatin1String("application/x-krita")) {
0489                 kritaNative = filterData;
0490             } else if (mimeType == QLatin1String("image/openraster")) {
0491                 ora = filterData;
0492             } else {
0493                 otherFileTypes.append(filterData);
0494             }
0495             if (defaultMimeType == mimeType) {
0496                 debugWidgetUtils << "KoFileDialog: Matched default MIME type to filter" << filterData.fullLine;
0497                 defaultFilter = filterData;
0498             }
0499             mimeSeen << mimeType;
0500         }
0501     }
0502 
0503     QStringList retFilterList;
0504     QMap<QString, QString> retFilterToSuffixMap;
0505     auto addFilterItem = [&](const FilterData &filterData) {
0506         if (retFilterList.contains(filterData.fullLine)) {
0507             debugWidgetUtils << "KoFileDialog: Duplicated filter" << filterData.fullLine;
0508             return;
0509         }
0510         retFilterList.append(filterData.fullLine);
0511         // the "simplified" version that comes to "onFilterSelect" when details are disabled
0512         retFilterToSuffixMap.insert(filterData.descriptionOnly, filterData.defaultSuffix);
0513         // "full version" that comes when details are enabled
0514         retFilterToSuffixMap.insert(filterData.fullLine, filterData.defaultSuffix);
0515     };
0516 
0517     if (!allSupported.isEmpty()) {
0518         FilterData allFilter {};
0519         if (allSupported.contains("*.kra")) {
0520             allSupported.remove("*.kra ");
0521             allSupported.prepend("*.kra ");
0522             allFilter.defaultSuffix = QStringLiteral("kra");
0523         } else if (!defaultFilter.fullLine.isEmpty()) {
0524             const QString suffixToMove = QString("*.") + defaultFilter.defaultSuffix + " ";
0525             allSupported.remove(suffixToMove);
0526             allSupported.prepend(suffixToMove);
0527             allFilter.defaultSuffix = defaultFilter.defaultSuffix;
0528         } else {
0529             // XXX: we don't have a meaningful default suffix
0530             warnWidgetUtils << "KoFileDialog: No default suffix for 'All supported formats'";
0531             allFilter.defaultSuffix = QStringLiteral("");
0532         }
0533         allFilter.descriptionOnly = i18n("All supported formats");
0534         allFilter.fullLine = allFilter.descriptionOnly + " ( " + allSupported + ")";
0535         addFilterItem(allFilter);
0536     }
0537     if (!kritaNative.fullLine.isEmpty()) {
0538         addFilterItem(kritaNative);
0539     }
0540     if (!ora.fullLine.isEmpty()) {
0541         addFilterItem(ora);
0542     }
0543 
0544     std::sort(otherFileTypes.begin(), otherFileTypes.end(), [](const FilterData &a, const FilterData &b) {
0545         return a.descriptionOnly < b.descriptionOnly;
0546     });
0547     Q_FOREACH(const FilterData &filterData, otherFileTypes) {
0548         addFilterItem(filterData);
0549     }
0550 
0551     if (hasAllFilesFilter) {
0552         // Reusing Qt's existing "All files" translation
0553         retFilterList.append(QFileDialog::tr("All files (*)"));
0554     }
0555 
0556     d->filterList = retFilterList;
0557     d->suffixes = retFilterToSuffixMap;
0558     d->defaultFilter = defaultFilter.fullLine; // this can be empty
0559 }
0560 
0561 QString KoFileDialog::getUsedDir(const QString &dialogName)
0562 {
0563     if (dialogName.isEmpty()) return "";
0564 
0565     KConfigGroup group =  KSharedConfig::openConfig()->group("File Dialogs");
0566     QString dir = group.readEntry(dialogName, "");
0567     return dir;
0568 }
0569 
0570 void KoFileDialog::saveUsedDir(const QString &fileName,
0571                                const QString &dialogName)
0572 {
0573 
0574     if (dialogName.isEmpty()) return;
0575 
0576     QFileInfo fileInfo(fileName);
0577     KConfigGroup group =  KSharedConfig::openConfig()->group("File Dialogs");
0578     group.writeEntry(dialogName, fileInfo.absolutePath());
0579 
0580 }
0581 
0582 void KoFileDialog::onFilterSelected(const QString &filter)
0583 {
0584     debugWidgetUtils << "KoFileDialog::onFilterSelected" << filter;
0585 
0586     // Setting default suffix for Android is broken as of Qt 5.12.0, returning the file
0587     // with extension added but no write permissions granted.
0588 #ifndef Q_OS_ANDROID
0589     QFileDialog::FileMode mode = d->fileDialog->fileMode();
0590     if (mode != QFileDialog::FileMode::Directory && mode != QFileDialog::FileMode::DirectoryOnly) {
0591         // we do not need suffixes for directories
0592         if (d->suffixes.contains(filter)) {
0593             QString suffix = d->suffixes[filter];
0594             debugWidgetUtils << "  Setting default suffix to" << suffix;
0595             d->fileDialog->setDefaultSuffix(suffix);
0596         } else {
0597             warnWidgetUtils << "KoFileDialog::onFilterSelected: Cannot find suffix for filter" << filter;
0598             d->fileDialog->setDefaultSuffix("");
0599         }
0600     }
0601 #endif
0602 }