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 ×tamp) 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"