1 /* This file is part of Spectacle, the KDE screenshot utility
2  * SPDX-FileCopyrightText: 2019 David Redondo <kde@david-redondo.de>
3  * SPDX-FileCopyrightText: 2015 Boudhayan Gupta <bgupta@kde.org>
4  * SPDX-License-Identifier: LGPL-2.0-or-later
5  */
6 
7 #include "ExportManager.h"
8 
9 #include "settings.h"
10 #include <kio_version.h>
11 
12 #include <QApplication>
13 #include <QClipboard>
14 #include <QDir>
15 #include <QFileDialog>
16 #include <QImageWriter>
17 #include <QMimeData>
18 #include <QMimeDatabase>
19 #include <QPainter>
20 #include <QRandomGenerator>
21 #include <QRegularExpression>
22 #include <QRegularExpressionMatch>
23 #include <QString>
24 #include <QTemporaryDir>
25 #include <QTemporaryFile>
26 
27 #include <KIO/FileCopyJob>
28 #include <KIO/ListJob>
29 #include <KIO/MkpathJob>
30 #include <KIO/StatJob>
31 #include <KRecentDocument>
32 #include <KSharedConfig>
33 #include <KWindowSystem>
34 #include <QWindow>
35 
ExportManager(QObject * parent)36 ExportManager::ExportManager(QObject *parent)
37     : QObject(parent)
38     , mImageSavedNotInTemp(false)
39     , mSavePixmap(QPixmap())
40     , mTempFile(QUrl())
41 {
42     connect(this, &ExportManager::imageSaved, &Settings::setLastSaveLocation);
43     connect(this, &ExportManager::imageSavedAndCopied, &Settings::setLastSaveLocation);
44 }
45 
~ExportManager()46 ExportManager::~ExportManager()
47 {
48     delete mTempDir;
49 }
50 
instance()51 ExportManager *ExportManager::instance()
52 {
53     static ExportManager instance;
54     return &instance;
55 }
56 
57 // screenshot pixmap setter and getter
58 
pixmap() const59 QPixmap ExportManager::pixmap() const
60 {
61     return mSavePixmap;
62 }
63 
setWindowTitle(const QString & windowTitle)64 void ExportManager::setWindowTitle(const QString &windowTitle)
65 {
66     mWindowTitle = windowTitle;
67 }
68 
windowTitle() const69 QString ExportManager::windowTitle() const
70 {
71     return mWindowTitle;
72 }
73 
captureMode() const74 Spectacle::CaptureMode ExportManager::captureMode() const
75 {
76     return mCaptureMode;
77 }
78 
setCaptureMode(Spectacle::CaptureMode theCaptureMode)79 void ExportManager::setCaptureMode(Spectacle::CaptureMode theCaptureMode)
80 {
81     mCaptureMode = theCaptureMode;
82 }
83 
setPixmap(const QPixmap & pixmap)84 void ExportManager::setPixmap(const QPixmap &pixmap)
85 {
86     mSavePixmap = pixmap;
87 
88     // reset our saved tempfile
89     if (mTempFile.isValid()) {
90         mUsedTempFileNames.append(mTempFile);
91         QFile file(mTempFile.toLocalFile());
92         file.remove();
93         mTempFile = QUrl();
94     }
95 
96     // since the pixmap was modified, we now consider the image unsaved
97     mImageSavedNotInTemp = false;
98 }
99 
updatePixmapTimestamp()100 void ExportManager::updatePixmapTimestamp()
101 {
102     mPixmapTimestamp = QDateTime::currentDateTime();
103 }
104 
setTimestamp(const QDateTime & timestamp)105 void ExportManager::setTimestamp(const QDateTime &timestamp)
106 {
107     mPixmapTimestamp = timestamp;
108 }
109 
110 // native file save helpers
111 
defaultSaveLocation() const112 QString ExportManager::defaultSaveLocation() const
113 {
114     const QUrl saveUrl = Settings::self()->defaultSaveLocation();
115     QString savePath = saveUrl.scheme().isEmpty() ? saveUrl.toString() : saveUrl.toLocalFile();
116     savePath = QDir::cleanPath(savePath);
117 
118     QDir savePathDir(savePath);
119     if (!(savePathDir.exists())) {
120         savePathDir.mkpath(QStringLiteral("."));
121     }
122 
123     return savePath;
124 }
125 
getAutosaveFilename()126 QUrl ExportManager::getAutosaveFilename()
127 {
128     const QString baseDir = defaultSaveLocation();
129     const QDir baseDirPath(baseDir);
130     const QString filename = makeAutosaveFilename();
131     const QString fullpath =
132         autoIncrementFilename(baseDirPath.filePath(filename), Settings::self()->defaultSaveImageFormat().toLower(), &ExportManager::isFileExists);
133 
134     const QUrl fileNameUrl = QUrl::fromUserInput(fullpath);
135     if (fileNameUrl.isValid()) {
136         return fileNameUrl;
137     } else {
138         return QUrl();
139     }
140 }
141 
truncatedFilename(QString const & filename)142 QString ExportManager::truncatedFilename(QString const &filename)
143 {
144     QString result = filename;
145     constexpr auto maxFilenameLength = 255;
146     constexpr auto maxExtensionLength = 5; // For example, ".jpeg"
147     constexpr auto maxCounterLength = 20; // std::numeric_limits<quint64>::max() == 18446744073709551615
148     constexpr auto maxLength = maxFilenameLength - maxCounterLength - maxExtensionLength;
149     result.truncate(maxLength);
150     return result;
151 }
152 
makeAutosaveFilename()153 QString ExportManager::makeAutosaveFilename()
154 {
155     return formatFilename(Settings::self()->saveFilenameFormat());
156 }
157 
formatFilename(const QString & nameTemplate)158 QString ExportManager::formatFilename(const QString &nameTemplate)
159 {
160     const QDateTime timestamp = mPixmapTimestamp;
161     QString baseName = nameTemplate;
162     QString baseDir = defaultSaveLocation();
163     QString title;
164 
165     if (mCaptureMode == Spectacle::CaptureMode::ActiveWindow || mCaptureMode == Spectacle::CaptureMode::TransientWithParent
166         || mCaptureMode == Spectacle::CaptureMode::WindowUnderCursor) {
167         title = mWindowTitle.replace(QLatin1Char('/'), QLatin1String("_")); // POSIX doesn't allow "/" in filenames
168     } else {
169         // Remove '%T' with separators around it
170         const auto wordSymbol = QStringLiteral(R"(\p{L}\p{M}\p{N})");
171         const auto separator = QStringLiteral("([^%1]+)").arg(wordSymbol);
172         const auto re = QRegularExpression(QStringLiteral("(.*?)(%1%T|%T%1)(.*?)").arg(separator));
173         baseName.replace(re, QStringLiteral(R"(\1\5)"));
174     }
175 
176     QString result = baseName.replace(QLatin1String("%Y"), timestamp.toString(QStringLiteral("yyyy")))
177                          .replace(QLatin1String("%y"), timestamp.toString(QStringLiteral("yy")))
178                          .replace(QLatin1String("%M"), timestamp.toString(QStringLiteral("MM")))
179                          .replace(QLatin1String("%D"), timestamp.toString(QStringLiteral("dd")))
180                          .replace(QLatin1String("%H"), timestamp.toString(QStringLiteral("hh")))
181                          .replace(QLatin1String("%m"), timestamp.toString(QStringLiteral("mm")))
182                          .replace(QLatin1String("%S"), timestamp.toString(QStringLiteral("ss")))
183                          .replace(QLatin1String("%T"), title);
184 
185     // check if basename includes %[N]d token for sequential file numbering
186     QRegularExpression paddingRE;
187     paddingRE.setPattern(QStringLiteral("%(\\d*)d"));
188     QRegularExpressionMatchIterator it = paddingRE.globalMatch(result);
189     if (it.hasNext()) {
190         // strip any subdirectories from the template to construct the filename matching regex
191         // we are matching filenames only, not paths
192         QString resultCopy = QRegularExpression::escape(result.section(QLatin1Char('/'), -1));
193         QVector<QRegularExpressionMatch> matches;
194         while (it.hasNext()) {
195             QRegularExpressionMatch paddingMatch = it.next();
196             matches.push_back(paddingMatch);
197             // determine padding value
198             int paddedLength = 1;
199             if (!paddingMatch.captured(1).isEmpty()) {
200                 paddedLength = paddingMatch.captured(1).toInt();
201             }
202             QString escapedMatch = QRegularExpression::escape(paddingMatch.captured());
203             resultCopy.replace(escapedMatch, QStringLiteral("(\\d{%1,})").arg(QString::number(paddedLength)));
204         }
205         if (result.contains(QLatin1Char('/'))) {
206             // In case the filename template contains a subdirectory,
207             // we need to search for files in the subdirectory instead of the baseDir.
208             // so let's add that to baseDir before we search for files.
209             baseDir += QStringLiteral("/%1").arg(result.section(QLatin1Char('/'), 0, -2));
210         }
211         // search save directory for files
212         QDir dir(baseDir);
213         const QStringList fileNames = dir.entryList(QDir::Files, QDir::Name);
214         int highestFileNumber = 0;
215 
216         // if there are files in the directory...
217         if (fileNames.length() > 0) {
218             QRegularExpression fileNumberRE;
219             fileNumberRE.setPattern(resultCopy);
220             // ... check the file names for string matching token with padding specified in result
221             const QStringList filteredFiles = fileNames.filter(fileNumberRE);
222             // if there are files in the directory that look like the file name with sequential numbering
223             if (filteredFiles.length() > 0) {
224                 // loop through filtered file names looking for highest number
225                 for (const QString &filteredFile : filteredFiles) {
226                     int currentFileNumber = fileNumberRE.match(filteredFile).captured(1).toInt();
227                     if (currentFileNumber > highestFileNumber) {
228                         highestFileNumber = currentFileNumber;
229                     }
230                 }
231             }
232         }
233         // replace placeholder with next number padded
234         for (const auto &match : matches) {
235             int paddedLength = 1;
236             if (!match.captured(1).isEmpty()) {
237                 paddedLength = match.captured(1).toInt();
238             }
239             const QString nextFileNumberPadded = QString::number(highestFileNumber + 1).rightJustified(paddedLength, QLatin1Char('0'));
240             result.replace(match.captured(), nextFileNumberPadded);
241         }
242     }
243 
244     // Remove leading and trailing '/'
245     while (result.startsWith(QLatin1Char('/'))) {
246         result.remove(0, 1);
247     }
248     while (result.endsWith(QLatin1Char('/'))) {
249         result.chop(1);
250     }
251 
252     if (result.isEmpty()) {
253         result = QStringLiteral("Screenshot");
254     }
255     return truncatedFilename(result);
256 }
257 
autoIncrementFilename(const QString & baseName,const QString & extension,FileNameAlreadyUsedCheck isFileNameUsed)258 QString ExportManager::autoIncrementFilename(const QString &baseName, const QString &extension, FileNameAlreadyUsedCheck isFileNameUsed)
259 {
260     QString result = truncatedFilename(baseName) + QLatin1String(".") + extension;
261     if (!((this->*isFileNameUsed)(QUrl::fromUserInput(result)))) {
262         return result;
263     }
264 
265     QString fileNameFmt = truncatedFilename(baseName) + QStringLiteral("-%1.");
266     for (quint64 i = 1; i < std::numeric_limits<quint64>::max(); i++) {
267         result = fileNameFmt.arg(i) + extension;
268         if (!((this->*isFileNameUsed)(QUrl::fromUserInput(result)))) {
269             return result;
270         }
271     }
272 
273     // unlikely this will ever happen, but just in case we've run
274     // out of numbers
275 
276     result = fileNameFmt.arg(QLatin1String("OVERFLOW-") + QString::number(QRandomGenerator::global()->bounded(10000)));
277     return truncatedFilename(result) + extension;
278 }
279 
makeSaveMimetype(const QUrl & url)280 QString ExportManager::makeSaveMimetype(const QUrl &url)
281 {
282     QMimeDatabase mimedb;
283     QString type = mimedb.mimeTypeForUrl(url).preferredSuffix();
284 
285     if (type.isEmpty()) {
286         return Settings::self()->defaultSaveImageFormat().toLower();
287     }
288     return type;
289 }
290 
writeImage(QIODevice * device,const QByteArray & format)291 bool ExportManager::writeImage(QIODevice *device, const QByteArray &format)
292 {
293     QImageWriter imageWriter(device, format);
294     imageWriter.setQuality(Settings::self()->compressionQuality());
295     /** Set compression 50 if the format is png. Otherwise if no compression value is specified
296      *  it will fallback to using quality (QTBUG-43618) and produce huge files.
297      *  See also qpnghandler.cpp#n1075. The other formats that do compression seem to have it
298      *  enabled by default and only disabled if compression is set to 0, also any value except 0
299      *  has the same effect for them.
300      */
301     if (format == "png") {
302         imageWriter.setCompression(50);
303     }
304     if (!(imageWriter.canWrite())) {
305         Q_EMIT errorMessage(i18n("QImageWriter cannot write image: %1", imageWriter.errorString()));
306         return false;
307     }
308 
309     return imageWriter.write(mSavePixmap.toImage());
310 }
311 
localSave(const QUrl & url,const QString & mimetype)312 bool ExportManager::localSave(const QUrl &url, const QString &mimetype)
313 {
314     // Create save directory if it doesn't exist
315     const QUrl dirPath(url.adjusted(QUrl::RemoveFilename));
316     const QDir dir(dirPath.path());
317 
318     if (!dir.mkpath(QStringLiteral("."))) {
319         Q_EMIT errorMessage(xi18nc("@info",
320                                    "Cannot save screenshot because creating "
321                                    "the directory failed:<nl/><filename>%1</filename>",
322                                    dirPath.path()));
323         return false;
324     }
325 
326     QFile outputFile(url.toLocalFile());
327 
328     outputFile.open(QFile::WriteOnly);
329     if (!writeImage(&outputFile, mimetype.toLatin1())) {
330         Q_EMIT errorMessage(i18n("Cannot save screenshot. Error while writing file."));
331         return false;
332     }
333     return true;
334 }
335 
remoteSave(const QUrl & url,const QString & mimetype)336 bool ExportManager::remoteSave(const QUrl &url, const QString &mimetype)
337 {
338     // Check if remote save directory exists
339     const QUrl dirPath(url.adjusted(QUrl::RemoveFilename));
340     KIO::ListJob *listJob = KIO::listDir(dirPath);
341     listJob->exec();
342 
343     if (listJob->error() != KJob::NoError) {
344         // Create remote save directory
345         KIO::MkpathJob *mkpathJob = KIO::mkpath(dirPath, QUrl(defaultSaveLocation()));
346         mkpathJob->exec();
347 
348         if (mkpathJob->error() != KJob::NoError) {
349             Q_EMIT errorMessage(xi18nc("@info",
350                                        "Cannot save screenshot because creating the "
351                                        "remote directory failed:<nl/><filename>%1</filename>",
352                                        dirPath.path()));
353             return false;
354         }
355     }
356 
357     QTemporaryFile tmpFile;
358 
359     if (tmpFile.open()) {
360         if (!writeImage(&tmpFile, mimetype.toLatin1())) {
361             Q_EMIT errorMessage(i18n("Cannot save screenshot. Error while writing temporary local file."));
362             return false;
363         }
364 
365         KIO::FileCopyJob *uploadJob = KIO::file_copy(QUrl::fromLocalFile(tmpFile.fileName()), url);
366         uploadJob->exec();
367 
368         if (uploadJob->error() != KJob::NoError) {
369             Q_EMIT errorMessage(i18n("Unable to save image. Could not upload file to remote location."));
370             return false;
371         }
372         return true;
373     }
374 
375     return false;
376 }
377 
tempSave()378 QUrl ExportManager::tempSave()
379 {
380     // if we already have a temp file saved, use that
381     if (mTempFile.isValid()) {
382         if (QFile(mTempFile.toLocalFile()).exists()) {
383             return mTempFile;
384         }
385     }
386 
387     if (!mTempDir) {
388         mTempDir = new QTemporaryDir(QDir::tempPath() + QDir::separator() + QStringLiteral("Spectacle.XXXXXX"));
389     }
390     if (mTempDir && mTempDir->isValid()) {
391         // create the temporary file itself with normal file name and also unique one for this session
392         // supports the use-case of creating multiple screenshots in a row
393         // and exporting them to the same destination e.g. via clipboard,
394         // where the temp file name is used as filename suggestion
395         const QString baseFileName = mTempDir->path() + QLatin1Char('/') + QUrl::fromLocalFile(makeAutosaveFilename()).fileName();
396 
397         QString mimetype = makeSaveMimetype(QUrl(baseFileName));
398         const QString fileName = autoIncrementFilename(baseFileName, mimetype, &ExportManager::isTempFileAlreadyUsed);
399         QFile tmpFile(fileName);
400         if (tmpFile.open(QFile::WriteOnly)) {
401             if (writeImage(&tmpFile, mimetype.toLatin1())) {
402                 mTempFile = QUrl::fromLocalFile(tmpFile.fileName());
403                 // try to make sure 3rd-party which gets the url of the temporary file e.g. on export
404                 // properly treats this as readonly, also hide from other users
405                 tmpFile.setPermissions(QFile::ReadUser);
406                 return mTempFile;
407             }
408         }
409     }
410 
411     Q_EMIT errorMessage(i18n("Cannot save screenshot. Error while writing temporary local file."));
412     return QUrl();
413 }
414 
save(const QUrl & url)415 bool ExportManager::save(const QUrl &url)
416 {
417     if (!(url.isValid())) {
418         Q_EMIT errorMessage(i18n("Cannot save screenshot. The save filename is invalid."));
419         return false;
420     }
421 
422     QString mimetype = makeSaveMimetype(url);
423     bool saveSucceded = false;
424     if (url.isLocalFile()) {
425         saveSucceded = localSave(url, mimetype);
426     } else {
427         saveSucceded = remoteSave(url, mimetype);
428     }
429     if (saveSucceded) {
430         mImageSavedNotInTemp = true;
431         KRecentDocument::add(url, QGuiApplication::desktopFileName());
432     }
433     return saveSucceded;
434 }
435 
isFileExists(const QUrl & url) const436 bool ExportManager::isFileExists(const QUrl &url) const
437 {
438     if (!(url.isValid())) {
439         return false;
440     }
441     KIO::StatJob *existsJob = KIO::statDetails(url, KIO::StatJob::DestinationSide, KIO::StatNoDetails, KIO::HideProgressInfo);
442 
443     existsJob->exec();
444 
445     return (existsJob->error() == KJob::NoError);
446 }
447 
isImageSavedNotInTemp() const448 bool ExportManager::isImageSavedNotInTemp() const
449 {
450     return mImageSavedNotInTemp;
451 }
452 
isTempFileAlreadyUsed(const QUrl & url) const453 bool ExportManager::isTempFileAlreadyUsed(const QUrl &url) const
454 {
455     return mUsedTempFileNames.contains(url);
456 }
457 
458 // save slots
459 
doSave(const QUrl & url,bool notify)460 void ExportManager::doSave(const QUrl &url, bool notify)
461 {
462     if (mSavePixmap.isNull()) {
463         Q_EMIT errorMessage(i18n("Cannot save an empty screenshot image."));
464         return;
465     }
466 
467     QUrl savePath = url.isValid() ? url : getAutosaveFilename();
468     if (save(savePath)) {
469         QDir dir(savePath.path());
470         dir.cdUp();
471 
472         Q_EMIT imageSaved(savePath);
473         if (notify) {
474             Q_EMIT forceNotify(savePath);
475         }
476     }
477 }
478 
doSaveAs(QWidget * parentWindow,bool notify)479 bool ExportManager::doSaveAs(QWidget *parentWindow, bool notify)
480 {
481     QStringList supportedFilters;
482 
483     // construct the supported mimetype list
484     const auto mimeTypes = QImageWriter::supportedMimeTypes();
485     supportedFilters.reserve(mimeTypes.count());
486     for (const auto &mimeType : mimeTypes) {
487         supportedFilters.append(QString::fromUtf8(mimeType).trimmed());
488     }
489 
490     // construct the file name
491     const QString filenameExtension = Settings::self()->defaultSaveImageFormat().toLower();
492     const QString mimetype = QMimeDatabase().mimeTypeForFile(QStringLiteral("~/fakefile.") + filenameExtension, QMimeDatabase::MatchExtension).name();
493     QFileDialog dialog(parentWindow);
494     dialog.setAcceptMode(QFileDialog::AcceptSave);
495     dialog.setFileMode(QFileDialog::AnyFile);
496     dialog.setDirectoryUrl(Settings::self()->lastSaveAsLocation().adjusted(QUrl::RemoveFilename));
497     dialog.selectFile(makeAutosaveFilename() + QStringLiteral(".") + filenameExtension);
498     dialog.setDefaultSuffix(QStringLiteral(".") + filenameExtension);
499     dialog.setMimeTypeFilters(supportedFilters);
500     dialog.selectMimeTypeFilter(mimetype);
501 
502     // launch the dialog
503     if (dialog.exec() == QFileDialog::Accepted) {
504         const QUrl saveUrl = dialog.selectedUrls().constFirst();
505         if (saveUrl.isValid()) {
506             if (save(saveUrl)) {
507                 Q_EMIT imageSaved(saveUrl);
508                 Settings::setLastSaveAsLocation(saveUrl);
509                 if (notify) {
510                     Q_EMIT forceNotify(saveUrl);
511                 }
512                 return true;
513             }
514         }
515     }
516     return false;
517 }
518 
doSaveAndCopy(const QUrl & url)519 void ExportManager::doSaveAndCopy(const QUrl &url)
520 {
521     if (mSavePixmap.isNull()) {
522         Q_EMIT errorMessage(i18n("Cannot save an empty screenshot image."));
523         return;
524     }
525 
526     QUrl savePath = url.isValid() ? url : getAutosaveFilename();
527     if (save(savePath)) {
528         QDir dir(savePath.path());
529         dir.cdUp();
530 
531         doCopyToClipboard(false);
532         Q_EMIT imageSavedAndCopied(savePath);
533     }
534 }
535 
536 // misc helpers
doCopyToClipboard(bool notify)537 void ExportManager::doCopyToClipboard(bool notify)
538 {
539     const auto copyToClipboard = [this, notify]() {
540         auto data = new QMimeData();
541         data->setImageData(mSavePixmap.toImage());
542         data->setData(QStringLiteral("x-kde-force-image-copy"), QByteArray());
543         QApplication::clipboard()->setMimeData(data, QClipboard::Clipboard);
544         Q_EMIT imageCopied();
545         if (notify) {
546             Q_EMIT forceNotify(QUrl());
547         }
548     };
549 
550     if (KWindowSystem::isPlatformWayland() && !QGuiApplication::focusWindow()) {
551         // under wayland you can copy to clipboard only from a focused window
552         // delay the copy until after a window has focus
553         QMetaObject::Connection *connection = new QMetaObject::Connection;
554         *connection = connect(qApp, &QGuiApplication::focusWindowChanged, this, [copyToClipboard, connection](const QWindow *) {
555             disconnect(*connection);
556             delete connection;
557 
558             copyToClipboard();
559         });
560     } else {
561         copyToClipboard();
562     }
563 }
564 
doCopyLocationToClipboard(bool notify)565 void ExportManager::doCopyLocationToClipboard(bool notify)
566 {
567     QString localFile;
568     if (mImageSavedNotInTemp) {
569         // The image has been saved (manually or automatically), we need to choose that file path
570         localFile = Settings::self()->lastSaveLocation().toLocalFile();
571     } else {
572         // use a temporary save path, and copy that to clipboard instead
573         localFile = ExportManager::instance()->tempSave().toLocalFile();
574     }
575 
576     QApplication::clipboard()->setText(localFile);
577     Q_EMIT imageLocationCopied(QUrl::fromLocalFile(localFile));
578     if (notify) {
579         Q_EMIT forceNotify(QUrl::fromLocalFile(localFile));
580     }
581 }
582 
doPrint(QPrinter * printer)583 void ExportManager::doPrint(QPrinter *printer)
584 {
585     QPainter painter;
586 
587     if (!(painter.begin(printer))) {
588         Q_EMIT errorMessage(i18n("Printing failed. The printer failed to initialize."));
589         delete printer;
590         return;
591     }
592 
593     QRect devRect(0, 0, printer->width(), printer->height());
594     QPixmap pixmap = mSavePixmap.scaled(devRect.size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
595     QRect srcRect = pixmap.rect();
596     srcRect.moveCenter(devRect.center());
597 
598     painter.drawPixmap(srcRect.topLeft(), pixmap);
599     painter.end();
600 
601     delete printer;
602     return;
603 }
604 
605 const QMap<QString, KLocalizedString> ExportManager::filenamePlaceholders{
606     {QStringLiteral("%Y"), ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Year (4 digit)")},
607     {QStringLiteral("%y"), ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Year (2 digit)")},
608     {QStringLiteral("%M"), ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Month")},
609     {QStringLiteral("%D"), ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Day")},
610     {QStringLiteral("%H"), ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Hour")},
611     {QStringLiteral("%m"), ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Minute")},
612     {QStringLiteral("%S"), ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Second")},
613     {QStringLiteral("%T"), ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Window Title")},
614     {QStringLiteral("%d"), ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Sequential numbering")},
615     {QStringLiteral("%Nd"),
616      ki18nc("A placeholder in the user configurable filename will replaced by the specified value", "Sequential numbering, padded out to N digits")},
617 };
618