File indexing completed on 2024-04-28 13:20:55

0001 /*
0002  *   SPDX-FileCopyrightText: 2003 Waldo Bastian <bastian@kde.org>
0003  *
0004  *   SPDX-License-Identifier: GPL-2.0-only
0005  *
0006  */
0007 
0008 #include "menufile.h"
0009 
0010 #include <QDir>
0011 #include <QFile>
0012 #include <QFileInfo>
0013 #include <QRegularExpression>
0014 #include <QStandardPaths>
0015 #include <QTextStream>
0016 
0017 #include "kmenuedit_debug.h"
0018 #include <KLocalizedString>
0019 
0020 const QString MenuFile::MF_MENU = QStringLiteral("Menu");
0021 const QString MenuFile::MF_PUBLIC_ID = QStringLiteral("-//freedesktop//DTD Menu 1.0//EN");
0022 const QString MenuFile::MF_SYSTEM_ID = QStringLiteral("http://www.freedesktop.org/standards/menu-spec/1.0/menu.dtd");
0023 const QString MenuFile::MF_NAME = QStringLiteral("Name");
0024 const QString MenuFile::MF_INCLUDE = QStringLiteral("Include");
0025 const QString MenuFile::MF_EXCLUDE = QStringLiteral("Exclude");
0026 const QString MenuFile::MF_FILENAME = QStringLiteral("Filename");
0027 const QString MenuFile::MF_DELETED = QStringLiteral("Deleted");
0028 const QString MenuFile::MF_NOTDELETED = QStringLiteral("NotDeleted");
0029 const QString MenuFile::MF_MOVE = QStringLiteral("Move");
0030 const QString MenuFile::MF_OLD = QStringLiteral("Old");
0031 const QString MenuFile::MF_NEW = QStringLiteral("New");
0032 const QString MenuFile::MF_DIRECTORY = QStringLiteral("Directory");
0033 const QString MenuFile::MF_LAYOUT = QStringLiteral("Layout");
0034 const QString MenuFile::MF_MENUNAME = QStringLiteral("Menuname");
0035 const QString MenuFile::MF_SEPARATOR = QStringLiteral("Separator");
0036 const QString MenuFile::MF_MERGE = QStringLiteral("Merge");
0037 
0038 MenuFile::MenuFile(const QString &file)
0039     : m_fileName(file)
0040     , m_bDirty(false)
0041 {
0042     load();
0043 }
0044 
0045 MenuFile::~MenuFile()
0046 {
0047 }
0048 
0049 bool MenuFile::load()
0050 {
0051     if (m_fileName.isEmpty()) {
0052         return false;
0053     }
0054 
0055     QFile file(m_fileName);
0056     if (!file.open(QIODevice::ReadOnly)) {
0057         if (file.exists()) {
0058             qCWarning(KMENUEDIT_LOG) << "Could not read " << m_fileName;
0059         }
0060         create();
0061         return false;
0062     }
0063 
0064     QString errorMsg;
0065     int errorRow;
0066     int errorCol;
0067     if (!m_doc.setContent(&file, &errorMsg, &errorRow, &errorCol)) {
0068         qCWarning(KMENUEDIT_LOG) << "Parse error in " << m_fileName << ", line " << errorRow << ", col " << errorCol << ": " << errorMsg;
0069         file.close();
0070         create();
0071         return false;
0072     }
0073     file.close();
0074 
0075     return true;
0076 }
0077 
0078 void MenuFile::create()
0079 {
0080     QDomImplementation impl;
0081     QDomDocumentType docType = impl.createDocumentType(MF_MENU, MF_PUBLIC_ID, MF_SYSTEM_ID);
0082     m_doc = impl.createDocument(QString(), MF_MENU, docType);
0083 }
0084 
0085 bool MenuFile::save()
0086 {
0087     QFile file(m_fileName);
0088     // create directory if it doesn't exist
0089     QFileInfo info(file);
0090     info.dir().mkpath(QStringLiteral("."));
0091 
0092     if (!file.open(QIODevice::WriteOnly)) {
0093         qCWarning(KMENUEDIT_LOG) << "Could not write " << m_fileName;
0094         m_error = i18n("Could not write to %1", m_fileName);
0095         return false;
0096     }
0097     QTextStream stream(&file);
0098 #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
0099     stream.setCodec("UTF-8");
0100 #endif
0101 
0102     stream << m_doc.toString();
0103 
0104     file.close();
0105 
0106     if (file.error() != QFile::NoError) {
0107         qCWarning(KMENUEDIT_LOG) << "Could not close " << m_fileName;
0108         m_error = i18n("Could not write to %1", m_fileName);
0109         return false;
0110     }
0111 
0112     m_bDirty = false;
0113 
0114     return true;
0115 }
0116 
0117 QDomElement MenuFile::findMenu(QDomElement elem, const QString &menuName, bool create)
0118 {
0119     QString menuNodeName;
0120     QString subMenuName;
0121     int i = menuName.indexOf(QLatin1Char('/'));
0122     if (i >= 0) {
0123         menuNodeName = menuName.left(i);
0124         subMenuName = menuName.mid(i + 1);
0125     } else {
0126         menuNodeName = menuName;
0127     }
0128     if (i == 0) {
0129         return findMenu(elem, subMenuName, create);
0130     }
0131 
0132     if (menuNodeName.isEmpty()) {
0133         return elem;
0134     }
0135 
0136     QDomNode n = elem.firstChild();
0137     while (!n.isNull()) {
0138         QDomElement e = n.toElement(); // try to convert the node to an element.
0139         if (e.tagName() == MF_MENU) {
0140             QString name;
0141 
0142             QDomNode n2 = e.firstChild();
0143             while (!n2.isNull()) {
0144                 QDomElement e2 = n2.toElement();
0145                 if (!e2.isNull() && e2.tagName() == MF_NAME) {
0146                     name = e2.text();
0147                     break;
0148                 }
0149                 n2 = n2.nextSibling();
0150             }
0151 
0152             if (name == menuNodeName) {
0153                 if (subMenuName.isEmpty()) {
0154                     return e;
0155                 } else {
0156                     return findMenu(e, subMenuName, create);
0157                 }
0158             }
0159         }
0160         n = n.nextSibling();
0161     }
0162 
0163     if (!create) {
0164         return QDomElement();
0165     }
0166 
0167     // Create new node.
0168     QDomElement newElem = m_doc.createElement(MF_MENU);
0169     QDomElement newNameElem = m_doc.createElement(MF_NAME);
0170     newNameElem.appendChild(m_doc.createTextNode(menuNodeName));
0171     newElem.appendChild(newNameElem);
0172     elem.appendChild(newElem);
0173 
0174     if (subMenuName.isEmpty()) {
0175         return newElem;
0176     } else {
0177         return findMenu(newElem, subMenuName, create);
0178     }
0179 }
0180 
0181 static QString relativeToDesktopDirsLocation(const QString &file)
0182 {
0183     const QString canonical = QFileInfo(file).canonicalFilePath();
0184     const QStringList dirs = QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation);
0185     for (const QString &dir : dirs) {
0186         const QString base = dir + QStringLiteral("/desktop-directories");
0187         if (canonical.startsWith(base)) {
0188             return canonical.mid(base.length() + 1);
0189         }
0190     }
0191     return QString();
0192 }
0193 
0194 static QString entryToDirId(const QString &path)
0195 {
0196     // See also KDesktopFile::locateLocal
0197     QString local;
0198     if (QFileInfo(path).isAbsolute()) {
0199         // XDG Desktop menu items come with absolute paths, we need to
0200         // extract their relative path and then build a local path.
0201         local = relativeToDesktopDirsLocation(path);
0202     }
0203 
0204     if (local.isEmpty() || local.startsWith(QLatin1Char('/'))) {
0205         // What now? Use filename only and hope for the best.
0206         local = path.mid(path.lastIndexOf(QLatin1Char('/')) + 1);
0207     }
0208     return local;
0209 }
0210 
0211 static void purgeIncludesExcludes(const QDomElement &elem, const QString &appId, QDomElement &excludeNode, QDomElement &includeNode)
0212 {
0213     // Remove any previous includes/excludes of appId
0214     QDomNode n = elem.firstChild();
0215     while (!n.isNull()) {
0216         QDomElement e = n.toElement(); // try to convert the node to an element.
0217         bool bIncludeNode = (e.tagName() == MenuFile::MF_INCLUDE);
0218         bool bExcludeNode = (e.tagName() == MenuFile::MF_EXCLUDE);
0219         if (bIncludeNode) {
0220             includeNode = e;
0221         }
0222         if (bExcludeNode) {
0223             excludeNode = e;
0224         }
0225         if (bIncludeNode || bExcludeNode) {
0226             QDomNode n2 = e.firstChild();
0227             while (!n2.isNull()) {
0228                 QDomNode next = n2.nextSibling();
0229                 QDomElement e2 = n2.toElement();
0230                 if (!e2.isNull() && e2.tagName() == MenuFile::MF_FILENAME) {
0231                     if (e2.text() == appId) {
0232                         e.removeChild(e2);
0233                         break;
0234                     }
0235                 }
0236                 n2 = next;
0237             }
0238         }
0239         n = n.nextSibling();
0240     }
0241 }
0242 
0243 static void purgeDeleted(QDomElement elem)
0244 {
0245     // Remove any previous includes/excludes of appId
0246     QDomNode n = elem.firstChild();
0247     while (!n.isNull()) {
0248         QDomNode next = n.nextSibling();
0249         QDomElement e = n.toElement(); // try to convert the node to an element.
0250         if ((e.tagName() == MenuFile::MF_DELETED) || (e.tagName() == MenuFile::MF_NOTDELETED)) {
0251             elem.removeChild(e);
0252         }
0253         n = next;
0254     }
0255 }
0256 
0257 static void purgeLayout(QDomElement elem)
0258 {
0259     // Remove any previous includes/excludes of appId
0260     QDomNode n = elem.firstChild();
0261     while (!n.isNull()) {
0262         QDomNode next = n.nextSibling();
0263         QDomElement e = n.toElement(); // try to convert the node to an element.
0264         if (e.tagName() == MenuFile::MF_LAYOUT) {
0265             elem.removeChild(e);
0266         }
0267         n = next;
0268     }
0269 }
0270 
0271 void MenuFile::addEntry(const QString &menuName, const QString &menuId)
0272 {
0273     m_bDirty = true;
0274 
0275     m_removedEntries.removeAll(menuId);
0276 
0277     QDomElement elem = findMenu(m_doc.documentElement(), menuName, true);
0278 
0279     QDomElement excludeNode;
0280     QDomElement includeNode;
0281 
0282     purgeIncludesExcludes(elem, menuId, excludeNode, includeNode);
0283 
0284     if (includeNode.isNull()) {
0285         includeNode = m_doc.createElement(MF_INCLUDE);
0286         elem.appendChild(includeNode);
0287     }
0288 
0289     QDomElement fileNode = m_doc.createElement(MF_FILENAME);
0290     fileNode.appendChild(m_doc.createTextNode(menuId));
0291     includeNode.appendChild(fileNode);
0292 }
0293 
0294 void MenuFile::setLayout(const QString &menuName, const QStringList &layout)
0295 {
0296     m_bDirty = true;
0297 
0298     QDomElement elem = findMenu(m_doc.documentElement(), menuName, true);
0299 
0300     purgeLayout(elem);
0301 
0302     QDomElement layoutNode = m_doc.createElement(MF_LAYOUT);
0303     elem.appendChild(layoutNode);
0304 
0305     for (QStringList::ConstIterator it = layout.constBegin(); it != layout.constEnd(); ++it) {
0306         QString li = *it;
0307         if (li == QLatin1String(":S")) {
0308             layoutNode.appendChild(m_doc.createElement(MF_SEPARATOR));
0309         } else if (li == QLatin1String(":M")) {
0310             QDomElement mergeNode = m_doc.createElement(MF_MERGE);
0311             mergeNode.setAttribute(QStringLiteral("type"), QStringLiteral("menus"));
0312             layoutNode.appendChild(mergeNode);
0313         } else if (li == QLatin1String(":F")) {
0314             QDomElement mergeNode = m_doc.createElement(MF_MERGE);
0315             mergeNode.setAttribute(QStringLiteral("type"), QStringLiteral("files"));
0316             layoutNode.appendChild(mergeNode);
0317         } else if (li == QLatin1String(":A")) {
0318             QDomElement mergeNode = m_doc.createElement(MF_MERGE);
0319             mergeNode.setAttribute(QStringLiteral("type"), QStringLiteral("all"));
0320             layoutNode.appendChild(mergeNode);
0321         } else if (li.endsWith(QLatin1Char('/'))) {
0322             li.chop(1);
0323             QDomElement menuNode = m_doc.createElement(MF_MENUNAME);
0324             menuNode.appendChild(m_doc.createTextNode(li));
0325             layoutNode.appendChild(menuNode);
0326         } else {
0327             QDomElement fileNode = m_doc.createElement(MF_FILENAME);
0328             fileNode.appendChild(m_doc.createTextNode(li));
0329             layoutNode.appendChild(fileNode);
0330         }
0331     }
0332 }
0333 
0334 void MenuFile::removeEntry(const QString &menuName, const QString &menuId)
0335 {
0336     m_bDirty = true;
0337     m_removedEntries.append(menuId);
0338 
0339     QDomElement elem = findMenu(m_doc.documentElement(), menuName, true);
0340 
0341     QDomElement excludeNode;
0342     QDomElement includeNode;
0343 
0344     purgeIncludesExcludes(elem, menuId, excludeNode, includeNode);
0345 
0346     if (excludeNode.isNull()) {
0347         excludeNode = m_doc.createElement(MF_EXCLUDE);
0348         elem.appendChild(excludeNode);
0349     }
0350     QDomElement fileNode = m_doc.createElement(MF_FILENAME);
0351     fileNode.appendChild(m_doc.createTextNode(menuId));
0352     excludeNode.appendChild(fileNode);
0353 }
0354 
0355 void MenuFile::addMenu(const QString &menuName, const QString &menuFile)
0356 {
0357     m_bDirty = true;
0358     QDomElement elem = findMenu(m_doc.documentElement(), menuName, true);
0359 
0360     QDomElement dirElem = m_doc.createElement(MF_DIRECTORY);
0361     dirElem.appendChild(m_doc.createTextNode(entryToDirId(menuFile)));
0362     elem.appendChild(dirElem);
0363 }
0364 
0365 void MenuFile::moveMenu(const QString &oldMenu, const QString &newMenu)
0366 {
0367     m_bDirty = true;
0368 
0369     // Undelete the new menu
0370     QDomElement elem = findMenu(m_doc.documentElement(), newMenu, true);
0371     purgeDeleted(elem);
0372     elem.appendChild(m_doc.createElement(MF_NOTDELETED));
0373 
0374     // TODO: GET RID OF COMMON PART, IT BREAKS STUFF
0375     // Find common part
0376     QStringList oldMenuParts = oldMenu.split(QLatin1Char('/'));
0377     QStringList newMenuParts = newMenu.split(QLatin1Char('/'));
0378     QString commonMenuName;
0379     int max = qMin(oldMenuParts.count(), newMenuParts.count());
0380     int i = 0;
0381     for (; i < max; i++) {
0382         if (oldMenuParts[i] != newMenuParts[i]) {
0383             break;
0384         }
0385         commonMenuName += QLatin1Char('/') + oldMenuParts[i];
0386     }
0387     QString oldMenuName;
0388     for (int j = i; j < oldMenuParts.count() - 1; j++) {
0389         if (i != j) {
0390             oldMenuName += QLatin1Char('/');
0391         }
0392         oldMenuName += oldMenuParts[j];
0393     }
0394     QString newMenuName;
0395     for (int j = i; j < newMenuParts.count() - 1; j++) {
0396         if (i != j) {
0397             newMenuName += QLatin1Char('/');
0398         }
0399         newMenuName += newMenuParts[j];
0400     }
0401 
0402     if (oldMenuName == newMenuName) {
0403         return; // Can happen
0404     }
0405     elem = findMenu(m_doc.documentElement(), commonMenuName, true);
0406 
0407     // Add instructions for moving
0408     QDomElement moveNode = m_doc.createElement(MF_MOVE);
0409     QDomElement node = m_doc.createElement(MF_OLD);
0410     node.appendChild(m_doc.createTextNode(oldMenuName));
0411     moveNode.appendChild(node);
0412     node = m_doc.createElement(MF_NEW);
0413     node.appendChild(m_doc.createTextNode(newMenuName));
0414     moveNode.appendChild(node);
0415     elem.appendChild(moveNode);
0416 }
0417 
0418 void MenuFile::removeMenu(const QString &menuName)
0419 {
0420     m_bDirty = true;
0421 
0422     QDomElement elem = findMenu(m_doc.documentElement(), menuName, true);
0423 
0424     purgeDeleted(elem);
0425     elem.appendChild(m_doc.createElement(MF_DELETED));
0426 }
0427 
0428 /**
0429  * Returns a unique menu-name for a new menu under @p menuName
0430  * inspired by @p newMenu
0431  */
0432 QString MenuFile::uniqueMenuName(const QString &menuName, const QString &newMenu, const QStringList &excludeList)
0433 {
0434     QDomElement elem = findMenu(m_doc.documentElement(), menuName, false);
0435 
0436     QString result = newMenu;
0437     if (result.endsWith(QLatin1Char('/'))) {
0438         result.chop(1);
0439     }
0440 
0441     static const QRegularExpression re(QStringLiteral("(.*)(?=-\\d+)"));
0442     const QRegularExpressionMatch match = re.match(result);
0443     result = match.hasMatch() ? match.captured(1) : result;
0444 
0445     int trunc = result.length(); // Position of trailing '/'
0446 
0447     result.append(QLatin1Char('/'));
0448 
0449     for (int n = 1; ++n;) {
0450         if (findMenu(elem, result, false).isNull() && !excludeList.contains(result)) {
0451             return result;
0452         }
0453 
0454         result.truncate(trunc);
0455         result.append(QStringLiteral("-%1/").arg(n));
0456     }
0457     return QString(); // Never reached
0458 }
0459 
0460 void MenuFile::performAction(const ActionAtom *atom)
0461 {
0462     switch (atom->action) {
0463     case ADD_ENTRY:
0464         addEntry(atom->arg1, atom->arg2);
0465         return;
0466     case REMOVE_ENTRY:
0467         removeEntry(atom->arg1, atom->arg2);
0468         return;
0469     case ADD_MENU:
0470         addMenu(atom->arg1, atom->arg2);
0471         return;
0472     case REMOVE_MENU:
0473         removeMenu(atom->arg1);
0474         return;
0475     case MOVE_MENU:
0476         moveMenu(atom->arg1, atom->arg2);
0477         return;
0478     }
0479 }
0480 
0481 MenuFile::ActionAtom *MenuFile::pushAction(MenuFile::ActionType action, const QString &arg1, const QString &arg2)
0482 {
0483     ActionAtom *atom = new ActionAtom;
0484     atom->action = action;
0485     atom->arg1 = arg1;
0486     atom->arg2 = arg2;
0487     m_actionList.append(atom);
0488     return atom;
0489 }
0490 
0491 void MenuFile::popAction(ActionAtom *atom)
0492 {
0493     if (m_actionList.last() != atom) {
0494         qWarning("MenuFile::popAction Error, action not last in list.");
0495         return;
0496     }
0497     m_actionList.removeLast();
0498     delete atom;
0499 }
0500 
0501 bool MenuFile::performAllActions()
0502 {
0503     Q_FOREACH (ActionAtom *atom, m_actionList) {
0504         performAction(atom);
0505         delete atom;
0506     }
0507     m_actionList.clear();
0508 
0509     // Entries that have been removed from the menu are added to .hidden
0510     // so that they don't re-appear in Lost & Found
0511     QStringList removed = m_removedEntries;
0512     m_removedEntries.clear();
0513     for (QStringList::ConstIterator it = removed.constBegin(); it != removed.constEnd(); ++it) {
0514         addEntry(QStringLiteral("/.hidden/"), *it);
0515     }
0516 
0517     m_removedEntries.clear();
0518 
0519     if (!m_bDirty) {
0520         return true;
0521     }
0522 
0523     return save();
0524 }
0525 
0526 bool MenuFile::dirty() const
0527 {
0528     return (m_actionList.count() != 0) || m_bDirty;
0529 }
0530 
0531 void MenuFile::restoreMenuSystem(const QString &filename)
0532 {
0533     m_error.clear();
0534 
0535     m_fileName = filename;
0536     m_doc.clear();
0537     m_bDirty = false;
0538     Q_FOREACH (ActionAtom *atom, m_actionList) {
0539         delete atom;
0540     }
0541     m_actionList.clear();
0542 
0543     m_removedEntries.clear();
0544     create();
0545 }