File indexing completed on 2024-05-12 04:34:56

0001 /* This file is part of Spectacle, the KDE screenshot utility
0002  * SPDX-FileCopyrightText: 2019 David Redondo <kde@david-redondo.de>
0003  * SPDX-FileCopyrightText: 2015 Boudhayan Gupta <bgupta@kde.org>
0004  * SPDX-License-Identifier: LGPL-2.0-or-later
0005  */
0006 
0007 #include "ExportManager.h"
0008 
0009 #include "settings.h"
0010 #include <kio_version.h>
0011 
0012 #include <QApplication>
0013 #include <QClipboard>
0014 #include <QDir>
0015 #include <QFileDialog>
0016 #include <QImageWriter>
0017 #include <QLocale>
0018 #include <QMimeData>
0019 #include <QMimeDatabase>
0020 #include <QPainter>
0021 #include <QPrinter>
0022 #include <QRandomGenerator>
0023 #include <QRegularExpression>
0024 #include <QRegularExpressionMatch>
0025 #include <QString>
0026 #include <QTemporaryDir>
0027 #include <QTemporaryFile>
0028 #include <QtConcurrent/QtConcurrentRun>
0029 
0030 #include <KIO/DeleteJob>
0031 #include <KIO/FileCopyJob>
0032 #include <KIO/ListJob>
0033 #include <KIO/MkpathJob>
0034 #include <KIO/StatJob>
0035 #include <KRecentDocument>
0036 #include <KSharedConfig>
0037 #include <KSystemClipboard>
0038 #include <ZXing/ReadBarcode.h>
0039 
0040 using namespace Qt::StringLiterals;
0041 
0042 ExportManager::ExportManager(QObject *parent)
0043     : QObject(parent)
0044     , m_imageSavedNotInTemp(false)
0045     , m_saveImage(QImage())
0046     , m_tempFile(QUrl())
0047 {
0048     connect(this, &ExportManager::imageExported, [](Actions actions, const QUrl &url) {
0049         if (actions & AnySave) {
0050             Settings::setLastImageSaveLocation(url);
0051             if (actions & SaveAs) {
0052                 Settings::setLastImageSaveAsLocation(url);
0053             }
0054         }
0055     });
0056 }
0057 
0058 ExportManager::~ExportManager() = default;
0059 
0060 ExportManager *ExportManager::instance()
0061 {
0062     static ExportManager instance;
0063     // Ensure the SystemClipboard is instantiated early enough
0064     KSystemClipboard::instance();
0065     return &instance;
0066 }
0067 
0068 // screenshot image setter and getter
0069 
0070 QImage ExportManager::image() const
0071 {
0072     return m_saveImage;
0073 }
0074 
0075 void ExportManager::setWindowTitle(const QString &windowTitle)
0076 {
0077     m_windowTitle = windowTitle;
0078 }
0079 
0080 QString ExportManager::windowTitle() const
0081 {
0082     return m_windowTitle;
0083 }
0084 
0085 void ExportManager::setImage(const QImage &image)
0086 {
0087     m_saveImage = image;
0088 
0089     // reset our saved tempfile
0090     if (m_tempFile.isValid()) {
0091         m_usedTempFileNames.append(m_tempFile);
0092         QFile file(m_tempFile.toLocalFile());
0093         file.remove();
0094         m_tempFile = QUrl();
0095     }
0096 
0097     // since the image was modified, we now consider the image unsaved
0098     m_imageSavedNotInTemp = false;
0099 
0100     Q_EMIT imageChanged();
0101 }
0102 
0103 void ExportManager::updateTimestamp()
0104 {
0105     m_timestamp = QDateTime::currentDateTime();
0106 }
0107 
0108 void ExportManager::setTimestamp(const QDateTime &timestamp)
0109 {
0110     m_timestamp = timestamp;
0111 }
0112 
0113 QDateTime ExportManager::timestamp() const
0114 {
0115     return m_timestamp;
0116 }
0117 
0118 // native file save helpers
0119 
0120 static QString ensureDefaultLocationExists(const QUrl &saveUrl)
0121 {
0122     QString savePath = saveUrl.isRelative() ? saveUrl.toString() : saveUrl.toLocalFile();
0123     savePath = QDir::cleanPath(savePath);
0124 
0125     QDir savePathDir(savePath);
0126     if (!(savePathDir.exists())) {
0127         savePathDir.mkpath(u"."_s);
0128     }
0129     return savePath;
0130 }
0131 
0132 QString ExportManager::defaultSaveLocation() const
0133 {
0134     return ensureDefaultLocationExists(Settings::imageSaveLocation());
0135 }
0136 
0137 QString ExportManager::defaultVideoSaveLocation() const
0138 {
0139     return ensureDefaultLocationExists(Settings::videoSaveLocation());
0140 }
0141 
0142 QUrl ExportManager::getAutosaveFilename() const
0143 {
0144     const QString baseDir = defaultSaveLocation();
0145     const QDir baseDirPath(baseDir);
0146     const QString filename = formattedFilename();
0147     const QString fullpath = autoIncrementFilename(baseDirPath.filePath(filename),
0148                                                    Settings::preferredImageFormat().toLower(),
0149                                                    &ExportManager::isFileExists);
0150 
0151     const QUrl fileNameUrl = QUrl::fromUserInput(fullpath);
0152     if (fileNameUrl.isValid()) {
0153         return fileNameUrl;
0154     } else {
0155         return QUrl();
0156     }
0157 }
0158 
0159 QUrl ExportManager::tempVideoUrl()
0160 {
0161     const auto format = static_cast<VideoPlatform::Format>(Settings::preferredVideoFormat());
0162     auto extension = VideoPlatform::extensionForFormat(format);
0163     QString baseDir = defaultVideoSaveLocation();
0164     const QDir baseDirPath(baseDir);
0165     const QString filename = formattedFilename(Settings::videoFilenameTemplate());
0166     QString filepath = autoIncrementFilename(baseDirPath.filePath(filename), extension, &ExportManager::isFileExists);
0167 
0168     auto tempDir = temporaryDir();
0169     if (!tempDir) {
0170         return {};
0171     }
0172 
0173     if (!baseDir.isEmpty() && !baseDir.endsWith(u'/')) {
0174         baseDir += u'/';
0175     }
0176     filepath = tempDir->path() + u'/' + filepath.right(filepath.size() - baseDir.size());
0177     return QUrl::fromLocalFile(filepath);
0178 }
0179 
0180 const QTemporaryDir *ExportManager::temporaryDir()
0181 {
0182     if (!m_tempDir) {
0183         // Cleanup Spectacle's temp dirs after startup instead of while quitting.
0184         const auto filters = QDir::Filter::Dirs | QDir::NoDotAndDotDot | QDir::CaseSensitive | QDir::NoSymLinks;
0185         const auto oldDirs = QDir::temp().entryList({u"Spectacle.??????"_s}, filters);
0186         QList<QUrl> oldUrls;
0187         for (const auto &dir : oldDirs) {
0188             oldUrls << QUrl::fromLocalFile(QDir::tempPath() + u'/' + dir);
0189         }
0190         KIO::del(oldUrls, KIO::HideProgressInfo)->start();
0191         m_tempDir = std::make_unique<QTemporaryDir>(QDir::tempPath() + u"/Spectacle.XXXXXX"_s);
0192         m_tempDir->setAutoRemove(false);
0193     }
0194     return m_tempDir->isValid() ? m_tempDir.get() : nullptr;
0195 }
0196 
0197 QString ExportManager::truncatedFilename(QString const &filename) const
0198 {
0199     QString result = filename;
0200     constexpr auto maxFilenameLength = 255;
0201     constexpr auto maxExtensionLength = 5; // For example, ".jpeg"
0202     constexpr auto maxCounterLength = 20; // std::numeric_limits<quint64>::max() == 18446744073709551615
0203     constexpr auto maxLength = maxFilenameLength - maxCounterLength - maxExtensionLength;
0204     result.truncate(maxLength);
0205     return result;
0206 }
0207 
0208 QString ExportManager::formattedFilename(const QString &nameTemplate) const
0209 {
0210     const QDateTime timestamp = m_timestamp;
0211     QString baseName = nameTemplate;
0212     QString baseDir = defaultSaveLocation();
0213     QString title = m_windowTitle;
0214 
0215     if (!title.isEmpty()) {
0216         title.replace(u'/', u'_'); // POSIX doesn't allow "/" in filenames
0217     } else {
0218         // Remove <title> with separators around it
0219         const auto wordSymbol = uR"(\p{L}\p{M}\p{N})"_s;
0220         const auto separator = u"([^%1]+)"_s.arg(wordSymbol);
0221         const auto re = QRegularExpression(u"(.*?)(%1<title>|<title>%1)(.*?)"_s.arg(separator));
0222         baseName.replace(re, uR"(\1\5)"_s);
0223     }
0224 
0225     QString result = baseName;
0226     const auto &locale = QLocale::system();
0227     // QDateTime
0228     for (auto it = filenamePlaceholders.cbegin(); it != filenamePlaceholders.cend(); ++it) {
0229         if (it->flags.testFlags(Placeholder::QDateTime)) {
0230             result.replace(it->plainKey, locale.toString(timestamp, it->baseKey));
0231         }
0232     }
0233     // Manual interpretation
0234     // `h` and `hh` in QDateTime::toString or QLocale::toString
0235     // are only 12 hour when paired with an AM/PM display in the same toString call,
0236     // so we have to get the 12 hour format for `<h>` and `<hh>` manually.
0237     result.replace("<h>"_L1, timestamp.toString(u"hAP"_s).chopped(2));
0238     result.replace("<hh>"_L1, timestamp.toString(u"hhAP"_s).chopped(2));
0239     result.replace("<UnixTime>"_L1, QString::number(timestamp.toSecsSinceEpoch()));
0240     result.replace("<title>"_L1, title);
0241 
0242     // check if basename includes %[N]d token for sequential file numbering
0243     QRegularExpression paddingRE;
0244     paddingRE.setPattern(u"<(#+)>"_s);
0245     QRegularExpressionMatchIterator it = paddingRE.globalMatch(result);
0246     if (it.hasNext()) {
0247         // strip any subdirectories from the template to construct the filename matching regex
0248         // we are matching filenames only, not paths
0249         QString resultCopy = QRegularExpression::escape(result.section(u'/', -1));
0250         QList<QRegularExpressionMatch> matches;
0251         while (it.hasNext()) {
0252             QRegularExpressionMatch paddingMatch = it.next();
0253             matches.push_back(paddingMatch);
0254             // determine padding value
0255             int paddedLength = 1;
0256             if (!paddingMatch.captured(1).isEmpty()) {
0257                 paddedLength = paddingMatch.captured(1).length();
0258             }
0259             QString escapedMatch = QRegularExpression::escape(paddingMatch.captured());
0260             resultCopy.replace(escapedMatch, u"(\\d{%1,})"_s.arg(QString::number(paddedLength)));
0261         }
0262         if (result.contains(u'/')) {
0263             // In case the filename template contains a subdirectory,
0264             // we need to search for files in the subdirectory instead of the baseDir.
0265             // so let's add that to baseDir before we search for files.
0266             baseDir += u"/%1"_s.arg(result.section(u'/', 0, -2));
0267         }
0268         // search save directory for files
0269         QDir dir(baseDir);
0270         const QStringList fileNames = dir.entryList(QDir::Files, QDir::Name);
0271         int highestFileNumber = 0;
0272 
0273         // if there are files in the directory...
0274         if (fileNames.length() > 0) {
0275             QRegularExpression fileNumberRE;
0276             fileNumberRE.setPattern(resultCopy);
0277             // ... check the file names for string matching token with padding specified in result
0278             const QStringList filteredFiles = fileNames.filter(fileNumberRE);
0279             // if there are files in the directory that look like the file name with sequential numbering
0280             if (filteredFiles.length() > 0) {
0281                 // loop through filtered file names looking for highest number
0282                 for (const QString &filteredFile : filteredFiles) {
0283                     int currentFileNumber = fileNumberRE.match(filteredFile).captured(1).length();
0284                     if (currentFileNumber > highestFileNumber) {
0285                         highestFileNumber = currentFileNumber;
0286                     }
0287                 }
0288             }
0289         }
0290         // replace placeholder with next number padded
0291         for (const auto &match : matches) {
0292             int paddedLength = 1;
0293             if (!match.captured(1).isEmpty()) {
0294                 paddedLength = match.captured(1).length();
0295             }
0296             const QString nextFileNumberPadded = QString::number(highestFileNumber + 1).rightJustified(paddedLength, u'0');
0297             result.replace(match.captured(), nextFileNumberPadded);
0298         }
0299     }
0300 
0301     // Remove leading and trailing '/'
0302     while (result.startsWith(u'/')) {
0303         result.remove(0, 1);
0304     }
0305     while (result.endsWith(u'/')) {
0306         result.chop(1);
0307     }
0308 
0309     if (result.isEmpty()) {
0310         result = u"Screenshot"_s;
0311     }
0312     return truncatedFilename(result);
0313 }
0314 
0315 QString ExportManager::autoIncrementFilename(const QString &baseName, const QString &extension, FileNameAlreadyUsedCheck isFileNameUsed) const
0316 {
0317     QString result = truncatedFilename(baseName) + u'.' + extension;
0318     if (!((this->*isFileNameUsed)(QUrl::fromUserInput(result)))) {
0319         return result;
0320     }
0321 
0322     QString fileNameFmt = truncatedFilename(baseName) + u"-%1.";
0323     for (quint64 i = 1; i < std::numeric_limits<quint64>::max(); i++) {
0324         result = fileNameFmt.arg(i) + extension;
0325         if (!((this->*isFileNameUsed)(QUrl::fromUserInput(result)))) {
0326             return result;
0327         }
0328     }
0329 
0330     // unlikely this will ever happen, but just in case we've run
0331     // out of numbers
0332 
0333     result = fileNameFmt.arg(u"OVERFLOW-" + QString::number(QRandomGenerator::global()->bounded(10000)));
0334     return truncatedFilename(result) + extension;
0335 }
0336 
0337 QString ExportManager::imageFileSuffix(const QUrl &url) const
0338 {
0339     QMimeDatabase mimedb;
0340     const QString type = mimedb.mimeTypeForUrl(url).preferredSuffix();
0341 
0342     if (type.isEmpty()) {
0343         return Settings::self()->preferredImageFormat().toLower();
0344     }
0345     return type;
0346 }
0347 
0348 bool ExportManager::writeImage(QIODevice *device, const QByteArray &suffix)
0349 {
0350     // In the documentation for QImageWriter, it is a bit ambiguous what "format" means.
0351     // From looking at how QImageWriter handles the built-in supported formats internally,
0352     // "format" basically means the file extension, not the mimetype.
0353     QImageWriter imageWriter(device, suffix);
0354     imageWriter.setQuality(Settings::imageCompressionQuality());
0355     /** Set compression 50 if the format is png. Otherwise if no compression value is specified
0356      *  it will fallback to using quality (QTBUG-43618) and produce huge files.
0357      *  See also qpnghandler.cpp#n1075. The other formats that do compression seem to have it
0358      *  enabled by default and only disabled if compression is set to 0, also any value except 0
0359      *  has the same effect for them.
0360      */
0361     if (suffix == "png") {
0362         imageWriter.setCompression(50);
0363     }
0364     if (!(imageWriter.canWrite())) {
0365         Q_EMIT errorMessage(i18n("QImageWriter cannot write image: %1", imageWriter.errorString()));
0366         return false;
0367     }
0368 
0369     return imageWriter.write(m_saveImage);
0370 }
0371 
0372 bool ExportManager::localSave(const QUrl &url, const QString &suffix)
0373 {
0374     // Create save directory if it doesn't exist
0375     const QUrl dirPath(url.adjusted(QUrl::RemoveFilename));
0376     const QDir dir(dirPath.path());
0377 
0378     if (!dir.mkpath(u"."_s)) {
0379         Q_EMIT errorMessage(xi18nc("@info",
0380                                    "Cannot save screenshot because creating "
0381                                    "the directory failed:<nl/><filename>%1</filename>",
0382                                    dirPath.path()));
0383         return false;
0384     }
0385 
0386     QFile outputFile(url.toLocalFile());
0387 
0388     outputFile.open(QFile::WriteOnly);
0389     if (!writeImage(&outputFile, suffix.toLatin1())) {
0390         Q_EMIT errorMessage(i18n("Cannot save screenshot. Error while writing file."));
0391         return false;
0392     }
0393     return true;
0394 }
0395 
0396 bool ExportManager::remoteSave(const QUrl &url, const QString &suffix)
0397 {
0398     // Check if remote save directory exists
0399     const QUrl dirPath(url.adjusted(QUrl::RemoveFilename));
0400     KIO::ListJob *listJob = KIO::listDir(dirPath);
0401     listJob->exec();
0402 
0403     if (listJob->error() != KJob::NoError) {
0404         // Create remote save directory
0405         KIO::MkpathJob *mkpathJob = KIO::mkpath(dirPath, QUrl(defaultSaveLocation()));
0406         mkpathJob->exec();
0407 
0408         if (mkpathJob->error() != KJob::NoError) {
0409             Q_EMIT errorMessage(xi18nc("@info",
0410                                        "Cannot save screenshot because creating the "
0411                                        "remote directory failed:<nl/><filename>%1</filename>",
0412                                        dirPath.path()));
0413             return false;
0414         }
0415     }
0416 
0417     QTemporaryFile tmpFile;
0418 
0419     if (tmpFile.open()) {
0420         if (!writeImage(&tmpFile, suffix.toLatin1())) {
0421             Q_EMIT errorMessage(i18n("Cannot save screenshot. Error while writing temporary local file."));
0422             return false;
0423         }
0424 
0425         KIO::FileCopyJob *uploadJob = KIO::file_copy(QUrl::fromLocalFile(tmpFile.fileName()), url);
0426         uploadJob->exec();
0427 
0428         if (uploadJob->error() != KJob::NoError) {
0429             Q_EMIT errorMessage(i18n("Unable to save image. Could not upload file to remote location."));
0430             return false;
0431         }
0432         return true;
0433     }
0434 
0435     return false;
0436 }
0437 
0438 QUrl ExportManager::tempSave()
0439 {
0440     // if we already have a temp file saved, use that
0441     if (m_tempFile.isValid()) {
0442         if (QFile(m_tempFile.toLocalFile()).exists()) {
0443             return m_tempFile;
0444         }
0445     }
0446 
0447     auto tempDir = this->temporaryDir();
0448     if (tempDir) {
0449         // create the temporary file itself with normal file name and also unique one for this session
0450         // supports the use-case of creating multiple screenshots in a row
0451         // and exporting them to the same destination e.g. via clipboard,
0452         // where the temp file name is used as filename suggestion
0453         const QString baseFileName = m_tempDir->path() + u'/' + QUrl::fromLocalFile(formattedFilename()).fileName();
0454 
0455         QString suffix = imageFileSuffix(QUrl(baseFileName));
0456         const QString fileName = autoIncrementFilename(baseFileName, suffix, &ExportManager::isTempFileAlreadyUsed);
0457         QFile tmpFile(fileName);
0458         if (tmpFile.open(QFile::WriteOnly)) {
0459             if (writeImage(&tmpFile, suffix.toLatin1())) {
0460                 m_tempFile = QUrl::fromLocalFile(tmpFile.fileName());
0461                 // try to make sure 3rd-party which gets the url of the temporary file e.g. on export
0462                 // properly treats this as readonly, also hide from other users
0463                 tmpFile.setPermissions(QFile::ReadUser);
0464                 return m_tempFile;
0465             }
0466         }
0467     }
0468 
0469     Q_EMIT errorMessage(i18n("Cannot save screenshot. Error while writing temporary local file."));
0470     return QUrl();
0471 }
0472 
0473 bool ExportManager::save(const QUrl &url)
0474 {
0475     if (!(url.isValid())) {
0476         Q_EMIT errorMessage(i18n("Cannot save screenshot. The save filename is invalid."));
0477         return false;
0478     }
0479 
0480     const QString suffix = imageFileSuffix(url);
0481     bool saveSucceded = false;
0482     if (url.isLocalFile()) {
0483         saveSucceded = localSave(url, suffix);
0484     } else {
0485         saveSucceded = remoteSave(url, suffix);
0486     }
0487     if (saveSucceded) {
0488         m_imageSavedNotInTemp = true;
0489         KRecentDocument::add(url, QGuiApplication::desktopFileName());
0490     }
0491     return saveSucceded;
0492 }
0493 
0494 bool ExportManager::isFileExists(const QUrl &url) const
0495 {
0496     if (!(url.isValid())) {
0497         return false;
0498     }
0499     // Using StatJob instead of QFileInfo::exists() is necessary for checking non-local URLs.
0500     KIO::StatJob *existsJob = KIO::stat(url, KIO::StatJob::DestinationSide, KIO::StatNoDetails, KIO::HideProgressInfo);
0501 
0502     existsJob->exec();
0503 
0504     return (existsJob->error() == KJob::NoError);
0505 }
0506 
0507 bool ExportManager::isImageSavedNotInTemp() const
0508 {
0509     return m_imageSavedNotInTemp;
0510 }
0511 
0512 bool ExportManager::isTempFileAlreadyUsed(const QUrl &url) const
0513 {
0514     return m_usedTempFileNames.contains(url);
0515 }
0516 
0517 void ExportManager::exportImage(ExportManager::Actions actions, QUrl url)
0518 {
0519     if (m_saveImage.isNull() && actions & (Save | SaveAs | CopyImage)) {
0520         Q_EMIT errorMessage(i18n("Cannot save an empty screenshot image."));
0521         return;
0522     }
0523 
0524     bool success = false;
0525     if (actions & SaveAs) {
0526         QStringList supportedFilters;
0527 
0528         // construct the supported mimetype list
0529         const auto mimeTypes = QImageWriter::supportedMimeTypes();
0530         supportedFilters.reserve(mimeTypes.count());
0531         for (const auto &mimeType : mimeTypes) {
0532             supportedFilters.append(QString::fromUtf8(mimeType).trimmed());
0533         }
0534 
0535         // construct the file name
0536         const QString filenameExtension = Settings::self()->preferredImageFormat().toLower();
0537         const QString mimetype = QMimeDatabase().mimeTypeForFile(u"~/fakefile."_s + filenameExtension, QMimeDatabase::MatchExtension).name();
0538         QFileDialog dialog;
0539         dialog.setAcceptMode(QFileDialog::AcceptSave);
0540         dialog.setFileMode(QFileDialog::AnyFile);
0541         QUrl dirUrl = url.adjusted(QUrl::RemoveFilename);
0542         if (!dirUrl.isValid()) {
0543             dirUrl = Settings::self()->lastImageSaveAsLocation().adjusted(QUrl::RemoveFilename);
0544         }
0545         dialog.setDirectoryUrl(dirUrl);
0546         dialog.selectFile(formattedFilename() + u"."_s + filenameExtension);
0547         dialog.setDefaultSuffix(u"."_s + filenameExtension);
0548         dialog.setMimeTypeFilters(supportedFilters);
0549         dialog.selectMimeTypeFilter(mimetype);
0550 
0551         // launch the dialog
0552         const bool accepted = dialog.exec() == QDialog::Accepted;
0553         if (accepted && !dialog.selectedUrls().isEmpty()) {
0554             url = dialog.selectedUrls().constFirst();
0555         }
0556         actions.setFlag(SaveAs, accepted && url.isValid());
0557     }
0558 
0559     bool saved = actions & AnySave;
0560     if (saved) {
0561         if (!url.isValid()) {
0562             url = getAutosaveFilename();
0563         }
0564         saved = success = save(url);
0565         if (!success) {
0566             actions.setFlag(Save, false);
0567             actions.setFlag(SaveAs, false);
0568         }
0569     }
0570 
0571     if (actions & CopyImage) {
0572         auto data = new QMimeData();
0573         bool savedLocal = saved && url.isLocalFile();
0574         bool canWriteTemp = temporaryDir() != nullptr;
0575         if (savedLocal || canWriteTemp) {
0576             QString localFilePath;
0577             if (savedLocal || (url.isEmpty() && canWriteTemp)) {
0578                 if (url.isEmpty()) {
0579                     url = tempSave();
0580                 }
0581                 localFilePath = url.toLocalFile();
0582             } else {
0583                 localFilePath = tempSave().toLocalFile();
0584             }
0585             QFile imageFile(localFilePath);
0586             imageFile.open(QFile::ReadOnly);
0587             const auto mimetype = QMimeDatabase().mimeTypeForFile(localFilePath);
0588             data->setData(mimetype.name(), imageFile.readAll());
0589             imageFile.close();
0590         } else {
0591             // Fallback to the old way if we can't save a temp file for some reason.
0592             data->setImageData(m_saveImage);
0593         }
0594         // "x-kde-force-image-copy" is handled by Klipper.
0595         // It ensures that the image is copied to Klipper even with the
0596         // "Non-text selection: Never save in history" setting selected in Klipper.
0597         data->setData(u"x-kde-force-image-copy"_s, QByteArray());
0598         KSystemClipboard::instance()->setMimeData(data, QClipboard::Clipboard);
0599         success = true;
0600     }
0601 
0602     if (actions & CopyPath
0603         // This behavior has no relation to the setting in the config UI,
0604         // but it was added to solve this feature request:
0605         // https://bugs.kde.org/show_bug.cgi?id=357423
0606         || (saved && Settings::clipboardGroup() == Settings::PostScreenshotCopyLocation)) {
0607         if (!url.isValid()) {
0608             if (m_imageSavedNotInTemp) {
0609                 // The image has been saved (manually or automatically),
0610                 // we need to choose that file path
0611                 url = Settings::self()->lastImageSaveLocation();
0612             } else {
0613                 // use a temporary save path, and copy that to clipboard instead
0614                 url = ExportManager::instance()->tempSave();
0615             }
0616         }
0617 
0618         // will be deleted for us by the platform's clipboard manager.
0619         auto data = new QMimeData();
0620         data->setText(url.toLocalFile());
0621         KSystemClipboard::instance()->setMimeData(data, QClipboard::Clipboard);
0622         success = true;
0623     }
0624 
0625     if (success) {
0626         Q_EMIT imageExported(actions, url);
0627     }
0628 }
0629 
0630 void ExportManager::scanQRCode()
0631 {
0632     auto scan = [this] {
0633         const auto image = ExportManager::instance()->image();
0634         const auto zximage = ZXing::ImageView(image.constBits(), image.width(), image.height(), ZXing::ImageFormat::XRGB);
0635         const auto result = ZXing::ReadBarcode(zximage, {});
0636 
0637         if (result.isValid()) {
0638             QVariant content;
0639             if (result.contentType() == ZXing::ContentType::Text) {
0640                 content = QString::fromStdString(result.text());
0641             } else {
0642                 QByteArray b;
0643                 b.resize(result.bytes().size());
0644                 std::copy(result.bytes().begin(), result.bytes().end(), b.begin());
0645                 content = b;
0646             }
0647             Q_EMIT qrCodeScanned(content);
0648         }
0649     };
0650     auto future = QtConcurrent::run(scan);
0651 }
0652 
0653 void ExportManager::exportVideo(ExportManager::Actions actions, const QUrl &inputUrl, QUrl outputUrl)
0654 {
0655     // input can be empty or nonexistent, but not if we're saving
0656     const auto &inputFile = inputUrl.toLocalFile();
0657     const auto &inputName = inputUrl.fileName();
0658     if ((inputName.isEmpty() || !QFileInfo::exists(inputFile)) && actions & (Save | SaveAs)) {
0659         Q_EMIT errorMessage(i18nc("@info:shell","Failed to export video: Temporary file URL must be an existing local file"));
0660         return;
0661     }
0662 
0663     // output can be empty, but not invalid or with an empty name when not empty and saving
0664     const auto &outputName = outputUrl.fileName();
0665     if (!outputUrl.isEmpty() && (!outputUrl.isValid() || outputName.isEmpty()) && actions & (Save | SaveAs)) {
0666         Q_EMIT errorMessage(i18nc("@info:shell","Failed to export video: Output file URL must be a valid URL with a file name"));
0667         return;
0668     }
0669 
0670     if (actions & SaveAs) {
0671         QMimeDatabase mimeDatabase;
0672         // construct the file name
0673         const auto &extension = inputName.mid(inputName.lastIndexOf(u'.'));
0674         const auto &mimetype = mimeDatabase.mimeTypeForFile(inputName, QMimeDatabase::MatchExtension).name();
0675         QFileDialog dialog;
0676         dialog.setAcceptMode(QFileDialog::AcceptSave);
0677         dialog.setFileMode(QFileDialog::AnyFile);
0678         const auto outputDir = outputUrl.adjusted(QUrl::RemoveFilename);
0679         if (!outputDir.isValid()) {
0680             dialog.setDirectoryUrl(Settings::self()->lastVideoSaveAsLocation().adjusted(QUrl::RemoveFilename));
0681         } else {
0682             dialog.setDirectoryUrl(outputUrl);
0683         }
0684         dialog.setDefaultSuffix(extension);
0685         dialog.selectFile(!outputName.isEmpty() ? outputName : inputName);
0686         dialog.setMimeTypeFilters({mimetype});
0687         dialog.selectMimeTypeFilter(mimetype);
0688 
0689         // launch the dialog
0690         const bool accepted = dialog.exec() == QDialog::Accepted;
0691         const auto &selectedUrl = dialog.selectedUrls().value(0, QUrl());
0692         if (accepted && !selectedUrl.fileName().isEmpty()) {
0693             outputUrl = selectedUrl;
0694         }
0695         actions.setFlag(SaveAs, accepted && outputUrl.isValid());
0696     }
0697 
0698     bool inputFromTemp = temporaryDir() && inputFile.startsWith(m_tempDir->path());
0699     if (!outputUrl.isValid()) {
0700         if (actions & AnySave && inputFromTemp) {
0701             // Use the temp url without the temp dir as the new url, if necessary
0702             const auto &tempDirPath = m_tempDir->path() + u'/';
0703             const auto &reducedPath = inputFile.right(inputFile.size() - tempDirPath.size());
0704             outputUrl = Settings::videoSaveLocation().adjusted(QUrl::StripTrailingSlash);
0705             outputUrl.setPath(outputUrl.path() + u'/' + reducedPath);
0706         } else {
0707             outputUrl = inputUrl;
0708         }
0709     }
0710 
0711     // When the input is the output, it should still count as a successful save
0712     bool saved = inputUrl == outputUrl;
0713     if (actions & AnySave && !saved) {
0714         const auto &saveDirUrl = outputUrl.adjusted(QUrl::RemoveFilename);
0715         bool saveDirExists = false;
0716         if (saveDirUrl.isLocalFile()) {
0717             saveDirExists = QFileInfo::exists(saveDirUrl.toLocalFile());
0718         } else {
0719             KIO::ListJob *listJob = KIO::listDir(saveDirUrl);
0720             listJob->exec();
0721             saveDirExists = listJob->error() == KJob::NoError;
0722         }
0723         if (!saveDirExists) {
0724             KIO::MkpathJob *mkpathJob = KIO::mkpath(saveDirUrl);
0725             mkpathJob->exec();
0726             saveDirExists = mkpathJob->error() == KJob::NoError;
0727         }
0728         if (saveDirExists) {
0729             if (inputFromTemp) {
0730                 auto fileMoveJob = KIO::file_move(inputUrl, outputUrl);
0731                 fileMoveJob->exec();
0732                 saved = fileMoveJob->error() == KJob::NoError;
0733             } else {
0734                 auto fileCopyJob = KIO::file_copy(inputUrl, outputUrl);
0735                 fileCopyJob->exec();
0736                 saved = fileCopyJob->error() == KJob::NoError;
0737             }
0738         }
0739         if (!saved) {
0740             actions.setFlag(AnySave, false);
0741             Q_EMIT errorMessage(i18nc("@info", "Unable to save recording. Could not move file to location: %1", outputUrl.toString()));
0742             return;
0743         }
0744     }
0745 
0746     bool copiedPath = false;
0747     if (actions & CopyPath
0748         // This behavior has no relation to the setting in the config UI,
0749         // but it was added to solve this feature request:
0750         // https://bugs.kde.org/show_bug.cgi?id=357423
0751         || (saved && Settings::clipboardGroup() == Settings::PostScreenshotCopyLocation)) {
0752         // will be deleted for us by the platform's clipboard manager.
0753         auto data = new QMimeData();
0754         data->setText(outputUrl.isLocalFile() ? outputUrl.toLocalFile() : outputUrl.toString());
0755         KSystemClipboard::instance()->setMimeData(data, QClipboard::Clipboard);
0756         copiedPath = true;
0757     }
0758 
0759     if (saved || copiedPath) {
0760         Q_EMIT videoExported(actions, outputUrl);
0761     }
0762 }
0763 
0764 void ExportManager::doPrint(QPrinter *printer)
0765 {
0766     QPainter painter;
0767 
0768     if (!(painter.begin(printer))) {
0769         Q_EMIT errorMessage(i18n("Printing failed. The printer failed to initialize."));
0770         return;
0771     }
0772 
0773     painter.setRenderHint(QPainter::LosslessImageRendering);
0774 
0775     QRect devRect(0, 0, printer->width(), printer->height());
0776     QImage image = m_saveImage.scaled(devRect.size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
0777     QRect srcRect = image.rect();
0778     srcRect.moveCenter(devRect.center());
0779 
0780     painter.drawImage(srcRect.topLeft(), image);
0781     painter.end();
0782 }
0783 
0784 KLocalizedString placeholderDescription(const char *string)
0785 {
0786     return ki18nc("A placeholder in the user configurable filename will replaced by the specified value", string);
0787 }
0788 
0789 using enum ExportManager::Placeholder::Flag;
0790 const QList<ExportManager::Placeholder> ExportManager::filenamePlaceholders{
0791     {Date | Extra | QDateTime, u"d"_s, placeholderDescription("Day (1–31)")},
0792     {Date | QDateTime, u"dd"_s, placeholderDescription("Day (01–31)")},
0793     {Date | Extra | QDateTime, u"ddd"_s, placeholderDescription("Day (short name)")},
0794     {Date | Extra | QDateTime, u"dddd"_s, placeholderDescription("Day (long name)")},
0795     {Date | Extra | QDateTime, u"M"_s, placeholderDescription("Month (1–12)")},
0796     {Date | QDateTime, u"MM"_s, placeholderDescription("Month (01–12)")},
0797     {Date | QDateTime, u"MMM"_s, placeholderDescription("Month (short name)")},
0798     {Date | QDateTime, u"MMMM"_s, placeholderDescription("Month (long name)")},
0799     {Date | QDateTime, u"yy"_s, placeholderDescription("Year (2 digit)")},
0800     {Date | QDateTime, u"yyyy"_s, placeholderDescription("Year (4 digit)")},
0801     {Time | Extra | QDateTime, u"H"_s, placeholderDescription("Hour (0–23)")},
0802     {Time | QDateTime, u"HH"_s, placeholderDescription("Hour (00–23)")},
0803     {Time | Extra, u"h"_s, placeholderDescription("Hour (1–12)")},
0804     {Time, u"hh"_s, placeholderDescription("Hour (01–12)")},
0805     {Time | Extra | QDateTime, u"m"_s, placeholderDescription("Minute (0–59)")},
0806     {Time | QDateTime, u"mm"_s, placeholderDescription("Minute (00–59)")},
0807     {Time | Extra | QDateTime, u"s"_s, placeholderDescription("Second (0–59)")},
0808     {Time | QDateTime, u"ss"_s, placeholderDescription("Second (00–59)")},
0809     {Time | Extra | QDateTime, u"z"_s, placeholderDescription("Millisecond (0–999)")},
0810     {Time | Extra | QDateTime, u"zz"_s}, // same as `z`
0811     {Time | Extra | QDateTime, u"zzz"_s, placeholderDescription("Millisecond (000–999)")},
0812     {Time | Extra | QDateTime, u"AP"_s, placeholderDescription("AM/PM")},
0813     {Time | Extra | QDateTime, u"A"_s}, // same as `AP`
0814     {Time | Extra | QDateTime, u"ap"_s, placeholderDescription("am/pm")},
0815     {Time | Extra | QDateTime, u"a"_s}, // same as `ap`
0816     {Time | QDateTime, u"Ap"_s, placeholderDescription("AM/PM or am/pm (localized)")},
0817     {Time | Extra | QDateTime, u"aP"_s}, // same as `Ap`
0818     {Time | Extra | QDateTime, u"t"_s, placeholderDescription("Timezone (short name)")},
0819     {Time | QDateTime, u"tt"_s, placeholderDescription("Timezone offset (±0000)")},
0820     {Time | Extra | QDateTime, u"ttt"_s, placeholderDescription("Timezone offset (±00:00)")},
0821     {Time | Extra | QDateTime, u"tttt"_s, placeholderDescription("Timezone (long name)")},
0822     {Time | Extra, u"UnixTime"_s, placeholderDescription("Seconds since the Unix epoch")},
0823     {Other, u"title"_s, placeholderDescription("Window Title")},
0824     {Other, u"#"_s, placeholderDescription("Sequential numbering, padded by inserting additional '#' characters")},
0825 };
0826 
0827 #include "moc_ExportManager.cpp"