File indexing completed on 2024-05-19 04:23:37
0001 // SPDX-FileCopyrightText: 2006-2008 Tuomas Suutari <tuomas@nepnep.net> 0002 // SPDX-FileCopyrightText: 2006-2014 Jesper K. Pedersen <jesper.pedersen@kdab.com> 0003 // SPDX-FileCopyrightText: 2007 Dirk Mueller <mueller@kde.org> 0004 // SPDX-FileCopyrightText: 2007 Laurent Montel <montel@kde.org> 0005 // SPDX-FileCopyrightText: 2008-2011 Jan Kundrát <jkt@flaska.net> 0006 // SPDX-FileCopyrightText: 2008-2009 Henner Zeller <h.zeller@acm.org> 0007 // SPDX-FileCopyrightText: 2012 Yuri Chornoivan <yurchor@ukr.net> 0008 // SPDX-FileCopyrightText: 2012-2013 Miika Turkia <miika.turkia@gmail.com> 0009 // SPDX-FileCopyrightText: 2014-2020 Tobias Leupold <tl@stonemx.de> 0010 // SPDX-FileCopyrightText: 2018-2020 Robert Krawitz <rlk@alum.mit.edu> 0011 // SPDX-FileCopyrightText: 2012-2023 Johannes Zarl-Zierl <johannes@zarl-zierl.at> 0012 // 0013 // SPDX-License-Identifier: GPL-2.0-or-later 0014 0015 #include "FileWriter.h" 0016 0017 #include "CompressFileInfo.h" 0018 #include "ElementWriter.h" 0019 #include "NumberedBackup.h" 0020 0021 #include <DB/Category.h> 0022 #include <DB/ImageDB.h> 0023 #include <DB/TagInfo.h> 0024 #include <Utilities/List.h> 0025 #include <kpabase/Logging.h> 0026 #include <kpabase/SettingsData.h> 0027 #include <kpabase/UIDelegate.h> 0028 0029 #include <KLocalizedString> 0030 #include <QElapsedTimer> 0031 #include <QFile> 0032 #include <QFileInfo> 0033 #include <QMutexLocker> 0034 #include <QXmlStreamWriter> 0035 0036 // 0037 // 0038 // 0039 // +++++++++++++++++++++++++++++++ REMEMBER ++++++++++++++++++++++++++++++++ 0040 // 0041 // 0042 // 0043 // 0044 // Update DB::ImageDB::fileVersion every time you update the file format! 0045 // 0046 // 0047 // 0048 // 0049 // 0050 // 0051 // 0052 // 0053 // (sorry for the noise, but it is really important :-) 0054 0055 using Utilities::StringSet; 0056 0057 namespace 0058 { 0059 constexpr QFileDevice::Permissions FILE_PERMISSIONS { QFile::ReadOwner | QFile::WriteOwner | QFile::ReadGroup | QFile::WriteGroup | QFile::ReadOther }; 0060 } 0061 0062 void DB::FileWriter::save(const QString &fileName, bool isAutoSave) 0063 { 0064 setUseCompressedFileFormat(Settings::SettingsData::instance()->useCompressedIndexXML()); 0065 0066 if (!isAutoSave) 0067 NumberedBackup(m_db->uiDelegate()).makeNumberedBackup(); 0068 0069 // prepare XML document for saving: 0070 m_db->m_categoryCollection.initIdMap(); 0071 QFile out(fileName + QStringLiteral(".tmp")); 0072 if (!out.open(QIODevice::WriteOnly | QIODevice::Text)) { 0073 m_db->uiDelegate().error( 0074 DB::LogMessage { DBLog(), QStringLiteral("Error saving to file '%1': %2").arg(out.fileName(), out.errorString()) }, i18n("<p>Could not save the image database to XML.</p>" 0075 "File %1 could not be opened because of the following error: %2", 0076 out.fileName(), out.errorString()), 0077 i18n("Error while saving...")); 0078 return; 0079 } 0080 if (!out.setPermissions(FILE_PERMISSIONS)) { 0081 qCWarning(DBLog, "Could not set permissions on file %s!", qPrintable(out.fileName())); 0082 } 0083 QElapsedTimer timer; 0084 if (TimingLog().isDebugEnabled()) 0085 timer.start(); 0086 QXmlStreamWriter writer(&out); 0087 writer.setAutoFormatting(true); 0088 writer.writeStartDocument(); 0089 0090 { 0091 ElementWriter dummy(writer, QStringLiteral("KPhotoAlbum")); 0092 writer.writeAttribute(QStringLiteral("version"), QString::number(DB::ImageDB::fileVersion())); 0093 writer.writeAttribute(QStringLiteral("compressed"), QString::number(useCompressedFileFormat())); 0094 0095 saveCategories(writer); 0096 saveImages(writer); 0097 saveBlockList(writer); 0098 saveMemberGroups(writer); 0099 // saveSettings(writer); 0100 saveGlobalSortOrder(writer); 0101 } 0102 writer.writeEndDocument(); 0103 qCDebug(TimingLog) << "DB::FileWriter::save(): Saving took" << timer.elapsed() << "ms"; 0104 0105 // State: index.xml has previous DB version, index.xml.tmp has the current version. 0106 0107 // original file can be safely deleted 0108 if ((!QFile::remove(fileName)) && QFile::exists(fileName)) { 0109 m_db->uiDelegate().error( 0110 DB::LogMessage { DBLog(), QStringLiteral("Removal of file '%1' failed.").arg(fileName) }, i18n("<p>Failed to remove old version of image database.</p>" 0111 "<p>Please try again or replace the file %1 with file %2 manually!</p>", 0112 fileName, out.fileName()), 0113 i18n("Error while saving...")); 0114 return; 0115 } 0116 // State: index.xml doesn't exist, index.xml.tmp has the current version. 0117 if (!out.rename(fileName)) { 0118 m_db->uiDelegate().error( 0119 DB::LogMessage { DBLog(), QStringLiteral("Renaming index.xml to '%1' failed.").arg(out.fileName()) }, i18n("<p>Failed to move temporary XML file to permanent location.</p>" 0120 "<p>Please try again or rename file %1 to %2 manually!</p>", 0121 out.fileName(), fileName), 0122 i18n("Error while saving...")); 0123 // State: index.xml.tmp has the current version. 0124 return; 0125 } 0126 // State: index.xml has the current version. 0127 } 0128 0129 void DB::FileWriter::saveCategories(QXmlStreamWriter &writer) 0130 { 0131 const QStringList categories = DB::ImageDB::instance()->categoryCollection()->categoryNames(); 0132 ElementWriter dummy(writer, QStringLiteral("Categories")); 0133 0134 DB::CategoryPtr tokensCategory = DB::ImageDB::instance()->categoryCollection()->categoryForSpecial(DB::Category::TokensCategory); 0135 const DB::TagInfo *untaggedTag = DB::ImageDB::instance()->untaggedTag(); 0136 for (const QString &name : categories) { 0137 DB::CategoryPtr category = DB::ImageDB::instance()->categoryCollection()->categoryForName(name); 0138 0139 if (!shouldSaveCategory(name)) { 0140 continue; 0141 } 0142 0143 ElementWriter dummy(writer, QStringLiteral("Category")); 0144 writer.writeAttribute(QStringLiteral("name"), name); 0145 writer.writeAttribute(QStringLiteral("icon"), category->iconName()); 0146 writer.writeAttribute(QStringLiteral("show"), QString::number(category->doShow())); 0147 writer.writeAttribute(QStringLiteral("viewtype"), QString::number(category->viewType())); 0148 writer.writeAttribute(QStringLiteral("thumbnailsize"), QString::number(category->thumbnailSize())); 0149 writer.writeAttribute(QStringLiteral("positionable"), QString::number(category->positionable())); 0150 if (category == tokensCategory) { 0151 writer.writeAttribute(QStringLiteral("meta"), QStringLiteral("tokens")); 0152 } 0153 0154 // As bug 423334 shows, it is easy to forget to add a group to the respective category 0155 // when it's created. We can not enforce correct creation of member groups in our API, 0156 // but we can prevent incorrect data from entering index.xml. 0157 const auto categoryItems = Utilities::mergeListsUniqly(category->items(), m_db->memberMap().groups(name)); 0158 for (const QString &tagName : categoryItems) { 0159 ElementWriter dummy(writer, QStringLiteral("value")); 0160 writer.writeAttribute(QStringLiteral("value"), tagName); 0161 writer.writeAttribute(QStringLiteral("id"), QString::number(category->idForName(tagName))); 0162 QDate birthDate = category->birthDate(tagName); 0163 if (!birthDate.isNull()) 0164 writer.writeAttribute(QStringLiteral("birthDate"), birthDate.toString(Qt::ISODate)); 0165 if (untaggedTag && untaggedTag->category() == category.data() && untaggedTag->tagName() == tagName) { 0166 writer.writeAttribute(QStringLiteral("meta"), QStringLiteral("mark-untagged")); 0167 } 0168 } 0169 } 0170 } 0171 0172 void DB::FileWriter::saveImages(QXmlStreamWriter &writer) 0173 { 0174 DB::ImageInfoList list = m_db->m_images; 0175 0176 // Copy files from clipboard to end of overview, so we don't loose them 0177 const auto clipBoardImages = m_db->m_clipboard; 0178 for (const DB::ImageInfoPtr &infoPtr : clipBoardImages) { 0179 list.append(infoPtr); 0180 } 0181 0182 { 0183 ElementWriter dummy(writer, QStringLiteral("images")); 0184 0185 for (const DB::ImageInfoPtr &infoPtr : qAsConst(list)) { 0186 save(writer, infoPtr); 0187 } 0188 } 0189 } 0190 0191 void DB::FileWriter::saveBlockList(QXmlStreamWriter &writer) 0192 { 0193 ElementWriter dummy(writer, QStringLiteral("blocklist")); 0194 QList<DB::FileName> blockList(m_db->m_blockList.begin(), m_db->m_blockList.end()); 0195 // sort blocklist to get diffable files 0196 std::sort(blockList.begin(), blockList.end()); 0197 for (const DB::FileName &block : qAsConst(blockList)) { 0198 ElementWriter dummy(writer, QStringLiteral("block")); 0199 writer.writeAttribute(QStringLiteral("file"), block.relative()); 0200 } 0201 } 0202 0203 void DB::FileWriter::saveMemberGroups(QXmlStreamWriter &writer) 0204 { 0205 if (m_db->m_members.isEmpty()) 0206 return; 0207 0208 ElementWriter dummy(writer, QStringLiteral("member-groups")); 0209 for (QMap<QString, QMap<QString, StringSet>>::ConstIterator memberMapIt = m_db->m_members.memberMap().constBegin(); 0210 memberMapIt != m_db->m_members.memberMap().constEnd(); ++memberMapIt) { 0211 const QString categoryName = memberMapIt.key(); 0212 0213 // FIXME (l3u): This can happen when an empty sub-category (group) is present. 0214 // Would be fine to fix the reason why this happens in the first place. 0215 if (categoryName.isEmpty()) { 0216 continue; 0217 } 0218 0219 if (!shouldSaveCategory(categoryName)) 0220 continue; 0221 0222 QMap<QString, StringSet> groupMap = memberMapIt.value(); 0223 for (QMap<QString, StringSet>::ConstIterator groupMapIt = groupMap.constBegin(); groupMapIt != groupMap.constEnd(); ++groupMapIt) { 0224 0225 // FIXME (l3u): This can happen when an empty sub-category (group) is present. 0226 // Would be fine to fix the reason why this happens in the first place. 0227 if (groupMapIt.key().isEmpty()) { 0228 continue; 0229 } 0230 0231 if (useCompressedFileFormat()) { 0232 const StringSet members = groupMapIt.value(); 0233 ElementWriter dummy(writer, QStringLiteral("member")); 0234 writer.writeAttribute(QStringLiteral("category"), categoryName); 0235 writer.writeAttribute(QStringLiteral("group-name"), groupMapIt.key()); 0236 QStringList idList; 0237 for (const QString &member : members) { 0238 DB::CategoryPtr catPtr = m_db->m_categoryCollection.categoryForName(categoryName); 0239 if (catPtr->idForName(member) == 0) 0240 qCWarning(DBLog) << "Member" << member << "in group" << categoryName << "->" << groupMapIt.key() << "has no id!"; 0241 idList.append(QString::number(catPtr->idForName(member))); 0242 } 0243 std::sort(idList.begin(), idList.end()); 0244 writer.writeAttribute(QStringLiteral("members"), idList.join(QStringLiteral(","))); 0245 } else { 0246 const auto groupMapItValue = groupMapIt.value(); 0247 QStringList members(groupMapItValue.begin(), groupMapItValue.end()); 0248 std::sort(members.begin(), members.end()); 0249 for (const QString &member : qAsConst(members)) { 0250 ElementWriter dummy(writer, QStringLiteral("member")); 0251 writer.writeAttribute(QStringLiteral("category"), memberMapIt.key()); 0252 writer.writeAttribute(QStringLiteral("group-name"), groupMapIt.key()); 0253 writer.writeAttribute(QStringLiteral("member"), member); 0254 } 0255 0256 // Add an entry even if the group is empty 0257 // (this is not necessary for the compressed format) 0258 if (members.size() == 0) { 0259 ElementWriter dummy(writer, QStringLiteral("member")); 0260 writer.writeAttribute(QStringLiteral("category"), memberMapIt.key()); 0261 writer.writeAttribute(QStringLiteral("group-name"), groupMapIt.key()); 0262 } 0263 } 0264 } 0265 } 0266 } 0267 0268 void DB::FileWriter::saveGlobalSortOrder(QXmlStreamWriter &writer) 0269 { 0270 ElementWriter dummy(writer, QStringLiteral("global-sort-order")); 0271 for (const auto &item : m_db->categoryCollection()->globalSortOrder()->modifiedSortOrder()) { 0272 ElementWriter dummy(writer, QStringLiteral("item")); 0273 writer.writeAttribute(QStringLiteral("category"), item.category); 0274 writer.writeAttribute(QStringLiteral("item"), item.item); 0275 } 0276 } 0277 0278 /* 0279 Perhaps, we may need this later ;-) 0280 0281 void DB::FileWriter::saveSettings(QXmlStreamWriter& writer) 0282 { 0283 ElementWriter dummy(writer, settingsString); 0284 0285 QMap<QString, QString> settings; 0286 // For testing 0287 settings.insert(QStringLiteral("tokensCategory"), QStringLiteral("Tokens")); 0288 settings.insert(QStringLiteral("untaggedCategory"), QStringLiteral("Events")); 0289 settings.insert(QStringLiteral("untaggedTag"), QStringLiteral("untagged")); 0290 0291 QMapIterator<QString, QString> settingsIterator(settings); 0292 while (settingsIterator.hasNext()) { 0293 ElementWriter dummy(writer, settingString); 0294 settingsIterator.next(); 0295 writer.writeAttribute(QStringLiteral("key"), escape(settingsIterator.key())); 0296 writer.writeAttribute(QStringLiteral("value"), escape(settingsIterator.value())); 0297 } 0298 } 0299 */ 0300 0301 static const QString &stdDateTimeToString(const Utilities::FastDateTime &date) 0302 { 0303 static QString s_lastDateTimeString; 0304 static Utilities::FastDateTime s_lastDateTime; 0305 static QMutex s_lastDateTimeLocker; 0306 QMutexLocker dummy(&s_lastDateTimeLocker); 0307 if (date.isValid() && date != s_lastDateTime) { 0308 s_lastDateTime = date; 0309 s_lastDateTimeString = date.toString(Qt::ISODate); 0310 } 0311 return s_lastDateTimeString; 0312 } 0313 0314 void DB::FileWriter::save(QXmlStreamWriter &writer, const DB::ImageInfoPtr &info) 0315 { 0316 ElementWriter dummy(writer, QStringLiteral("image")); 0317 writer.writeAttribute(QStringLiteral("file"), info->fileName().relative()); 0318 if (info->label() != QFileInfo(info->fileName().relative()).completeBaseName()) 0319 writer.writeAttribute(QStringLiteral("label"), info->label()); 0320 if (!info->description().isEmpty()) 0321 writer.writeAttribute(QStringLiteral("description"), info->description()); 0322 0323 DB::ImageDate date = info->date(); 0324 Utilities::FastDateTime start = date.start(); 0325 Utilities::FastDateTime end = date.end(); 0326 0327 writer.writeAttribute(QStringLiteral("startDate"), stdDateTimeToString(start)); 0328 if (start != end) 0329 writer.writeAttribute(QStringLiteral("endDate"), stdDateTimeToString(end)); 0330 0331 if (info->angle() != 0) 0332 writer.writeAttribute(QStringLiteral("angle"), QString::number(info->angle())); 0333 writer.writeAttribute(QStringLiteral("md5sum"), info->MD5Sum().toHexString()); 0334 writer.writeAttribute(QStringLiteral("width"), QString::number(info->size().width())); 0335 writer.writeAttribute(QStringLiteral("height"), QString::number(info->size().height())); 0336 0337 if (info->rating() != -1) { 0338 writer.writeAttribute(QStringLiteral("rating"), QString::number(info->rating())); 0339 } 0340 0341 if (info->stackId()) { 0342 writer.writeAttribute(QStringLiteral("stackId"), QString::number(info->stackId())); 0343 writer.writeAttribute(QStringLiteral("stackOrder"), QString::number(info->stackOrder())); 0344 } 0345 0346 if (info->isVideo()) 0347 writer.writeAttribute(QStringLiteral("videoLength"), QString::number(info->videoLength())); 0348 0349 if (useCompressedFileFormat()) 0350 writeCategoriesCompressed(writer, info); 0351 else 0352 writeCategories(writer, info); 0353 } 0354 0355 QString DB::FileWriter::areaToString(QRect area) const 0356 { 0357 QStringList areaString; 0358 areaString.append(QString::number(area.x())); 0359 areaString.append(QString::number(area.y())); 0360 areaString.append(QString::number(area.width())); 0361 areaString.append(QString::number(area.height())); 0362 return areaString.join(QStringLiteral(" ")); 0363 } 0364 0365 void DB::FileWriter::writeCategories(QXmlStreamWriter &writer, const DB::ImageInfoPtr &info) 0366 { 0367 ElementWriter topElm(writer, QStringLiteral("options"), false); 0368 0369 QStringList grps = info->availableCategories(); 0370 // in contrast to CategoryCollection::categories, availableCategories is randomly sorted (since it is now a QHash) 0371 grps.sort(); 0372 for (const QString &name : grps) { 0373 if (!shouldSaveCategory(name)) 0374 continue; 0375 0376 ElementWriter categoryElm(writer, QStringLiteral("option"), false); 0377 0378 const auto itemsOfCategory = info->itemsOfCategory(name); 0379 QStringList items(itemsOfCategory.begin(), itemsOfCategory.end()); 0380 std::sort(items.begin(), items.end()); 0381 if (!items.isEmpty()) { 0382 topElm.writeStartElement(); 0383 categoryElm.writeStartElement(); 0384 writer.writeAttribute(QStringLiteral("name"), name); 0385 } 0386 0387 for (const QString &itemValue : qAsConst(items)) { 0388 ElementWriter dummy(writer, QStringLiteral("value")); 0389 writer.writeAttribute(QStringLiteral("value"), itemValue); 0390 0391 QRect area = info->areaForTag(name, itemValue); 0392 if (!area.isNull()) { 0393 writer.writeAttribute(QStringLiteral("area"), areaToString(area)); 0394 } 0395 } 0396 } 0397 } 0398 0399 void DB::FileWriter::writeCategoriesCompressed(QXmlStreamWriter &writer, const DB::ImageInfoPtr &info) 0400 { 0401 QMap<QString, QList<QPair<QString, QRect>>> positionedTags; 0402 0403 const QList<DB::CategoryPtr> categoryList = DB::ImageDB::instance()->categoryCollection()->categories(); 0404 for (const DB::CategoryPtr &category : categoryList) { 0405 QString categoryName = category->name(); 0406 0407 if (!shouldSaveCategory(categoryName)) 0408 continue; 0409 0410 const StringSet items = info->itemsOfCategory(categoryName); 0411 if (!items.empty()) { 0412 QStringList idList; 0413 0414 for (const QString &itemValue : items) { 0415 QRect area = info->areaForTag(categoryName, itemValue); 0416 0417 if (area.isValid()) { 0418 // Positioned tags can't be stored in the "fast" format 0419 // so we have to handle them separately 0420 positionedTags[categoryName] << QPair<QString, QRect>(itemValue, area); 0421 } else { 0422 int id = category->idForName(itemValue); 0423 idList.append(QString::number(id)); 0424 } 0425 } 0426 0427 // Possibly all ids of a category have area information, so only 0428 // write the category attribute if there are actually ids to write 0429 if (!idList.isEmpty()) { 0430 std::sort(idList.begin(), idList.end()); 0431 writer.writeAttribute(escape(categoryName), idList.join(QStringLiteral(","))); 0432 } 0433 } 0434 } 0435 0436 // Add a "readable" sub-element for the positioned tags 0437 // FIXME: can this be merged with the code in writeCategories()? 0438 if (!positionedTags.isEmpty()) { 0439 ElementWriter topElm(writer, QStringLiteral("options"), false); 0440 topElm.writeStartElement(); 0441 0442 QMapIterator<QString, QList<QPair<QString, QRect>>> categoryWithAreas(positionedTags); 0443 while (categoryWithAreas.hasNext()) { 0444 categoryWithAreas.next(); 0445 0446 ElementWriter categoryElm(writer, QStringLiteral("option"), false); 0447 categoryElm.writeStartElement(); 0448 writer.writeAttribute(QStringLiteral("name"), categoryWithAreas.key()); 0449 0450 QList<QPair<QString, QRect>> areas = categoryWithAreas.value(); 0451 std::sort(areas.begin(), areas.end(), 0452 [](QPair<QString, QRect> a, QPair<QString, QRect> b) { return a.first < b.first; }); 0453 for (const auto &positionedTag : qAsConst(areas)) { 0454 ElementWriter dummy(writer, QStringLiteral("value")); 0455 writer.writeAttribute(QStringLiteral("value"), positionedTag.first); 0456 writer.writeAttribute(QStringLiteral("area"), areaToString(positionedTag.second)); 0457 } 0458 } 0459 } 0460 } 0461 0462 bool DB::FileWriter::shouldSaveCategory(const QString &categoryName) const 0463 { 0464 // Profiling indicated that this function was a hotspot, so this cache improved saving speed with 25% 0465 static QHash<QString, bool> cache; 0466 if (cache.contains(categoryName)) 0467 return cache[categoryName]; 0468 0469 // A few bugs has shown up, where an invalid category name has crashed KPA. It therefore checks for such invalid names here. 0470 if (!m_db->m_categoryCollection.categoryForName(categoryName)) { 0471 qCWarning(DBLog, "Invalid category name: %s", qPrintable(categoryName)); 0472 cache.insert(categoryName, false); 0473 return false; 0474 } 0475 0476 const auto category = m_db->m_categoryCollection.categoryForName(categoryName).data(); 0477 Q_ASSERT(category); 0478 const bool shouldSave = category->shouldSave(); 0479 cache.insert(categoryName, shouldSave); 0480 return shouldSave; 0481 } 0482 0483 /** 0484 * @brief Escape problematic characters in a string that forms an XML attribute name. 0485 * 0486 * N.B.: Attribute values do not need to be escaped! 0487 * @see DB::FileReader::unescape 0488 * 0489 * @param str the string to be escaped 0490 * @return the escaped string 0491 */ 0492 QString DB::FileWriter::escape(const QString &str) 0493 { 0494 static bool hashUsesCompressedFormat = useCompressedFileFormat(); 0495 static QHash<QString, QString> s_cache; 0496 if (hashUsesCompressedFormat != useCompressedFileFormat()) 0497 s_cache.clear(); 0498 0499 if (s_cache.contains(str)) 0500 return s_cache[str]; 0501 0502 QString tmp(str); 0503 // Regex to match characters that are not allowed to start XML attribute names 0504 static const QRegExp rx(QStringLiteral("([^a-zA-Z0-9:_])")); 0505 int pos = 0; 0506 0507 // Encoding special characters if compressed XML is selected 0508 if (useCompressedFileFormat()) { 0509 while ((pos = rx.indexIn(tmp, pos)) != -1) { 0510 QString before = rx.cap(1); 0511 QString after = QString::asprintf("_.%0X", rx.cap(1).data()->toLatin1()); 0512 tmp.replace(pos, before.length(), after); 0513 pos += after.length(); 0514 } 0515 } else 0516 tmp.replace(QStringLiteral(" "), QStringLiteral("_")); 0517 s_cache.insert(str, tmp); 0518 return tmp; 0519 } 0520 0521 // vi:expandtab:tabstop=4 shiftwidth=4: