# Copyright (C) 2009 Canonical # # Authors: # Michael Vogt # # 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; version 3. # # 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; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA from gi.repository import GObject import gettext import glob import locale import logging import os import string import xapian # we use lxml.etree instead of xml.etree.ElementTree because it's quite a bit # faster, especially on slow machines like ARM import lxml.etree as ET from xml.sax.saxutils import escape as xml_escape from xml.sax.saxutils import unescape as xml_unescape import softwarecenter.paths from softwarecenter.enums import ( AppInfoFields, NonAppVisibility, SortMethods, ) from softwarecenter.backend.recagent import RecommenderAgent from softwarecenter.db.appfilter import AppFilter from softwarecenter.db.enquire import AppEnquire from softwarecenter.region import get_region_cached from softwarecenter.utils import utf8 from gettext import gettext as _ # not possible not use local logger LOG = logging.getLogger(__name__) def get_category_by_name(categories, untrans_name): # find a specific category cat = [cat for cat in categories if cat.untranslated_name == untrans_name] if cat: return cat[0] return None def categories_sorted_by_name(categories): # sort categories by name sorted_catnames = [] # first pass, sort by translated names for cat in categories: sorted_catnames.append(cat.name) sorted_catnames = sorted(sorted_catnames, cmp=locale.strcoll) # second pass, assemble cats by sorted their sorted catnames sorted_cats = [] for name in sorted_catnames: for cat in categories: if cat.name == name: sorted_cats.append(cat) break return sorted_cats def get_query_for_category(db, untranslated_category_name): cat_parser = CategoriesParser(db) categories = cat_parser.parse_applications_menu() for c in categories: if untranslated_category_name == c.untranslated_name: query = c.query return query return False class Category(GObject.GObject): """represents a menu category""" def __init__(self, untranslated_name, name, iconname, query, only_unallocated=True, dont_display=False, flags=[], subcategories=[], sortmode=SortMethods.BY_ALPHABET, item_limit=0): GObject.GObject.__init__(self) if type(name) == str: self.name = unicode(name, 'utf8').encode('utf8') else: self.name = name.encode('utf8') self.untranslated_name = untranslated_name self.iconname = iconname for subcategory in subcategories: query = xapian.Query(xapian.Query.OP_OR, query, subcategory.query) self.query = query self.only_unallocated = only_unallocated self.subcategories = subcategories self.dont_display = dont_display self.flags = flags self.sortmode = sortmode self.item_limit = item_limit @property def is_forced_sort_mode(self): return (self.sortmode != SortMethods.BY_ALPHABET) def get_documents(self, db): """ return the database docids for the given category """ enq = AppEnquire(db._aptcache, db) app_filter = AppFilter(db, db._aptcache) if "available-only" in self.flags: app_filter.set_available_only(True) if "not-installed-only" in self.flags: app_filter.set_not_installed_only(True) enq.set_query(self.query, limit=self.item_limit, filter=app_filter, sortmode=self.sortmode, nonapps_visible=NonAppVisibility.ALWAYS_VISIBLE, nonblocking_load=False) return enq.get_documents() def __str__(self): return "" % ( self.name, self.sortmode, self.item_limit) class RecommendedForYouCategory(Category): __gsignals__ = { "needs-refresh": (GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (), ), "recommender-agent-error": (GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (GObject.TYPE_STRING,), ), } def __init__(self, db, subcategory=None): self.db = db self.subcategory = subcategory if subcategory: # this is the set of recommendations for a given subcategory cat_title = u"Recommended For You in %s" % ( subcategory.untranslated_name) tr_title = utf8(_("Recommended For You in %s")) % utf8( subcategory.name) else: # this is the full set of recommendations for e.g. the lobby view cat_title = u"Recommended For You" tr_title = _("Recommended For You") super(RecommendedForYouCategory, self).__init__( cat_title, tr_title, None, xapian.Query(), flags=['available-only', 'not-installed-only'], item_limit=60) self.recommender_agent = RecommenderAgent() self.recommender_agent.connect( "recommend-me", self._recommend_me_result) self.recommender_agent.connect( "error", self._recommender_agent_error) self.recommender_agent.query_recommend_me() def _recommend_me_result(self, recommender_agent, result_list): pkgs = [] for item in result_list['data']: pkgs.append(item['package_name']) if self.subcategory: self.query = xapian.Query(xapian.Query.OP_AND, self.db.get_query_for_pkgnames(pkgs), self.subcategory.query) else: self.query = self.db.get_query_for_pkgnames(pkgs) self.emit("needs-refresh") def _recommender_agent_error(self, recommender_agent, msg): LOG.warn("Error while accessing the recommender service: %s" % msg) self.emit("recommender-agent-error", msg) class AppRecommendationsCategory(Category): __gsignals__ = { "needs-refresh": (GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (), ), "recommender-agent-error": (GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (GObject.TYPE_STRING,), ), } def __init__(self, db, pkgname): super(AppRecommendationsCategory, self).__init__( u"People Also Installed", _(u"People Also Installed"), None, xapian.Query(), flags=['available-only', 'not-installed-only'], item_limit=4) self.db = db self.recommender_agent = RecommenderAgent() self.recommender_agent.connect( "recommend-app", self._recommend_app_result) self.recommender_agent.connect( "error", self._recommender_agent_error) self.recommender_agent.query_recommend_app(pkgname) def _recommend_app_result(self, recommender_agent, result_list): pkgs = [] for item in result_list['data']: pkgs.append(item['package_name']) self.query = self.db.get_query_for_pkgnames(pkgs) self.emit("needs-refresh") def _recommender_agent_error(self, recommender_agent, msg): LOG.warn("Error while accessing the recommender service: %s" % msg) self.emit("recommender-agent-error", msg) class CategoriesParser(object): """ Parser that is able to read the categories from a menu file """ # class wide cache to avoid multiple parsing CATEGORIES_PARSER_CACHE = {} def __init__(self, db): self.db = db # build the string substitution support self._build_string_template_dict() def parse_applications_menu(self, datadir=None, use_cache=True): """ parse a application menu and return a list of Category objects """ if datadir is None: datadir = softwarecenter.paths.desktopdir # the DB must be part of the cache key because different DBs may # yield different results (e.g. in the tests) cachekey = (self.db, datadir) if use_cache and cachekey in self.CATEGORIES_PARSER_CACHE: return self.CATEGORIES_PARSER_CACHE[cachekey] categories = [] # we support multiple menu files and menu drop ins menu_files = [datadir + "/desktop/software-center.menu"] menu_files += glob.glob(datadir + "/menu.d/*.menu") for f in menu_files: if not os.path.exists(f): continue tree = ET.parse(f) root = tree.getroot() for child in root.getchildren(): category = None if child.tag == "Menu": category = self._parse_menu_tag(child) if category: categories.append(category) # post processing for # now build the unallocated queries, once for top-level, # and for the subcategories. this means that subcategories # can have a "OnlyUnallocated/" that applies only to # unallocated entries in their sublevel for cat in categories: self._build_unallocated_queries(cat.subcategories) self._build_unallocated_queries(categories) # add to the cache self.CATEGORIES_PARSER_CACHE[cachekey] = categories # debug print for cat in categories: LOG.debug("%s %s %s" % (cat.name.decode('utf8'), cat.iconname, cat.query)) return categories def _build_string_template_dict(self): """ this build the dict used to substitute menu entries dynamically, currently used for the CURRENT_REGION """ region = "%s" % get_region_cached()["countrycode"] self._template_dict = {'CURRENT_REGION': region} def _substitute_string_if_needed(self, t): """ substitute the given string with the current supported dynamic menu keys """ return string.Template(t).substitute(self._template_dict) def _cat_sort_cmp(self, a, b): """sort helper for the categories sorting""" #print "cmp: ", a.name, b.name if a.untranslated_name == "System": return 1 elif b.untranslated_name == "System": return -1 elif a.untranslated_name == "Developer Tools": return 1 elif b.untranslated_name == "Developer Tools": return -1 return locale.strcoll(a.name, b.name) def _parse_directory_tag(self, element): from softwarecenter.db.update import DesktopConfigParser cp = DesktopConfigParser() fname = "/usr/share/desktop-directories/%s" % element.text if not os.path.exists(fname): return None LOG.debug("reading '%s'" % fname) cp.read(fname) try: untranslated_name = name = cp.get("Desktop Entry", "Name") except Exception: LOG.warn("'%s' has no name" % fname) return None try: gettext_domain = cp.get("Desktop Entry", "X-Ubuntu-Gettext-Domain") except: gettext_domain = None try: icon = cp.get("Desktop Entry", "Icon") except Exception: icon = "applications-other" name = cp.get_value(AppInfoFields.NAME, translated=True) return (untranslated_name, name, gettext_domain, icon) def _parse_flags_tag(self, element): flags = [] for an_elem in element.getchildren(): flags.append(an_elem.text) return flags def _parse_and_or_not_tag(self, element, query, xapian_op): """parse a , , tag """ for operator_elem in element.getchildren(): # get the query-text if operator_elem.text: qtext = self._substitute_string_if_needed( operator_elem.text).lower() # parse the individual element if operator_elem.tag == "Not": query = self._parse_and_or_not_tag( operator_elem, query, xapian.Query.OP_AND_NOT) elif operator_elem.tag == "Or": or_elem = self._parse_and_or_not_tag( operator_elem, xapian.Query(), xapian.Query.OP_OR) query = xapian.Query(xapian.Query.OP_AND, or_elem, query) elif operator_elem.tag == "Category": LOG.debug("adding: %s" % operator_elem.text) q = xapian.Query("AC" + qtext) query = xapian.Query(xapian_op, query, q) elif operator_elem.tag == "SCSection": LOG.debug("adding section: %s" % operator_elem.text) # we have the section once in apt-xapian-index and once # in our own DB this is why we need two prefixes # FIXME: ponder if it makes sense to simply write # out XS in update-software-center instead of AE? q = xapian.Query(xapian.Query.OP_OR, xapian.Query("XS" + qtext), xapian.Query("AE" + qtext)) query = xapian.Query(xapian_op, query, q) elif operator_elem.tag == "SCType": LOG.debug("adding type: %s" % operator_elem.text) q = xapian.Query("AT" + qtext) query = xapian.Query(xapian_op, query, q) elif operator_elem.tag == "SCDebtag": LOG.debug("adding debtag: %s" % operator_elem.text) q = xapian.Query("XT" + qtext) query = xapian.Query(xapian_op, query, q) elif operator_elem.tag == "SCChannel": LOG.debug("adding channel: %s" % operator_elem.text) q = xapian.Query("AH" + qtext) query = xapian.Query(xapian_op, query, q) elif operator_elem.tag == "SCOrigin": LOG.debug("adding origin: %s" % operator_elem.text) # FIXME: origin is currently case-sensitive?!? q = xapian.Query("XOO" + operator_elem.text) query = xapian.Query(xapian_op, query, q) elif operator_elem.tag == "SCPkgname": LOG.debug("adding tag: %s" % operator_elem.text) # query both axi and s-c q1 = xapian.Query("AP" + qtext) q = xapian.Query(xapian.Query.OP_OR, q1, xapian.Query("XP" + qtext)) query = xapian.Query(xapian_op, query, q) elif operator_elem.tag == "SCPkgnameWildcard": LOG.debug("adding tag: %s" % operator_elem.text) # query both axi and s-c and ensure that the pkgname is # mangled to workaround xapian's query parser lack of # quoting :/ s = "pkg_wildcard:%s" % qtext.replace("-", "_") q = self.db.xapian_parser.parse_query(s, xapian.QueryParser.FLAG_WILDCARD) query = xapian.Query(xapian_op, query, q) else: LOG.warn("UNHANDLED: %s %s" % (operator_elem.tag, operator_elem.text)) return query def _parse_include_tag(self, element): for include in element.getchildren(): if include.tag == "Or": query = xapian.Query() return self._parse_and_or_not_tag(include, query, xapian.Query.OP_OR) if include.tag == "And": query = xapian.Query("") return self._parse_and_or_not_tag(include, query, xapian.Query.OP_AND) # without "and" tag we take the first entry elif include.tag == "Category": return xapian.Query("AC" + include.text.lower()) else: LOG.warn("UNHANDLED: _parse_include_tag: %s" % include.tag) # empty query matches all return xapian.Query("") def _parse_menu_tag(self, item): name = None untranslated_name = None query = None icon = None only_unallocated = False dont_display = False flags = [] subcategories = [] sortmode = SortMethods.BY_ALPHABET item_limit = 0 for element in item.getchildren(): # ignore inline translations, we use gettext for this if (element.tag == "Name" and '{http://www.w3.org/XML/1998/namespace}lang' in element.attrib): continue if element.tag == "Name": untranslated_name = element.text # gettext/xml writes stuff from software-center.menu # out into the pot as escaped xml, so we need to escape # the name first, get the translation and unescape it again escaped_name = xml_escape(untranslated_name) name = xml_unescape(gettext.gettext(escaped_name)) elif element.tag == "SCIcon": icon = element.text elif element.tag == 'Flags': flags = self._parse_flags_tag(element) elif element.tag == "Directory": l = self._parse_directory_tag(element) if l: (untranslated_name, name, gettext_domain, icon) = l elif element.tag == "Include": query = self._parse_include_tag(element) elif element.tag == "OnlyUnallocated": only_unallocated = True elif element.tag == "SCDontDisplay": dont_display = True elif element.tag == "SCSortMode": sortmode = int(element.text) if not self._verify_supported_sort_mode(sortmode): return None elif element.tag == "SCItemLimit": item_limit = int(element.text) elif element.tag == "Menu": subcat = self._parse_menu_tag(element) if subcat: subcategories.append(subcat) else: LOG.warn("UNHANDLED tag in _parse_menu_tag: %s" % element.tag) if untranslated_name and query: return Category(untranslated_name, name, icon, query, only_unallocated, dont_display, flags, subcategories, sortmode, item_limit) else: LOG.warn("UNHANDLED entry: %s %s %s %s" % (name, untranslated_name, icon, query)) return None def _verify_supported_sort_mode(self, sortmode): """ verify that we use a sortmode that we know and can handle """ # always supported if sortmode in (SortMethods.UNSORTED, SortMethods.BY_ALPHABET, SortMethods.BY_TOP_RATED, SortMethods.BY_SEARCH_RANKING, SortMethods.BY_CATALOGED_TIME): return True # we don't know this sortmode LOG.error("unknown sort mode '%i'" % sortmode) return False def _build_unallocated_queries(self, categories): for cat_unalloc in categories: if not cat_unalloc.only_unallocated: continue for cat in categories: if cat.name != cat_unalloc.name: cat_unalloc.query = xapian.Query(xapian.Query.OP_AND_NOT, cat_unalloc.query, cat.query) #print cat_unalloc.name, cat_unalloc.query return # static category mapping for the tiles category_cat = { 'Utility': 'Accessories', 'System': 'Accessories', 'Education': 'Education', 'Game': 'Games', 'Sports': 'Games', 'Graphics': 'Graphics', 'Network': 'Internet', 'Office': 'Office', 'Science': 'Science & Engineering', 'Audio': 'Sound & Video', 'AudioVideo': 'Sound & Video', 'Video': 'Sound & Video', 'Settings': 'Themes & Tweaks', 'Accessibility': 'Universal Access', 'Development': 'Developer Tools', 'X-Publishing': 'Books & Magazines', } category_subcat = { 'BoardGame': 'Games;Board Games', 'CardGame': 'Games;Card Games', 'LogicGame': 'Games;Puzzles', 'RolePlaying': 'Games;Role Playing', 'SportsGame': 'Games;Sports', '3DGraphics': 'Graphics;3D Graphics', 'VectorGraphics': 'Graphics;Drawing', 'RasterGraphics': 'Graphics;Painting & Editing', 'Photography': 'Graphics;Photography', 'Publishing': 'Graphics;Publishing', 'Scanning': 'Graphics;Scanning & OCR', 'OCR': 'Graphics;Scanning & OCR', 'Viewer': 'Graphics;Viewers', 'InstantMessaging': 'Internet;Chat', 'IRCClient': 'Internet;Chat', 'FileTransfer': 'Internet;File Sharing', 'Email': 'Internet;Mail', 'WebBrowser': 'Internet;Web Browsers', 'Astronomy': 'Science & Engineering;Astronomy', 'Biology': 'Science & Engineering;Biology', 'Chemistry': 'Science & Engineering;Chemistry', 'ArtificialIntelligence': 'Science & Engineering;Computing & Robotics', 'ComputerScience': 'Science & Engineering;Computing & Robotics', 'Robotics': 'Science & Engineering;Computing & Robotics', 'Electronics': 'Science & Engineering;Electronics', 'Engineering': 'Science & Engineering;Engineering', 'Geography': 'Science & Engineering;Geography', 'Geology': 'Science & Engineering;Geology', 'Geoscience': 'Science & Engineering;Geology', 'DataVisualization': 'Science & Engineering;Mathematics', 'Math': 'Science & Engineering;Mathematics', 'NumericalAnalysis': 'Science & Engineering;Mathematics', 'MedicalSoftware': 'Science & Engineering;Medicine', 'Electricity': 'Science & Engineering;Physics', 'Physics': 'Science & Engineering;Physics', 'Debugger': 'Developer Tools;Debugging', 'GUIDesigner': 'Developer Tools;Graphic Interface Design', 'IDE': 'Developer Tools;IDEs', 'Translation': 'Developer Tools;Localization', 'Profiling': 'Developer Tools;Profiling', 'RevisionControl': 'Developer Tools;Version Control', 'WebDevelopment': 'Developer Tools;Web Development', }