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