/* SPDX-FileCopyrightText: 2015 Gregor Mi SPDX-License-Identifier: LGPL-2.1-or-later */ #include "kmoretools.h" #include "kmoretools_debug.h" #include "kmoretools_p.h" #include "kmoretoolsconfigdialog_p.h" #include #include #include #include #include #include #include class KMoreToolsPrivate { public: QString uniqueId; // allocated via new, don't forget to delete QList serviceList; QMap menuBuilderMap; public: KMoreToolsPrivate(const QString &uniqueId) : uniqueId(uniqueId) { } ~KMoreToolsPrivate() { qDeleteAll(menuBuilderMap); qDeleteAll(serviceList); } /** * @return uniqueId if kmtDesktopfileSubdir is empty * else kmtDesktopfileSubdir */ QString kmtDesktopfileSubdirOrUniqueId(const QString &kmtDesktopfileSubdir) { if (kmtDesktopfileSubdir.isEmpty()) { return uniqueId; } return kmtDesktopfileSubdir; } /** * Finds a file in the '/usr/share'/kf5/kmoretools/'uniqueId'/ directory. * '/usr/share' = "~/.local/share", "/usr/local/share", "/usr/share" (see QStandardPaths::GenericDataLocation) * 'uniqueId' = @see uniqueId() * * @param can be a filename with or without relative path. But no absolute path. * @returns the first occurrence if there are more than one found */ QString findFileInKmtDesktopfilesDir(const QString &filename) { return findFileInKmtDesktopfilesDir(uniqueId, filename); } static QString findFileInKmtDesktopfilesDir(const QString &kmtDesktopfileSubdir, const QString &filename) { const QString kmtDesktopfilesFilename = QLatin1String("kf6/kmoretools/") + kmtDesktopfileSubdir + QLatin1Char('/') + filename; const QString foundKmtFile = QStandardPaths::locate(QStandardPaths::GenericDataLocation, kmtDesktopfilesFilename); return foundKmtFile; } }; KMoreTools::KMoreTools(const QString &uniqueId) : d(new KMoreToolsPrivate(uniqueId)) { } KMoreTools::~KMoreTools() = default; KMoreToolsService *KMoreTools::registerServiceByDesktopEntryName(const QString &desktopEntryName, const QString &kmtDesktopfileSubdir, KMoreTools::ServiceLocatingMode serviceLocatingMode) { const QString foundKmtDesktopfilePath = d->findFileInKmtDesktopfilesDir(d->kmtDesktopfileSubdirOrUniqueId(kmtDesktopfileSubdir), desktopEntryName + QLatin1String(".desktop")); const bool isKmtDesktopfileProvided = !foundKmtDesktopfilePath.isEmpty(); KService::Ptr kmtDesktopfile; if (isKmtDesktopfileProvided) { kmtDesktopfile = KService::Ptr(new KService(foundKmtDesktopfilePath)); if (!kmtDesktopfile->isValid()) { qCCritical(KMORETOOLS) << "KMoreTools::registerServiceByDesktopEntryName: the kmt-desktopfile " << desktopEntryName << " is provided but no Exec line is specified. The desktop file is probably faulty. Please fix. Return nullptr."; return nullptr; } } else { qCWarning(KMORETOOLS) << "KMoreTools::registerServiceByDesktopEntryName: desktopEntryName " << desktopEntryName << " (kmtDesktopfileSubdir=" << kmtDesktopfileSubdir << ") not provided (or at the wrong place) in the installed kmt-desktopfiles directory. If the service is also not installed on " "the system the user won't get nice translated app name and description."; qCDebug(KMORETOOLS) << "`-- More info at findFileInKmtDesktopfilesDir, QStandardPaths::standardLocations = " << QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation); // /usr/share etc. } bool isInstalled = false; KService::Ptr installedService; if (serviceLocatingMode == KMoreTools::ServiceLocatingMode_Default) { // == default behaviour: search for installed services installedService = KService::serviceByDesktopName(desktopEntryName); isInstalled = installedService != nullptr; } else if (serviceLocatingMode == KMoreTools::ServiceLocatingMode_ByProvidedExecLine) { // only use provided kmt-desktopfile: if (!isKmtDesktopfileProvided) { qCCritical(KMORETOOLS) << "KMoreTools::registerServiceByDesktopEntryName for " << desktopEntryName << ": If detectServiceExistenceViaProvidedExecLine is true then a kmt-desktopfile must be provided. Please fix. Return nullptr."; return nullptr; } const QString tryExec = kmtDesktopfile->property(QStringLiteral("TryExec")); isInstalled = (!tryExec.isEmpty() && !QStandardPaths::findExecutable(tryExec).isEmpty()) || !QStandardPaths::findExecutable(kmtDesktopfile->exec()).isEmpty(); } else { Q_UNREACHABLE(); // case not handled } auto registeredService = new KMoreToolsService(d->kmtDesktopfileSubdirOrUniqueId(kmtDesktopfileSubdir), desktopEntryName, isInstalled, installedService, kmtDesktopfile); // add or replace item in serviceList auto foundService = std::find_if(d->serviceList.begin(), d->serviceList.end(), [&desktopEntryName](KMoreToolsService *service) { return service->desktopEntryName() == desktopEntryName; }); if (foundService == d->serviceList.end()) { d->serviceList.append(registeredService); } else { KMoreToolsService *foundServicePtr = *foundService; int i = d->serviceList.indexOf(foundServicePtr); delete foundServicePtr; d->serviceList.replace(i, registeredService); } return registeredService; } KMoreToolsMenuBuilder *KMoreTools::menuBuilder(const QString &userConfigPostfix) const { if (d->menuBuilderMap.find(userConfigPostfix) == d->menuBuilderMap.end()) { d->menuBuilderMap.insert(userConfigPostfix, new KMoreToolsMenuBuilder(d->uniqueId, userConfigPostfix)); } return d->menuBuilderMap[userConfigPostfix]; } // ------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------ class KMoreToolsServicePrivate { public: QString kmtDesktopfileSubdir; QString desktopEntryName; KService::Ptr installedService; KService::Ptr kmtDesktopfile; QUrl homepageUrl; int maxUrlArgCount = 0; bool isInstalled = false; QString appstreamId; public: QString getServiceName() { if (installedService) { return installedService->name(); } else { if (kmtDesktopfile) { return kmtDesktopfile->name(); } else { return QString(); } } } QString getServiceGenericName() { if (installedService) { return installedService->genericName(); } else { if (kmtDesktopfile) { return kmtDesktopfile->genericName(); } else { return QString(); } } } /** * @return the provided icon or an empty icon if not kmtDesktopfile is available or the icon was not found */ QIcon getKmtProvidedIcon() { if (!kmtDesktopfile) { return QIcon(); } QString iconPath = KMoreToolsPrivate::findFileInKmtDesktopfilesDir(kmtDesktopfileSubdir, kmtDesktopfile->icon() + QLatin1String(".svg")); QIcon svgIcon(iconPath); if (!svgIcon.isNull()) { return svgIcon; } iconPath = KMoreToolsPrivate::findFileInKmtDesktopfilesDir(kmtDesktopfileSubdir, kmtDesktopfile->icon() + QLatin1String(".png")); QIcon pngIcon(iconPath); if (!pngIcon.isNull()) { return pngIcon; } return QIcon(); } }; KMoreToolsService::KMoreToolsService(const QString &kmtDesktopfileSubdir, const QString &desktopEntryName, bool isInstalled, KService::Ptr installedService, KService::Ptr kmtDesktopfile) : d(new KMoreToolsServicePrivate()) { d->kmtDesktopfileSubdir = kmtDesktopfileSubdir; d->desktopEntryName = desktopEntryName; d->isInstalled = isInstalled; d->installedService = installedService; d->kmtDesktopfile = kmtDesktopfile; } KMoreToolsService::~KMoreToolsService() = default; QString KMoreToolsService::desktopEntryName() const { return d->desktopEntryName; } bool KMoreToolsService::isInstalled() const { return d->isInstalled; } KService::Ptr KMoreToolsService::installedService() const { return d->installedService; } KService::Ptr KMoreToolsService::kmtProvidedService() const { return d->kmtDesktopfile; } QIcon KMoreToolsService::kmtProvidedIcon() const { return d->getKmtProvidedIcon(); } QUrl KMoreToolsService::homepageUrl() const { return d->homepageUrl; } void KMoreToolsService::setHomepageUrl(const QUrl &url) { d->homepageUrl = url; } int KMoreToolsService::maxUrlArgCount() const { return d->maxUrlArgCount; } void KMoreToolsService::setMaxUrlArgCount(int maxUrlArgCount) { d->maxUrlArgCount = maxUrlArgCount; } QString KMoreToolsService::formatString(const QString &formatString) const { QString result = formatString; QString genericName = d->getServiceGenericName(); if (genericName.isEmpty()) { genericName = d->getServiceName(); if (genericName.isEmpty()) { genericName = desktopEntryName(); } } QString name = d->getServiceName(); if (name.isEmpty()) { name = desktopEntryName(); } result.replace(QLatin1String("$GenericName"), genericName); result.replace(QLatin1String("$Name"), name); result.replace(QLatin1String("$DesktopEntryName"), desktopEntryName()); return result; } QIcon KMoreToolsService::icon() const { if (installedService() != nullptr) { return QIcon::fromTheme(installedService()->icon()); } else if (kmtProvidedService() != nullptr) { return d->getKmtProvidedIcon(); } else { return QIcon(); } } void KMoreToolsService::setExec(const QString &exec) { auto service = installedService(); if (service) { service->setExec(exec); } } QString KMoreToolsService::appstreamId() const { return d->appstreamId; } void KMoreToolsService::setAppstreamId(const QString &id) { d->appstreamId = id; } // ------------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------------ const QString configFile = QStringLiteral("kmoretoolsrc"); const QString configKey = QStringLiteral("menu_structure"); class KMoreToolsMenuBuilderPrivate { public: QString uniqueId; /** * default value is "", see KMoreTools::menuBuilder() */ QString userConfigPostfix; QList menuItems; KmtMenuItemIdGen menuItemIdGen; QString initialItemTextTemplate = QStringLiteral("$GenericName"); public: KMoreToolsMenuBuilderPrivate() { } ~KMoreToolsMenuBuilderPrivate() { } void deleteAndClearMenuItems() { for (auto item : std::as_const(menuItems)) { delete item; } menuItems.clear(); } KmtMenuStructureDto readUserConfig() const { KConfig config(configFile, KConfig::NoGlobals, QStandardPaths::ConfigLocation); auto configGroup = config.group(uniqueId + userConfigPostfix); QString json = configGroup.readEntry(configKey); KmtMenuStructureDto configuredStructure; configuredStructure.deserialize(json); return configuredStructure; } void writeUserConfig(const KmtMenuStructureDto &mstruct) const { KConfig config(configFile, KConfig::NoGlobals, QStandardPaths::ConfigLocation); auto configGroup = config.group(uniqueId + userConfigPostfix); auto configValue = mstruct.serialize(); configGroup.writeEntry(configKey, configValue); configGroup.sync(); } enum CreateMenuStructureOption { CreateMenuStructure_Default, CreateMenuStructure_MergeWithUserConfig, }; /** * Merge strategy if createMenuStructureOption == CreateMenuStructure_MergeWithUserConfig * -------------------------------------------------------------------------------------- * 1) For each 'main' section item from configStruct * lookup in current structure (all installed items) and if found add to new structure * This means items which are in configStruct but not in current structure will be discarded. * * 2) Add remaining 'main' section items from current to new structure * * 3) Do the 1) and 2) analogous for 'more' section * * * How default structure and DTOs play together * -------------------------------------------- * Part 1: * * defaultStruct (in memory, defined by application that uses KMoreTools) * + configuredStruct (DTO, loaded from disk, from json) * = currentStruct (in memory, used to create the actual menu) * This is done by KMoreToolsMenuBuilderPrivate::createMenuStructure(mergeWithUserConfig = true). * * Part 2: * defaultStruct => defaultStructDto * currentStruct => currentStructDto * Both DTOs go to the Configure dialog. * Users edits structure => new configuredStruct (DTO => to json => to disk) * * * If createMenuStructureOption == CreateMenuStructure_Default then the default menu structure is returned. */ KmtMenuStructure createMenuStructure(CreateMenuStructureOption createMenuStructureOption) const { KmtMenuStructureDto configuredStructure; // if this stays empty then the default structure will not be changed if (createMenuStructureOption == CreateMenuStructure_MergeWithUserConfig) { // fill if should be merged configuredStructure = readUserConfig(); } KmtMenuStructure mstruct; QList menuItemsSource = menuItems; QList menuItemsSortedAsConfigured; // presort as in configuredStructure for (const KmtMenuItemDto &item : std::as_const(configuredStructure.list)) { auto foundItem = std::find_if(menuItemsSource.begin(), menuItemsSource.end(), [&item](const KMoreToolsMenuItem *kMenuItem) { return kMenuItem->id() == item.id; }); if (foundItem != menuItemsSource.end()) { menuItemsSortedAsConfigured.append(*foundItem); // add to final list menuItemsSource.removeOne(*foundItem); // remove from source } } // Add remaining items from source. These may be main and more section items // so that the resulting list may have [ main items, more items, main items, more items ] // instead of only [ main items, more items ] // But in the next step this won't matter. menuItemsSortedAsConfigured.append(menuItemsSource); // build MenuStructure from presorted list for (auto item : std::as_const(menuItemsSortedAsConfigured)) { const auto registeredService = item->registeredService(); if ((registeredService && registeredService->isInstalled()) || !registeredService) { // if a QAction was registered directly std::optional confItem = configuredStructure.findInstalled(item->id()); if ((!confItem && item->defaultLocation() == KMoreTools::MenuSection_Main) || (confItem && confItem->menuSection == KMoreTools::MenuSection_Main)) { mstruct.mainItems.append(item); } else if ((!confItem && item->defaultLocation() == KMoreTools::MenuSection_More) || (confItem && confItem->menuSection == KMoreTools::MenuSection_More)) { mstruct.moreItems.append(item); } else { Q_ASSERT_X(false, "buildAndAppendToMenu", "invalid enum"); // todo/later: apart from static programming error, if the config garbage this might happen } } else { if (!mstruct.notInstalledServices.contains(item->registeredService())) { mstruct.notInstalledServices.append(item->registeredService()); } } } return mstruct; } /** * @param defaultStructure also contains the currently not-installed items */ void showConfigDialog(KmtMenuStructureDto defaultStructureDto, const QString &title = QString()) const { // read from config auto currentStructure = createMenuStructure(CreateMenuStructure_MergeWithUserConfig); auto currentStructureDto = currentStructure.toDto(); KMoreToolsConfigDialog *dlg = new KMoreToolsConfigDialog(defaultStructureDto, currentStructureDto, title); if (dlg->exec() == QDialog::Accepted) { currentStructureDto = dlg->currentStructure(); writeUserConfig(currentStructureDto); } delete dlg; } /** * Create the 'More' menu with parent as parent * @param parent The parent of the menu */ void createMoreMenu(const KmtMenuStructure &mstruct, QMenu *parent) { for (auto item : std::as_const(mstruct.moreItems)) { const auto action = item->action(); action->setParent(parent); parent->addAction(action); } if (!mstruct.notInstalledServices.isEmpty()) { parent->addSection(i18nc("@action:inmenu", "Not installed:")); for (auto registeredService : std::as_const(mstruct.notInstalledServices)) { QMenu *submenuForNotInstalled = KmtNotInstalledUtil::createSubmenuForNotInstalledApp(registeredService->formatString(QStringLiteral("$Name")), parent, registeredService->icon(), registeredService->homepageUrl(), registeredService->appstreamId()); parent->addMenu(submenuForNotInstalled); } } } }; KMoreToolsMenuBuilder::KMoreToolsMenuBuilder() { Q_UNREACHABLE(); } KMoreToolsMenuBuilder::KMoreToolsMenuBuilder(const QString &uniqueId, const QString &userConfigPostfix) : d(new KMoreToolsMenuBuilderPrivate()) { d->uniqueId = uniqueId; d->userConfigPostfix = userConfigPostfix; } KMoreToolsMenuBuilder::~KMoreToolsMenuBuilder() { d->deleteAndClearMenuItems(); } void KMoreToolsMenuBuilder::setInitialItemTextTemplate(const QString &templateText) { d->initialItemTextTemplate = templateText; } KMoreToolsMenuItem *KMoreToolsMenuBuilder::addMenuItem(KMoreToolsService *registeredService, KMoreTools::MenuSection defaultLocation) { auto kmtMenuItem = new KMoreToolsMenuItem(registeredService, defaultLocation, d->initialItemTextTemplate); kmtMenuItem->setId(d->menuItemIdGen.getId(registeredService->desktopEntryName())); d->menuItems.append(kmtMenuItem); return kmtMenuItem; } KMoreToolsMenuItem *KMoreToolsMenuBuilder::addMenuItem(QAction *action, const QString &itemId, KMoreTools::MenuSection defaultLocation) { auto kmtMenuItem = new KMoreToolsMenuItem(action, d->menuItemIdGen.getId(itemId), defaultLocation); d->menuItems.append(kmtMenuItem); return kmtMenuItem; } void KMoreToolsMenuBuilder::clear() { d->deleteAndClearMenuItems(); d->menuItemIdGen.reset(); } QString KMoreToolsMenuBuilder::menuStructureAsString(bool mergeWithUserConfig) const { KmtMenuStructure mstruct = d->createMenuStructure(mergeWithUserConfig ? KMoreToolsMenuBuilderPrivate::CreateMenuStructure_MergeWithUserConfig : KMoreToolsMenuBuilderPrivate::CreateMenuStructure_Default); QString s; s += QLatin1String("|main|:"); for (auto item : std::as_const(mstruct.mainItems)) { s += item->registeredService()->desktopEntryName() + QLatin1Char('.'); } s += QLatin1String("|more|:"); for (auto item : std::as_const(mstruct.moreItems)) { s += item->registeredService()->desktopEntryName() + QLatin1Char('.'); } s += QLatin1String("|notinstalled|:"); for (auto regService : std::as_const(mstruct.notInstalledServices)) { s += regService->desktopEntryName() + QLatin1Char('.'); } return s; } // TMP / for unit test void KMoreToolsMenuBuilder::showConfigDialog(const QString &title) { d->showConfigDialog(d->createMenuStructure(KMoreToolsMenuBuilderPrivate::CreateMenuStructure_Default).toDto(), title); } void KMoreToolsMenuBuilder::buildByAppendingToMenu(QMenu *menu, KMoreTools::ConfigureDialogAccessibleSetting configureDialogAccessibleSetting, QMenu **outMoreMenu) { KmtMenuStructure mstruct = d->createMenuStructure(KMoreToolsMenuBuilderPrivate::CreateMenuStructure_MergeWithUserConfig); for (auto item : std::as_const(mstruct.mainItems)) { const auto action = item->action(); if (!action->parent()) { // if the action has no parent, set it to the menu to be filled action->setParent(menu); } menu->addAction(action); } QMenu *moreMenu = new QMenu(i18nc("@action:inmenu", "More"), menu); if (!mstruct.moreItems.isEmpty() || !mstruct.notInstalledServices.isEmpty()) { if (mstruct.mainItems.isEmpty()) { d->createMoreMenu(mstruct, menu); } else { menu->addSeparator(); menu->addMenu(moreMenu); d->createMoreMenu(mstruct, moreMenu); } } if (moreMenu->isEmpty()) { if (outMoreMenu) { *outMoreMenu = nullptr; } } else { if (outMoreMenu) { *outMoreMenu = moreMenu; } } QMenu *baseMenu; // either the "Configure..." menu should be shown via setting or the Ctrl key is pressed if (configureDialogAccessibleSetting == KMoreTools::ConfigureDialogAccessible_Always || QApplication::keyboardModifiers() & Qt::ControlModifier || (configureDialogAccessibleSetting == KMoreTools::ConfigureDialogAccessible_Defensive && !mstruct.notInstalledServices.empty())) { if (moreMenu->isEmpty()) { // "more" menu was not created... // ...then we add the configure menu to the main menu baseMenu = menu; } else { // more menu has items // ...then it was added to main menu and has got at least on item baseMenu = moreMenu; } if (!baseMenu->isEmpty()) { baseMenu->addSeparator(); auto configureAction = baseMenu->addAction(QIcon::fromTheme(QStringLiteral("configure")), i18nc("@action:inmenu", "Configure...")); configureAction->setData(QStringLiteral("configureItem")); // tag the action (currently only used in unit-test) KmtMenuStructure mstructDefault = d->createMenuStructure(KMoreToolsMenuBuilderPrivate::CreateMenuStructure_Default); KmtMenuStructureDto mstructDefaultDto = mstructDefault.toDto(); // makes sure the "Reset" button works as expected QObject::connect(configureAction, &QAction::triggered, configureAction, [this, mstructDefaultDto](bool) { this->d->showConfigDialog(mstructDefaultDto); }); } } } class KMoreToolsMenuItemPrivate { public: QString id; KMoreToolsService *registeredService = nullptr; QString initialItemText; QAction *action = nullptr; KMoreTools::MenuSection defaultLocation; bool actionAutoCreated = false; // action might stay nullptr even if actionCreated is true }; KMoreToolsMenuItem::KMoreToolsMenuItem(KMoreToolsService *registeredService, KMoreTools::MenuSection defaultLocation, const QString &initialItemTextTemplate) : d(new KMoreToolsMenuItemPrivate()) { d->registeredService = registeredService; d->defaultLocation = defaultLocation; // set menu item caption (text) QString defaultName = registeredService->formatString(initialItemTextTemplate); // e.g. "$GenericName", "$Name" d->initialItemText = registeredService->formatString(defaultName); } KMoreToolsMenuItem::KMoreToolsMenuItem(QAction *action, const QString &itemId, KMoreTools::MenuSection defaultLocation) : d(new KMoreToolsMenuItemPrivate()) { d->action = action; d->id = itemId; d->defaultLocation = defaultLocation; } KMoreToolsMenuItem::~KMoreToolsMenuItem() { if (d->actionAutoCreated && d->action) { // Only do this if KMoreTools created the action. Other actions must be deleted by client. // d->action can already be nullptr in some cases. // Disconnects the 'connect' event (and potentially more; is this bad?) // that was connected in action() to detect action deletion. d->action->disconnect(d->action); } } QString KMoreToolsMenuItem::id() const { return d->id; } void KMoreToolsMenuItem::setId(const QString &id) { d->id = id; } KMoreToolsService *KMoreToolsMenuItem::registeredService() const { return d->registeredService; } KMoreTools::MenuSection KMoreToolsMenuItem::defaultLocation() const { return d->defaultLocation; } QString KMoreToolsMenuItem::initialItemText() const { return d->initialItemText; } void KMoreToolsMenuItem::setInitialItemText(const QString &itemText) { d->initialItemText = itemText; } QAction *KMoreToolsMenuItem::action() const { // currently we assume if a registeredService is given we auto-create the QAction once if (d->registeredService && !d->actionAutoCreated) { d->actionAutoCreated = true; if (d->registeredService->isInstalled()) { d->action = new QAction(d->registeredService->icon(), d->initialItemText, nullptr); // reset the action cache when action gets destroyed // this happens in unit-tests where menu.clear() is called before another buildByAppendingToMenu call // WARN: see also destructor! (might be a source of bugs?) QObject::connect(d->action, &QObject::destroyed, d->action, [this]() { this->d->actionAutoCreated = false; this->d->action = nullptr; }); } else { d->action = nullptr; } } // else: // !d->registeredService => action will be provided by user // or d->actionAutoCreated => action was autocreated (or set to nullptr if service not installed) return d->action; }