diff --git a/XMLDB/FileReader.cpp b/XMLDB/FileReader.cpp index 862f0a71..70492371 100644 --- a/XMLDB/FileReader.cpp +++ b/XMLDB/FileReader.cpp @@ -1,536 +1,544 @@ -/* Copyright (C) 2003-2015 Jesper K. Pedersen +/* Copyright (C) 2003-2019 The KPhotoAlbum Development Team This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; see the file COPYING. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ // Local includes #include "CompressFileInfo.h" #include "Database.h" #include "FileReader.h" #include "Logging.h" #include "XMLCategory.h" #include #include #include #include // KDE includes #include #include // Qt includes #include #include #include #include #include #include void XMLDB::FileReader::read( const QString& configFile ) { static QString versionString = QString::fromUtf8("version"); static QString compressedString = QString::fromUtf8("compressed"); ReaderPtr reader = readConfigFile( configFile ); ElementInfo info = reader->readNextStartOrStopElement(QString::fromUtf8("KPhotoAlbum")); if (!info.isStartToken) reader->complainStartElementExpected(QString::fromUtf8("KPhotoAlbum")); m_fileVersion = reader->attribute( versionString, QString::fromLatin1( "1" ) ).toInt(); if ( m_fileVersion > Database::fileVersion() ) { int ret = KMessageBox::warningContinueCancel( messageParent(), i18n("

The database file (index.xml) is from a newer version of KPhotoAlbum!

" "

Chances are you will be able to read this file, but when writing it back, " "information saved in the newer version will be lost

"), i18n("index.xml version mismatch"), KStandardGuiItem::cont(), KStandardGuiItem::cancel(), QString::fromLatin1( "checkDatabaseFileVersion" ) ); if (ret == KStandardGuiItem::Cancel) exit(-1); } setUseCompressedFileFormat( reader->attribute(compressedString).toInt() ); m_db->m_members.setLoading( true ); loadCategories( reader ); loadImages( reader ); loadBlockList( reader ); loadMemberGroups( reader ); //loadSettings(reader); m_db->m_members.setLoading( false ); checkIfImagesAreSorted(); checkIfAllImagesHaveSizeAttributes(); } void XMLDB::FileReader::createSpecialCategories() { // Setup the "Folder" category m_folderCategory = new XMLCategory(i18n("Folder"), QString::fromLatin1("folder"), DB::Category::TreeView, 32, false ); m_folderCategory->setType( DB::Category::FolderCategory ); // The folder category is not stored in the index.xml file, // but older versions of KPhotoAlbum stored a stub entry, which we need to remove first: if ( m_db->m_categoryCollection.categoryForName(m_folderCategory->name()) ) m_db->m_categoryCollection.removeCategory(m_folderCategory->name()); m_db->m_categoryCollection.addCategory( m_folderCategory ); dynamic_cast( m_folderCategory.data() )->setShouldSave( false ); // Setup the "Tokens" category DB::CategoryPtr tokenCat; if (m_fileVersion >= 7) { tokenCat = m_db->m_categoryCollection.categoryForSpecial( DB::Category::TokensCategory ); } else { // Before version 7, the "Tokens" category name wasn't stored to the settings. So ... // look for a literal "Tokens" category ... tokenCat = m_db->m_categoryCollection.categoryForName(QString::fromUtf8("Tokens")); if (!tokenCat) { // ... and a translated "Tokens" category if we don't have the literal one. tokenCat = m_db->m_categoryCollection.categoryForName(i18n("Tokens")); } if (tokenCat) { // in this case we need to give the tokens category its special meaning: m_db->m_categoryCollection.removeCategory(tokenCat->name()); tokenCat->setType(DB::Category::TokensCategory); m_db->m_categoryCollection.addCategory(tokenCat); } } if (! tokenCat) { // Create a new "Tokens" category tokenCat = new XMLCategory(i18n("Tokens"), QString::fromUtf8("tag"), DB::Category::TreeView, 32, true); tokenCat->setType(DB::Category::TokensCategory); m_db->m_categoryCollection.addCategory(tokenCat); } // KPhotoAlbum 2.2 did not write the tokens to the category section, // so unless we do this small trick they will not show up when importing. for (char ch = 'A'; ch <= 'Z'; ++ch) { tokenCat->addItem(QString::fromUtf8("%1").arg(QChar::fromLatin1(ch))); } // Setup the "Media Type" category DB::CategoryPtr mediaCat; mediaCat = new XMLCategory(i18n("Media Type"), QString::fromLatin1("view-categories"), DB::Category::TreeView, 32, false); mediaCat->addItem( i18n( "Image" ) ); mediaCat->addItem( i18n( "Video" ) ); mediaCat->setType( DB::Category::MediaTypeCategory ); dynamic_cast( mediaCat.data() )->setShouldSave( false ); // The media type is not stored in the media category, // but older versions of KPhotoAlbum stored a stub entry, which we need to remove first: if ( m_db->m_categoryCollection.categoryForName(mediaCat->name()) ) m_db->m_categoryCollection.removeCategory( mediaCat->name() ); m_db->m_categoryCollection.addCategory( mediaCat ); } void XMLDB::FileReader::loadCategories( ReaderPtr reader ) { static QString nameString = QString::fromUtf8("name"); static QString iconString = QString::fromUtf8("icon"); static QString viewTypeString = QString::fromUtf8("viewtype"); static QString showString = QString::fromUtf8("show"); static QString thumbnailSizeString = QString::fromUtf8("thumbnailsize"); static QString positionableString = QString::fromUtf8("positionable"); static QString metaString = QString::fromUtf8("meta"); static QString tokensString = QString::fromUtf8("tokens"); static QString valueString = QString::fromUtf8("value"); static QString idString = QString::fromUtf8("id"); static QString birthDateString = QString::fromUtf8("birthDate"); static QString categoriesString = QString::fromUtf8("Categories"); static QString categoryString = QString::fromUtf8("Category"); ElementInfo info = reader->readNextStartOrStopElement(categoriesString); if (!info.isStartToken) reader->complainStartElementExpected(categoriesString); while ( reader->readNextStartOrStopElement(categoryString).isStartToken) { const QString categoryName = unescape(reader->attribute(nameString)); if ( !categoryName.isNull() ) { // Read Category info QString icon = reader->attribute(iconString); DB::Category::ViewType type = (DB::Category::ViewType) reader->attribute( viewTypeString, QString::fromLatin1( "0" ) ).toInt(); int thumbnailSize = reader->attribute( thumbnailSizeString, QString::fromLatin1( "32" ) ).toInt(); bool show = (bool) reader->attribute( showString, QString::fromLatin1( "1" ) ).toInt(); bool positionable = (bool) reader->attribute( positionableString, QString::fromLatin1( "0" ) ).toInt(); bool tokensCat = reader->attribute(metaString) == tokensString; DB::CategoryPtr cat = m_db->m_categoryCollection.categoryForName( categoryName ); bool repairMode = false; if (cat) { int choice = KMessageBox::warningContinueCancel( messageParent(), i18n( "

Line %1, column %2: duplicate category '%3'

" "

Choose continue to ignore the duplicate category and try an automatic repair, " "or choose cancel to quit.

", reader->lineNumber(), reader->columnNumber(), categoryName ), i18n("Error in database file")); if ( choice == KMessageBox::Continue ) repairMode = true; else exit(-1); } else { cat = new XMLCategory( categoryName, icon, type, thumbnailSize, show, positionable ); if (tokensCat) cat->setType(DB::Category::TokensCategory); m_db->m_categoryCollection.addCategory( cat ); } // Read values QStringList items; while( reader->readNextStartOrStopElement(valueString).isStartToken) { QString value = reader->attribute(valueString); if ( reader->hasAttribute(idString) ) { int id = reader->attribute(idString).toInt(); static_cast(cat.data())->setIdMapping( value, id ); } if (reader->hasAttribute(birthDateString)) cat->setBirthDate(value,QDate::fromString(reader->attribute(birthDateString), Qt::ISODate)); items.append( value ); reader->readEndElement(); } if ( repairMode ) { // merge with duplicate category qCInfo(XMLDBLog) << "Repairing category " << categoryName << ": merging items " << cat->items() << " with " << items; items.append(cat->items()); items.removeDuplicates(); } cat->setItems( items ); } } createSpecialCategories(); if (m_fileVersion < 7) { KMessageBox::information( messageParent(), i18nc("Leave \"Folder\" and \"Media Type\" untranslated below, those will show up with " "these exact names. Thanks :-)", "

This version of KPhotoAlbum does not translate \"standard\" categories " "any more.

" "

This may mean that – if you use a locale other than English – some of your " "categories are now displayed in English.

" "

You can manually rename your categories any time and then save your database." "

" "

In some cases, you may get two additional empty categories, \"Folder\" and " "\"Media Type\". You can delete those.

"), i18n("Changed standard category names") ); } } void XMLDB::FileReader::loadImages( ReaderPtr reader ) { static QString fileString = QString::fromUtf8("file"); static QString imagesString = QString::fromUtf8("images"); static QString imageString = QString::fromUtf8("image"); ElementInfo info = reader->readNextStartOrStopElement(imagesString); if (!info.isStartToken) reader->complainStartElementExpected(imagesString); while (reader->readNextStartOrStopElement(imageString).isStartToken) { const QString fileNameStr = reader->attribute(fileString); if ( fileNameStr.isNull() ) { qCWarning(XMLDBLog, "Element did not contain a file attribute" ); return; } const DB::FileName dbFileName = DB::FileName::fromRelativePath(fileNameStr); DB::ImageInfoPtr info = load( dbFileName, reader ); m_db->m_images.append(info); m_db->m_md5map.insert( info->MD5Sum(), dbFileName ); } } void XMLDB::FileReader::loadBlockList( ReaderPtr reader ) { static QString fileString = QString::fromUtf8("file"); static QString blockListString = QString::fromUtf8("blocklist"); static QString blockString = QString::fromUtf8("block"); ElementInfo info = reader->peekNext(); if ( info.isStartToken && info.tokenName == blockListString ) { reader->readNextStartOrStopElement(blockListString); while (reader->readNextStartOrStopElement(blockString).isStartToken) { QString fileName = reader->attribute(fileString); if ( !fileName.isEmpty() ) m_db->m_blockList.insert(DB::FileName::fromRelativePath(fileName)); reader->readEndElement(); } } } void XMLDB::FileReader::loadMemberGroups( ReaderPtr reader ) { static QString categoryString = QString::fromUtf8("category"); static QString groupNameString = QString::fromUtf8("group-name"); static QString memberString = QString::fromUtf8("member"); static QString membersString = QString::fromUtf8("members"); static QString memberGroupsString = QString::fromUtf8("member-groups"); ElementInfo info = reader->peekNext(); if ( info.isStartToken && info.tokenName == memberGroupsString) { reader->readNextStartOrStopElement(memberGroupsString); while(reader->readNextStartOrStopElement(memberString).isStartToken) { QString category = reader->attribute(categoryString); QString group = reader->attribute(groupNameString); if ( reader->hasAttribute(memberString) ) { QString member = reader->attribute(memberString); m_db->m_members.addMemberToGroup( category, group, member ); } else { QStringList members = reader->attribute(membersString).split( QString::fromLatin1( "," ), QString::SkipEmptyParts ); Q_FOREACH( const QString &memberItem, members ) { DB::CategoryPtr catPtr = m_db->m_categoryCollection.categoryForName( category ); if (!catPtr) { // category was not declared in "Categories" qCWarning(XMLDBLog) << "File corruption in index.xml. Inserting missing category: " << category; catPtr = new XMLCategory(category, QString::fromUtf8("dialog-warning"), DB::Category::TreeView, 32, false); m_db->m_categoryCollection.addCategory( catPtr ); } XMLCategory* cat = static_cast( catPtr.data() ); QString member = cat->nameForId( memberItem.toInt() ); if (member.isNull()) continue; m_db->m_members.addMemberToGroup( category, group, member ); } if(members.size() == 0) { // Groups are stored even if they are empty, so we also have to read them. // With no members, the above for loop will not be executed. m_db->m_members.addGroup(category, group); } } reader->readEndElement(); } } } /* void XMLDB::FileReader::loadSettings(ReaderPtr reader) { static QString settingsString = QString::fromUtf8("settings"); static QString settingString = QString::fromUtf8("setting"); static QString keyString = QString::fromUtf8("key"); static QString valueString = QString::fromUtf8("value"); ElementInfo info = reader->peekNext(); if (info.isStartToken && info.tokenName == settingsString) { reader->readNextStartOrStopElement(settingString); while(reader->readNextStartOrStopElement(settingString).isStartToken) { if (reader->hasAttribute(keyString) && reader->hasAttribute(valueString)) { m_db->m_settings.insert(unescape(reader->attribute(keyString)), unescape(reader->attribute(valueString))); } else { qWarning() << "File corruption in index.xml. Setting either lacking a key or a " << "value attribute. Ignoring this entry."; } reader->readEndElement(); } } } */ void XMLDB::FileReader::checkIfImagesAreSorted() { if ( !KMessageBox::shouldBeShownContinue( QString::fromLatin1( "checkWhetherImagesAreSorted" ) ) ) return; QDateTime last( QDate( 1900, 1, 1 ) ); bool wrongOrder = false; for( DB::ImageInfoListIterator it = m_db->m_images.begin(); !wrongOrder && it != m_db->m_images.end(); ++it ) { if ( last > (*it)->date().start() && (*it)->date().start().isValid() ) wrongOrder = true; last = (*it)->date().start(); } if ( wrongOrder ) { KMessageBox::information( messageParent(), i18n("

Your images/videos are not sorted, which means that navigating using the date bar " "will only work suboptimally.

" "

In the Maintenance menu, you can find Display Images with Incomplete Dates " "which you can use to find the images that are missing date information.

" "

You can then select the images that you have reason to believe have a correct date " "in either their Exif data or on the file, and execute Maintenance->Read Exif Info " "to reread the information.

" "

Finally, once all images have their dates set, you can execute " "Maintenance->Sort All by Date & Time to sort them in the database.

"), i18n("Images/Videos Are Not Sorted"), QString::fromLatin1( "checkWhetherImagesAreSorted" ) ); } } void XMLDB::FileReader::checkIfAllImagesHaveSizeAttributes() { QTime time; time.start(); if ( !KMessageBox::shouldBeShownContinue( QString::fromLatin1( "checkWhetherAllImagesIncludesSize" ) ) ) return; if ( m_db->s_anyImageWithEmptySize ) { KMessageBox::information( messageParent(), i18n("

Not all the images in the database have information about image sizes; this is needed to " "get the best result in the thumbnail view. To fix this, simply go to the Maintenance menu, " "and first choose Remove All Thumbnails, and after that choose Build Thumbnails.

" "

Not doing so will result in extra space around images in the thumbnail view - that is all - so " "there is no urgency in doing it.

"), i18n("Not All Images Have Size Information"), QString::fromLatin1( "checkWhetherAllImagesIncludesSize" ) ); } } DB::ImageInfoPtr XMLDB::FileReader::load( const DB::FileName& fileName, ReaderPtr reader ) { DB::ImageInfoPtr info = XMLDB::Database::createImageInfo(fileName, reader, m_db); m_nextStackId = qMax( m_nextStackId, info->stackId() + 1 ); info->createFolderCategoryItem( m_folderCategory, m_db->m_members ); return info; } XMLDB::ReaderPtr XMLDB::FileReader::readConfigFile( const QString& configFile ) { ReaderPtr reader = ReaderPtr(new XmlReader); QFile file( configFile ); if ( !file.exists() ) { // Load a default setup QFile file(Utilities::locateDataFile(QString::fromLatin1("default-setup"))); if ( !file.open( QIODevice::ReadOnly ) ) { KMessageBox::information( messageParent(), i18n( "

KPhotoAlbum was unable to load a default setup, which indicates an installation error

" "

If you have installed KPhotoAlbum yourself, then you must remember to set the environment variable " "KDEDIRS, to point to the topmost installation directory.

" "

If you for example ran cmake with -DCMAKE_INSTALL_PREFIX=/usr/local/kde, then you must use the following " "environment variable setup (this example is for Bash and compatible shells):

" "

export KDEDIRS=/usr/local/kde

" "

In case you already have KDEDIRS set, simply append the string as if you where setting the PATH " "environment variable

"), i18n("No default setup file found") ); } else { QTextStream stream( &file ); stream.setCodec( QTextCodec::codecForName("UTF-8") ); QString str = stream.readAll(); // Replace the default setup's category and tag names with localized ones str = str.replace(QString::fromUtf8("People"), i18n("People")); str = str.replace(QString::fromUtf8("Places"), i18n("Places")); str = str.replace(QString::fromUtf8("Events"), i18n("Events")); str = str.replace(QString::fromUtf8("untagged"), i18n("untagged")); str = str.replace( QRegExp( QString::fromLatin1("imageDirectory=\"[^\"]*\"")), QString::fromLatin1("") ); str = str.replace( QRegExp( QString::fromLatin1("htmlBaseDir=\"[^\"]*\"")), QString::fromLatin1("") ); str = str.replace( QRegExp( QString::fromLatin1("htmlBaseURL=\"[^\"]*\"")), QString::fromLatin1("") ); reader->addData(str); } } else { if ( !file.open( QIODevice::ReadOnly ) ) { KMessageBox::error( messageParent(), i18n("Unable to open '%1' for reading", configFile ), i18n("Error Running Demo") ); exit(-1); } reader->addData(file.readAll()); #if 0 QString errMsg; int errLine; int errCol; if ( !doc.setContent( &file, false, &errMsg, &errLine, &errCol )) { file.close(); // If parsing index.xml fails let's see if we could use a backup instead Utilities::checkForBackupFile( configFile, i18n( "line %1 column %2 in file %3: %4", errLine , errCol , configFile , errMsg ) ); if ( !file.open( QIODevice::ReadOnly ) || ( !doc.setContent( &file, false, &errMsg, &errLine, &errCol ) ) ) { KMessageBox::error( messageParent(), i18n( "Failed to recover the backup: %1", errMsg ) ); exit(-1); } } #endif } // Now read the content of the file. #if 0 QDomElement top = doc.documentElement(); if ( top.isNull() ) { KMessageBox::error( messageParent(), i18n("Error in file %1: No elements found", configFile ) ); exit(-1); } if ( top.tagName().toLower() != QString::fromLatin1( "kphotoalbum" ) && top.tagName().toLower() != QString::fromLatin1( "kimdaba" ) ) { // KimDaBa compatibility KMessageBox::error( messageParent(), i18n("Error in file %1: expected 'KPhotoAlbum' as top element but found '%2'", configFile , top.tagName() ) ); exit(-1); } #endif file.close(); return reader; } +/** + * @brief Unescape a string used as an XML attribute name. + * + * @see XMLDB::FileWriter::escape + * + * @param str the string to be unescaped + * @return the unescaped string + */ QString XMLDB::FileReader::unescape( const QString& str ) { static QHash cache; if ( cache.contains(str) ) return cache[str]; QString tmp( str ); // Matches encoded characters in attribute names QRegExp rx( QString::fromLatin1( "(_.)([0-9A-F]{2})" ) ); int pos = 0; // Unencoding special characters if compressed XML is selected if ( useCompressedFileFormat() ) { while ( ( pos = rx.indexIn( tmp, pos ) ) != -1 ) { QString before = rx.cap( 1 ) + rx.cap( 2 ); QString after = QString::fromLatin1( QByteArray::fromHex( rx.cap( 2 ).toLocal8Bit() ) ); tmp.replace( pos, before.length(), after ); pos += after.length(); } } else tmp.replace( QString::fromLatin1( "_" ), QString::fromLatin1( " " ) ); cache.insert(str,tmp); return tmp; } // TODO(hzeller): DEPENDENCY This pulls in the whole MainWindow dependency into the database backend. QWidget *XMLDB::FileReader::messageParent() { return MainWindow::Window::theMainWindow(); } // vi:expandtab:tabstop=4 shiftwidth=4: diff --git a/XMLDB/FileWriter.cpp b/XMLDB/FileWriter.cpp index bcd63e62..f0a876f4 100644 --- a/XMLDB/FileWriter.cpp +++ b/XMLDB/FileWriter.cpp @@ -1,527 +1,532 @@ -/* Copyright (C) 2003-2014 Jesper K. Pedersen +/* Copyright (C) 2003-2019 The KPhotoAlbum Development Team This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; see the file COPYING. If not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ #include "FileWriter.h" #include "CompressFileInfo.h" #include "Database.h" #include "ElementWriter.h" #include "Logging.h" #include "NumberedBackup.h" #include "XMLCategory.h" #include #include #include #include #include #include #include #include #include // I've added this to provide anyone interested // with a quick and easy means to benchmark performance differences // between old and new save behaviour. // Note: in Qt4, saving files was deterministic "by accident"; with Qt5, we have to work for it // (mostly because QSet is randomized now) // FIXME(ZaJ): this should be removed "soon" (at latest by KPA 5.2.0) #define DETERMINISTIC_DBSAVE // print saving time: //#define BENCHMARK_FILEWRITER // // // // +++++++++++++++++++++++++++++++ REMEMBER ++++++++++++++++++++++++++++++++ // // // // // Update XMLDB::Database::fileVersion every time you update the file format! // // // // // // // // // (sorry for the noise, but it is really important :-) using Utilities::StringSet; void XMLDB::FileWriter::save( const QString& fileName, bool isAutoSave ) { setUseCompressedFileFormat( Settings::SettingsData::instance()->useCompressedIndexXML() ); if ( !isAutoSave ) NumberedBackup().makeNumberedBackup(); // prepare XML document for saving: m_db->m_categoryCollection.initIdMap(); QFile out(fileName + QString::fromLatin1(".tmp")); if ( !out.open(QIODevice::WriteOnly | QIODevice::Text)) { KMessageBox::sorry( messageParent(), i18n("

Could not save the image database to XML.

" "File %1 could not be opened because of the following error: %2" , out.fileName(), out.errorString() ) ); return; } QTime t; if (TimingLog().isDebugEnabled()) t.start(); QXmlStreamWriter writer(&out); writer.setAutoFormatting(true); writer.writeStartDocument(); { ElementWriter dummy(writer, QString::fromLatin1("KPhotoAlbum")); writer.writeAttribute( QString::fromLatin1( "version" ), QString::number(Database::fileVersion())); writer.writeAttribute( QString::fromLatin1( "compressed" ), QString::number(useCompressedFileFormat())); saveCategories( writer ); saveImages( writer ); saveBlockList( writer ); saveMemberGroups( writer ); //saveSettings(writer); } writer.writeEndDocument(); qCDebug(TimingLog) << "XMLDB::FileWriter::save(): Saving took" << t.elapsed() <<"ms"; // State: index.xml has previous DB version, index.xml.tmp has the current version. // original file can be safely deleted if ( ( ! QFile::remove( fileName ) ) && QFile::exists( fileName ) ) { KMessageBox::sorry( messageParent(), i18n("

Failed to remove old version of image database.

" "

Please try again or replace the file %1 with file %2 manually!

", fileName, out.fileName() ) ); return; } // State: index.xml doesn't exist, index.xml.tmp has the current version. if ( ! out.rename( fileName ) ) { KMessageBox::sorry( messageParent(), i18n("

Failed to move temporary XML file to permanent location.

" "

Please try again or rename file %1 to %2 manually!

", out.fileName(), fileName ) ); // State: index.xml.tmp has the current version. return; } // State: index.xml has the current version. } void XMLDB::FileWriter::saveCategories( QXmlStreamWriter& writer ) { QStringList categories = DB::ImageDB::instance()->categoryCollection()->categoryNames(); ElementWriter dummy(writer, QString::fromLatin1("Categories") ); DB::CategoryPtr tokensCategory = DB::ImageDB::instance()->categoryCollection()->categoryForSpecial( DB::Category::TokensCategory ); for (QString name : categories) { DB::CategoryPtr category = DB::ImageDB::instance()->categoryCollection()->categoryForName(name); if (! shouldSaveCategory(name)) { continue; } ElementWriter dummy(writer, QString::fromUtf8("Category")); writer.writeAttribute(QString::fromUtf8("name"), name); writer.writeAttribute(QString::fromUtf8("icon"), category->iconName()); writer.writeAttribute(QString::fromUtf8("show"), QString::number(category->doShow())); writer.writeAttribute(QString::fromUtf8("viewtype"), QString::number(category->viewType())); writer.writeAttribute(QString::fromUtf8("thumbnailsize"), QString::number(category->thumbnailSize())); writer.writeAttribute(QString::fromUtf8("positionable"), QString::number(category->positionable())); if (category == tokensCategory) { writer.writeAttribute(QString::fromUtf8("meta"),QString::fromUtf8("tokens")); } // FIXME (l3u): // Correct me if I'm wrong, but we don't need this, as the tags used as groups are // added to the respective category anyway when they're created, so there's no need to // re-add them here. Apart from this, adding an empty group (one without members) does // add an empty tag ("") doing so. /* QStringList list = Utilities::mergeListsUniqly(category->items(), m_db->_members.groups(name)); */ Q_FOREACH(const QString &tagName, category->items()) { ElementWriter dummy( writer, QString::fromLatin1("value") ); writer.writeAttribute( QString::fromLatin1("value"), tagName ); writer.writeAttribute( QString::fromLatin1( "id" ), QString::number(static_cast( category.data() )->idForName( tagName ) )); QDate birthDate = category->birthDate(tagName); if (!birthDate.isNull()) writer.writeAttribute( QString::fromUtf8("birthDate"), birthDate.toString(Qt::ISODate) ); } } } void XMLDB::FileWriter::saveImages( QXmlStreamWriter& writer ) { DB::ImageInfoList list = m_db->m_images; // Copy files from clipboard to end of overview, so we don't loose them Q_FOREACH(const DB::ImageInfoPtr &infoPtr, m_db->m_clipboard) { list.append(infoPtr); } { ElementWriter dummy(writer, QString::fromLatin1( "images" ) ); Q_FOREACH(const DB::ImageInfoPtr &infoPtr, list) { save( writer, infoPtr ); } } } void XMLDB::FileWriter::saveBlockList( QXmlStreamWriter& writer ) { ElementWriter dummy( writer, QString::fromLatin1( "blocklist" ) ); #ifdef DETERMINISTIC_DBSAVE QList blockList = m_db->m_blockList.toList(); // sort blocklist to get diffable files std::sort(blockList.begin(), blockList.end()); #else QSet blockList = m_db->m_blockList; #endif Q_FOREACH(const DB::FileName &block, blockList) { ElementWriter dummy( writer, QString::fromLatin1( "block" ) ); writer.writeAttribute( QString::fromLatin1( "file" ), block.relative() ); } } void XMLDB::FileWriter::saveMemberGroups( QXmlStreamWriter& writer ) { if ( m_db->m_members.isEmpty() ) return; ElementWriter dummy( writer, QString::fromLatin1( "member-groups" ) ); for( QMap< QString,QMap >::ConstIterator memberMapIt= m_db->m_members.memberMap().constBegin(); memberMapIt != m_db->m_members.memberMap().constEnd(); ++memberMapIt ) { const QString categoryName = memberMapIt.key(); // FIXME (l3u): This can happen when an empty sub-category (group) is present. // Would be fine to fix the reason why this happens in the first place. if (categoryName.isEmpty()) { continue; } if ( !shouldSaveCategory( categoryName ) ) continue; QMap groupMap = memberMapIt.value(); for( QMap::ConstIterator groupMapIt= groupMap.constBegin(); groupMapIt != groupMap.constEnd(); ++groupMapIt ) { // FIXME (l3u): This can happen when an empty sub-category (group) is present. // Would be fine to fix the reason why this happens in the first place. if (groupMapIt.key().isEmpty()) { continue; } if ( useCompressedFileFormat() ) { StringSet members = groupMapIt.value(); ElementWriter dummy( writer, QString::fromLatin1( "member" ) ); writer.writeAttribute( QString::fromLatin1( "category" ), categoryName ); writer.writeAttribute( QString::fromLatin1( "group-name" ), groupMapIt.key() ); QStringList idList; Q_FOREACH(const QString& member, members) { DB::CategoryPtr catPtr = m_db->m_categoryCollection.categoryForName( categoryName ); XMLCategory* category = static_cast( catPtr.data() ); if (category->idForName(member)==0) qCWarning(XMLDBLog) << "Member" << member << "in group" << categoryName << "->" << groupMapIt.key() << "has no id!"; idList.append( QString::number( category->idForName( member ) ) ); } #ifdef DETERMINISTIC_DBSAVE std::sort(idList.begin(), idList.end()); #endif writer.writeAttribute( QString::fromLatin1( "members" ), idList.join( QString::fromLatin1( "," ) ) ); } else { #ifdef DETERMINISTIC_DBSAVE QStringList members = groupMapIt.value().toList(); std::sort(members.begin(), members.end()); #else QSet members = groupMapIt.value(); #endif Q_FOREACH(const QString& member, members) { ElementWriter dummy( writer, QString::fromLatin1( "member" ) ); writer.writeAttribute( QString::fromLatin1( "category" ), memberMapIt.key() ); writer.writeAttribute( QString::fromLatin1( "group-name" ), groupMapIt.key() ); writer.writeAttribute( QString::fromLatin1( "member" ), member ); } // Add an entry even if the group is empty // (this is not necessary for the compressed format) if (members.size() == 0) { ElementWriter dummy(writer, QString::fromLatin1("member")); writer.writeAttribute(QString::fromLatin1("category"), memberMapIt.key()); writer.writeAttribute(QString::fromLatin1("group-name"), groupMapIt.key()); } } } } } /* Perhaps, we may need this later ;-) void XMLDB::FileWriter::saveSettings(QXmlStreamWriter& writer) { static QString settingsString = QString::fromUtf8("settings"); static QString settingString = QString::fromUtf8("setting"); static QString keyString = QString::fromUtf8("key"); static QString valueString = QString::fromUtf8("value"); ElementWriter dummy(writer, settingsString); QMap settings; // For testing settings.insert(QString::fromUtf8("tokensCategory"), QString::fromUtf8("Tokens")); settings.insert(QString::fromUtf8("untaggedCategory"), QString::fromUtf8("Events")); settings.insert(QString::fromUtf8("untaggedTag"), QString::fromUtf8("untagged")); QMapIterator settingsIterator(settings); while (settingsIterator.hasNext()) { ElementWriter dummy(writer, settingString); settingsIterator.next(); writer.writeAttribute(keyString, escape(settingsIterator.key())); writer.writeAttribute(valueString, escape(settingsIterator.value())); } } */ void XMLDB::FileWriter::save( QXmlStreamWriter& writer, const DB::ImageInfoPtr& info ) { ElementWriter dummy( writer, QString::fromLatin1("image") ); writer.writeAttribute( QString::fromLatin1("file"), info->fileName().relative() ); if ( info->label() != QFileInfo(info->fileName().relative()).completeBaseName() ) writer.writeAttribute( QString::fromLatin1("label"), info->label() ); if ( !info->description().isEmpty() ) writer.writeAttribute( QString::fromLatin1("description"), info->description() ); DB::ImageDate date = info->date(); QDateTime start = date.start(); QDateTime end = date.end(); writer.writeAttribute( QString::fromLatin1( "startDate" ), start.toString(Qt::ISODate) ); if ( start != end ) writer.writeAttribute( QString::fromLatin1( "endDate" ), end.toString(Qt::ISODate) ); if ( info->angle() != 0 ) writer.writeAttribute( QString::fromLatin1("angle"), QString::number(info->angle())); writer.writeAttribute( QString::fromLatin1( "md5sum" ), info->MD5Sum().toHexString() ); writer.writeAttribute( QString::fromLatin1( "width" ), QString::number(info->size().width())); writer.writeAttribute( QString::fromLatin1( "height" ), QString::number(info->size().height())); if ( info->rating() != -1 ) { writer.writeAttribute( QString::fromLatin1("rating"), QString::number(info->rating())); } if ( info->stackId() ) { writer.writeAttribute( QString::fromLatin1("stackId"), QString::number(info->stackId())); writer.writeAttribute( QString::fromLatin1("stackOrder"), QString::number(info->stackOrder())); } if ( info->isVideo() ) writer.writeAttribute( QLatin1String("videoLength"), QString::number(info->videoLength())); if ( useCompressedFileFormat() ) writeCategoriesCompressed( writer, info ); else writeCategories( writer, info ); } QString XMLDB::FileWriter::areaToString(QRect area) const { QStringList areaString; areaString.append( QString::number(area.x()) ); areaString.append( QString::number(area.y()) ); areaString.append( QString::number(area.width()) ); areaString.append( QString::number(area.height()) ); return areaString.join( QString::fromLatin1(" ") ); } void XMLDB::FileWriter::writeCategories( QXmlStreamWriter& writer, const DB::ImageInfoPtr& info ) { ElementWriter topElm(writer, QString::fromLatin1("options"), false ); QStringList grps = info->availableCategories(); Q_FOREACH(const QString &name, grps) { if ( !shouldSaveCategory( name ) ) continue; ElementWriter categoryElm(writer, QString::fromLatin1("option"), false ); #ifdef DETERMINISTIC_DBSAVE QStringList items = info->itemsOfCategory(name).toList(); std::sort(items.begin(), items.end()); #else QSet items = info->itemsOfCategory(name); #endif if ( !items.isEmpty() ) { topElm.writeStartElement(); categoryElm.writeStartElement(); writer.writeAttribute( QString::fromLatin1("name"), name ); } Q_FOREACH(const QString& itemValue, items) { ElementWriter dummy( writer, QString::fromLatin1("value") ); writer.writeAttribute( QString::fromLatin1("value"), itemValue ); QRect area = info->areaForTag(name, itemValue); if ( ! area.isNull() ) { writer.writeAttribute(QString::fromLatin1("area"), areaToString(area)); } } } } void XMLDB::FileWriter::writeCategoriesCompressed( QXmlStreamWriter& writer, const DB::ImageInfoPtr& info ) { QMap>> positionedTags; QList categoryList = DB::ImageDB::instance()->categoryCollection()->categories(); Q_FOREACH(const DB::CategoryPtr &category, categoryList) { QString categoryName = category->name(); if ( !shouldSaveCategory( categoryName ) ) continue; StringSet items = info->itemsOfCategory(categoryName); if ( !items.empty() ) { QStringList idList; Q_FOREACH(const QString &itemValue, items) { QRect area = info->areaForTag(categoryName, itemValue); if ( area.isValid() ) { // Positioned tags can't be stored in the "fast" format // so we have to handle them separately positionedTags[categoryName] << QPair(itemValue, area); } else { int id = static_cast(category.data())->idForName(itemValue); idList.append( QString::number( id ) ); } } // Possibly all ids of a category have area information, so only // write the category attribute if there are actually ids to write if ( !idList.isEmpty() ) { #ifdef DETERMINISTIC_DBSAVE std::sort(idList.begin(), idList.end()); #endif writer.writeAttribute( escape( categoryName ), idList.join( QString::fromLatin1( "," ) ) ); } } } // Add a "readable" sub-element for the positioned tags // FIXME: can this be merged with the code in writeCategories()? if ( ! positionedTags.isEmpty() ) { ElementWriter topElm( writer, QString::fromLatin1("options"), false ); topElm.writeStartElement(); QMapIterator>> categoryWithAreas(positionedTags); while (categoryWithAreas.hasNext()) { categoryWithAreas.next(); ElementWriter categoryElm( writer, QString::fromLatin1("option"), false ); categoryElm.writeStartElement(); writer.writeAttribute( QString::fromLatin1("name"), categoryWithAreas.key() ); QList> areas = categoryWithAreas.value(); std::sort(areas.begin(),areas.end(), [](QPair a, QPair b) { return a.first < b.first; } ); Q_FOREACH( const auto &positionedTag, areas) { ElementWriter dummy( writer, QString::fromLatin1("value") ); writer.writeAttribute( QString::fromLatin1("value"), positionedTag.first ); writer.writeAttribute( QString::fromLatin1("area"), areaToString(positionedTag.second) ); } } } } bool XMLDB::FileWriter::shouldSaveCategory( const QString& categoryName ) const { // Profiling indicated that this function was a hotspot, so this cache improved saving speed with 25% static QHash cache; if ( cache.contains(categoryName)) return cache[categoryName]; // A few bugs has shown up, where an invalid category name has crashed KPA. It therefore checks for such invalid names here. if ( !m_db->m_categoryCollection.categoryForName( categoryName ) ) { qCWarning(XMLDBLog,"Invalid category name: %s", qPrintable(categoryName)); cache.insert(categoryName,false); return false; } const bool shouldSave = dynamic_cast( m_db->m_categoryCollection.categoryForName( categoryName ).data() )->shouldSave(); cache.insert(categoryName,shouldSave); return shouldSave; } /** - * Escape problematic characters in a string that forms an XML attribute name. + * @brief Escape problematic characters in a string that forms an XML attribute name. + * * N.B.: Attribute values do not need to be escaped! + * @see XMLDB::FileReader::unescape + * + * @param str the string to be escaped + * @return the escaped string */ QString XMLDB::FileWriter::escape( const QString& str ) { static QHash cache; if ( cache.contains(str) ) return cache[str]; QString tmp( str ); // Regex to match characters that are not allowed to start XML attribute names const QRegExp rx( QString::fromLatin1( "([^a-zA-Z0-9:_])" ) ); int pos = 0; // Encoding special characters if compressed XML is selected if ( useCompressedFileFormat() ) { while ( ( pos = rx.indexIn( tmp, pos ) ) != -1 ) { QString before = rx.cap( 1 ); QString after; after.sprintf( "_.%0X", rx.cap( 1 ).data()->toLatin1()); tmp.replace( pos, before.length(), after); pos += after.length(); } } else tmp.replace( QString::fromLatin1( " " ), QString::fromLatin1( "_" ) ); cache.insert(str,tmp); return tmp; } // TODO(hzeller): DEPENDENCY This pulls in the whole MainWindow dependency into the database backend. QWidget *XMLDB::FileWriter::messageParent() { return MainWindow::Window::theMainWindow(); } // vi:expandtab:tabstop=4 shiftwidth=4: diff --git a/documentation/database-layout.md b/documentation/database-layout.md index 451adfe7..5b145039 100644 --- a/documentation/database-layout.md +++ b/documentation/database-layout.md @@ -1,473 +1,483 @@ Image database overview for KPhotoAlbum {#database-layout} ======================================= Concepts -------- ### Fuzzy Dates ### KPhotoAlbum has the concept of fuzzy dates (or date intervals), which are defined by a start date and an end date (both include a timestamp). This helps for photos which have been digitized from an analog medium. When the exact timestamp is known, startDate equals endDate. ### Directory structure ### All images are expected to be located below a common root folder. The root folder is the one containing the index.xml database file. All file names in the index.xml file are relative to the root folder. ### Tags ### Tags (sometimes called Categories in KPhotoAlbum) are arranged in multiple independent hierarchies, i.e. there is no common root for all tags. Tag hierarchies are organized as DAGs (directed acyclic graphs). ### Additional metadata ### Exif information is stored in an sqlite database called `exif-info.db` in the image root folder. If the exif database is removed, it can be recreated from the image files. ### Thunmbnails ### Thumbnails are stored in packed form in `.thumbnails` in the image root folder. Thumbnails can be recreated from the image files. The packed format is much more efficient in terms of I/O than storing the thumbnails as individual image files in the filesystem. The thumbnails themselves are stored in JPEG format, packed into 32 MB container files named `thumb-`, with n starting from 0 and incrementing as needed. The size of the JPEG images is determined by the user's configuration choice. The choice of 32 MB is arbitrary, but it combines good I/O efficiency (many thumbnails per file and the ability to stream thumbnails efficiently) with backup efficiency (not modifying very large files constantly). There is no header, delimiter, or descriptor for the thumbnails in the container files; they require the index described below to be of use. Additionally, an index file named `thumbnailindex` contains an index allowing KPhotoAlbum to quickly locate the thumbnail for any given file. The thumbnailindex file is stored in binary form as implemented by QDataStream, as depicted below. The thumbnailindex cannot be regenerated from the thumbnail containers. ``` thumbnailindex | +-Header | +-File version (int, currently 4) | +-Current file (file index of last written thumbnail) being written to (int) | +-Current offset into current file, in bytes (int) | +-Total number of thumbnails indexed (int) | +-images | +-image | +-relative pathname (QString) | +-file index (int) | +-file offset (int) | +-thumbnail size (int) ``` index.xml --------- Below is a visualization of the DOM-Tree of the index.xml file. Attributes are within parenthesis, comments in square brackets. ### Version 3 ### Used in KPA v4.4 (and in KPA v4.5, if positionable tags are not used). ``` KPhotoAlbum | (version=3,compressed=1) | +-Categories | +-Category (name,icon,show,viewtype,thumbnailsize) | +-value (value, id) | +-images | +-image | (file, label, startDate, endDate, angle, md5sum, width, height) | (desctiption, stackId, stackOrder, rating, videoLength) [optional] | (#Categories.Category.name#=#Categories.Category.value.id#) [optional] | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,members) ``` ``` KPhotoAlbum | (version=3,compressed=0) | +-Categories | +-Category (name,icon,show,viewtype,thumbnailsize) | +-value (value, id) | +-images | +-image | (file, label, startDate, endDate, angle, md5sum, width, height) | (desctiption, stackId, stackOrder, rating, videoLength) [optional] | +-options | +-option(name=#Categories.Category.name#) | +-value(value=#Categories.Category.value.value#) | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,member) ``` ### Version 4 ### Used in KPA v4.5. ``` KPhotoAlbum | (version=4,compressed=1) | +-Categories | +-Category (name,icon,show,viewtype,thumbnailsize,positionable) | +-value (value, id) | +-images | +-image | (file, label, startDate, endDate, angle, md5sum, width, height) | (description, stackId, stackOrder, rating, videoLength) [optional] | (#Categories.Category.name#=#Categories.Category.value.id#) [optional] | +-options | +-option(name=#Categories.Category.name#) | +-value(value=#Categories.Category.value.value#, area="x y w h") | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,members) ``` ``` KPhotoAlbum | (version=4,compressed=0) | +-Categories | +-Category (name,icon,show,viewtype,thumbnailsize,positionable) | +-value (value, id) | +-images | +-image | (file, label, startDate, endDate, angle, md5sum, width, height) | (description, stackId, stackOrder, rating, videoLength) [optional] | +-options | +-option(name=#Categories.Category.name#) | +-value(value=#Categories.Category.value.value#, area="x y w h") | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,member) ``` #### Differences to version 3 #### * Tags can be positionable, i.e. the ```images.image.options.option.value``` elements may have an additional attribute ```area```. * In the compressed format, ```images.image``` tags may have sub-elements ```options.option.value```. This format is used only for category values when an area attribute is present. ### Version 5 ### Not used in an official release. ``` KPhotoAlbum | (version=5,compressed=1) | +-Categories | +-Category (name,icon,show,viewtype,thumbnailsize,positionable) | +-value | (value, id) | (birthDate) [optional] | +-images | +-image | (file, label, startDate, endDate, angle, md5sum, width, height) | (description, stackId, stackOrder, rating, videoLength) [optional] | (#Categories.Category.name#=#Categories.Category.value.id#) [optional] | +-options | +-option(name=#Categories.Category.name#) | +-value(value=#Categories.Category.value.value#, area="x y w h") | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,members) ``` ``` KPhotoAlbum | (version=5,compressed=0) | +-Categories | +-Category (name,icon,show,viewtype,thumbnailsize,positionable) | +-value | (value, id) | (birthDate) [optional] | +-images | +-image | (file, label, startDate, endDate, angle, md5sum, width, height) | (description, stackId, stackOrder, rating, videoLength) [optional] | +-options | +-option(name=#Categories.Category.name#) | +-value(value=#Categories.Category.value.value#, area="x y w h") | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,member) ``` #### Differences to version 4 #### * ```Categories.Category.value``` has an optional attribute ```birthDate``` ### Version 6 ### Used in KPA v4.6. Same structure as version 5. #### Differences to version 5 #### * The legacy categories Keywords, Persons and Locations are not handled special any more. Upon upgrade from an older version, "Persons" is renamed to "People", and "Locations" is renamed to "Places". * Older versions of KPhotoAlbum stored the standard categories (People, Places, Events, Folder, Tokens, Media Type and Keywords; those have a translation) as their respective localized versions. This lead to several problems if a non-English locale was used and has been fixed in v4.6. Along with this update, it was also necessary to move all thumbnails in the CategoryImages directory refering to the old names and fix the respective category names in kphotoalbumrc. * The GPS related image tags (gpsAlt, gpsLat, gpsLon and gpsPrec) have been removed and are now superseded by storing GPS data in the EXIF database. ### Version 7 ### Used in KPA v4.7 ``` KPhotoAlbum | (version=7, compressed=1) | +-Categories | +-Category | (name, icon, show, viewtype, thumbnailsize, positionable) | (meta) [optional] | +-value | (value, id) | (birthDate) [optional] | +-images | +-image | (file, label, startDate, endDate, angle, md5sum, width, height) | (description, stackId, stackOrder, rating, videoLength) [optional] | (#Categories.Category.name#=#Categories.Category.value.id#) [optional] | +-options | +-option(name=#Categories.Category.name#) | +-value(value=#Categories.Category.value.value#, area="x y w h") | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,members) ``` ``` KPhotoAlbum | (version=7, compressed=0) | +-Categories | +-Category | (name, icon, show, viewtype, thumbnailsize, positionable) | (meta) [optional] | +-value | (value, id) | (birthDate) [optional] | +-images | +-image | (file, label, startDate, endDate, angle, md5sum, width, height) | (description, stackId, stackOrder, rating, videoLength) [optional] | +-options | +-option(name=#Categories.Category.name#) | +-value(value=#Categories.Category.value.value#, area="x y w h") | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,member) ``` #### Differences to version 6 #### The concept of translatable "standard" categories led to a lot of problems when users started KPA with different locales. Some of them simply can't be solved, so we decided to remove translatable category names. Now, each category is stored with it's literal name. Added an additional optional "meta" attribute to the Category-tag, so that the "Tokens" category (a "special" category like "Folder", but stored in the database and thus causing the same translation problems like the old "standard" categories) can be marked as such and does not need to have a fixed name anymore. ### Version 8 ### Used in KPA v5.4 ``` KPhotoAlbum | (version=8, compressed=1) | +-Categories | +-Category | (name, icon, show, viewtype, thumbnailsize, positionable) | (meta) [optional] | +-value | (value, id) | (birthDate) [optional] | +-images | +-image | (file, startDate, md5sum, width, height) | (angle, description, endDate, label, rating, stackId, stackOrder, videoLength) [optional] | (#Categories.Category.name#=#Categories.Category.value.id#) [optional] | +-options | +-option(name=#Categories.Category.name#) | +-value(value=#Categories.Category.value.value#, area="x y w h") | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,members) ``` ``` KPhotoAlbum | (version=8, compressed=0) | +-Categories | +-Category | (name, icon, show, viewtype, thumbnailsize, positionable) | (meta) [optional] | +-value | (value, id) | (birthDate) [optional] | +-images | +-image | (file, startDate, md5sum, width, height) | (angle, description, endDate, label, rating, stackId, stackOrder, videoLength) [optional] | +-options | +-option(name=#Categories.Category.name#) | +-value(value=#Categories.Category.value.value#, area="x y w h") | +-blocklist | +-block (file) | +-member-groups +-member (category,group-name,member) ``` #### Differences to version 7 #### * ```images.image.angle``` is only saved when it differs from the default angle (0) * ```images.image.endDate``` is only saved when it differs from the start date * ```images.image.label``` is only saved when it differs from the default label ### Attribute values explained ### * blocklist - block + ```file``` Relative filename to ignore. * Categories - Category + ```icon```
XDG desktop icon name + ```name```
Category name + ```show```
```0|1``` - hide or show category in the viewer. + ```thumbnailsize```
Category-thumbnail size in pixel. + ```viewtype```
Appearance of list views in the browser. ```TreeView=0, ThumbedTreeView=1, IconView=2, ThumbedIconView=3``` + ```positionable``` (since version=4 / KPA v4.5)
```0|1``` - indicates whether this category can contain areas (positioned tags) or not. + ```meta``` (since version=7 / KPA v5.7)
Meta information that holds an unique id for special categories (so that they can be tracked when they are renamed for localization). + value * ```id```
Numerical tag id, unique within each Category. * ```value```
Tag name. * ```birthDate``` (since version=5 / KPA v4.6)
Birthdate (```yyyy-mm-dd```) of a person (but allowed on all categories). Is used to display the age of a person on an image. * images - image + ```angle```
Image rotation in degrees; between 0 and 359. + ```description```
Description field; Text. + ```endDate```
End date of the image (see fuzzy dates) (```yyyy-mm-dd[Thh:mm:ss]```, second optional part starts with uppercase 'T') + ```file```
Relative path to the image file. + ```gpsAlt``` (since KPA 3.1, deprecated in version=6 / KPA v4.6)
GPS altitude data, double. + ```gpsLat``` (since KPA 3.1, deprecated in version=6 / KPA v4.6)
GPS latitude data, double. + ```gpsLon``` (since KPA 3.1, deprecated in version=6 / KPA v4.6)
GPS longitude data, double. + ```gpsPrec``` (since KPA 3.1, deprecated in version=6 / KPA v4.6)
GPS precision data, integer (-1 for "no precision data"). + ```heigth```
Image height in pixel. + ```label```
Textual label assigned to the image + ```md5sum```
MD5 sum of the image file. + options * option - ```name```
Category name; matches one of ```Categories.Category.name``` - value + ```value```
Tag name; matches one of ```Categories.Category.value.value``` + ```area``` (since version=4 / KPA 4.5)
Positional information for the tag. X,Y (upper left corner), width, height; all values in pixel. + ```rating``` (since KPA 3.1)
Integer rating ("stars"), between 0 and 10. + ```stackId``` (since KPA 3.1)
Numerical stack ID; images with the same stackId are displayed as an image stack. Stack ID starts with 1. + ```stackOrder``` (since KPA 3.1)
Image position within a stack; only valid when stackId is set.
Unique within the same stack. Stack order starts with 1. + ```startDate```
Start date of the image (see fuzzy dates) (```yyyy-mm-dd[Thh:mm:ss]```, second optional part starts with uppercase 'T') + ```videoLength```
Length of the video in seconds; -1 if length is not known. Only applicable to video files. + ```width```
Image width in pixel. * member-groups - member + ```category```
Category name; matches one of ```Categories.Category.name``` + ```group-name```
Name of the group, may equal a Tag name and is usually displayed like a Tag name. + ```member``` (uncompressed format)
A single tag name. + ```members``` (compressed format)
Numerical tag ids, separated by comma. + +#### Encoding of category names #### + +In the compressed format, category names are used as attributes to the images. +In this context, the allowed character set is restricted by the rules for [XML attribute syntax](https://www.w3.org/TR/xml/#NT-NameStartChar), +and category names therefore need to be escaped. + +The details for character escaping can be seen here: + * [XMLDB::FileWriter::escape()](@ref XMLDB::FileWriter::escape) + * [XMLDB::FileReader::unescape()](@ref XMLDB::FileReader::unescape)