File indexing completed on 2024-05-12 08:34:14

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