From 352fb7b8337a6d9a0fc954e9c123a7e1ac10d643 Mon Sep 17 00:00:00 2001 From: Morten Mjelva Date: Fri, 26 Aug 2011 16:15:38 +0200 Subject: [PATCH] search: Allow searching for people in overview mode This adds contacts search to shell, powered by libfolks. Changes: - Add Folks and Gee to the build system - ShellContactSystem, a backend in C - ContactDisplay, search frontend in JS https://bugzilla.gnome.org/show_bug.cgi?id=643018 --- configure.ac | 2 + data/theme/gnome-shell.css | 52 +++++ js/Makefile.am | 1 + js/ui/contactDisplay.js | 179 ++++++++++++++++ js/ui/overview.js | 2 + src/Makefile.am | 4 +- src/shell-contact-system.c | 350 ++++++++++++++++++++++++++++++++ src/shell-contact-system.h | 50 +++++ tools/build/gnome-shell.modules | 34 ++++ 9 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 js/ui/contactDisplay.js create mode 100644 src/shell-contact-system.c create mode 100644 src/shell-contact-system.h diff --git a/configure.ac b/configure.ac index 87e1e908f..8a335a415 100644 --- a/configure.ac +++ b/configure.ac @@ -68,6 +68,7 @@ CLUTTER_MIN_VERSION=1.7.5 GOBJECT_INTROSPECTION_MIN_VERSION=0.10.1 GJS_MIN_VERSION=1.29.15 MUTTER_MIN_VERSION=3.0.0 +FOLKS_MIN_VERSION=0.5.2 GTK_MIN_VERSION=3.0.0 GIO_MIN_VERSION=2.29.10 LIBECAL_MIN_VERSION=2.32.0 @@ -82,6 +83,7 @@ STARTUP_NOTIFICATION_MIN_VERSION=0.11 PKG_CHECK_MODULES(GNOME_SHELL, gio-2.0 >= $GIO_MIN_VERSION gio-unix-2.0 dbus-glib-1 libxml-2.0 gtk+-3.0 >= $GTK_MIN_VERSION + folks >= $FOLKS_MIN_VERSION libmutter >= $MUTTER_MIN_VERSION gjs-internals-1.0 >= $GJS_MIN_VERSION libgnome-menu-3.0 $recorder_modules gconf-2.0 diff --git a/data/theme/gnome-shell.css b/data/theme/gnome-shell.css index 313d0b232..fbf9a589f 100644 --- a/data/theme/gnome-shell.css +++ b/data/theme/gnome-shell.css @@ -673,6 +673,11 @@ StTooltip StLabel { -shell-grid-item-size: 118px; } +.contact-grid { + spacing: 36px; + -shell-grid-item-size: 272px; /* 2 * -shell-grid-item-size + spacing */ +} + .icon-grid .overview-icon { icon-size: 96px; } @@ -739,11 +744,57 @@ StTooltip StLabel { text-align: center; } +.contact { + width: 272px; /* Same width as two normal results + spacing */ + height: 118px; /* Aspect ratio = 1.75. Normal US business card ratio */ + border-radius: 4px; + padding: 3px; + border: 1px rgba(0,0,0,0); + transition-duration: 100; +} + +.contact-content { + border-radius: 2px; + padding: 8px; + width: 232px; + height: 84px; + background-color: white; + color: black; + text-align: center; +} + +.contact-icon { + border-radius: 4px; +} + +.contact-details { + padding: 6px 8px 11px 8px; +} + +.contact-details-alias { + font-size: 16px; + padding-bottom: 11px; +} + +.contact-details-status { + font-size: 11pt; +} + +.contact-details-status-icon { + padding-right: 2px; +} + +.contact:hover { + background-color: rgba(255,255,255,0.1); + transition-duration: 100; +} + .app-well-app.running > .overview-icon { text-shadow: black 0px 2px 2px; background-image: url("running-indicator.svg"); } +.contact:selected, .app-well-app:selected > .overview-icon, .search-result-content:selected > .overview-icon { background-color: rgba(255,255,255,0.33); @@ -757,6 +808,7 @@ StTooltip StLabel { transition-duration: 100; } +.contact:focus, .app-well-app:focus > .overview-icon, .search-result-content:focus > .overview-icon { border: 1px solid #cccccc; diff --git a/js/Makefile.am b/js/Makefile.am index 25af72552..0351f82d0 100644 --- a/js/Makefile.am +++ b/js/Makefile.am @@ -22,6 +22,7 @@ nobase_dist_js_DATA = \ ui/autorunManager.js \ ui/boxpointer.js \ ui/calendar.js \ + ui/contactDisplay.js \ ui/ctrlAltTab.js \ ui/dash.js \ ui/dateMenu.js \ diff --git a/js/ui/contactDisplay.js b/js/ui/contactDisplay.js new file mode 100644 index 000000000..10048af68 --- /dev/null +++ b/js/ui/contactDisplay.js @@ -0,0 +1,179 @@ +/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ + +const Folks = imports.gi.Folks +const Lang = imports.lang; +const Meta = imports.gi.Meta; +const Shell = imports.gi.Shell; +const St = imports.gi.St; + +const Util = imports.misc.util; +const IconGrid = imports.ui.iconGrid; +const Search = imports.ui.search; +const SearchDisplay = imports.ui.searchDisplay; + +const MAX_SEARCH_RESULTS_ROWS = 1; +const ICON_SIZE = 81; + +function launchContact(id) { + Util.spawn(['gnome-contacts', '-i', id]); +} + + +/* This class represents a shown contact search result in the overview */ +function Contact(id) { + this._init(id); +} + +Contact.prototype = { + _init: function(id) { + this.individual = Shell.ContactSystem.get_default().get_individual(id); + + this.actor = new St.Bin({ style_class: 'contact', + reactive: true, + track_hover: true }); + + let content = new St.BoxLayout( { style_class: 'contact-content', + vertical: false }); + this.actor.set_child(content); + + let icon = new St.Icon({ icon_type: St.IconType.FULLCOLOR, + icon_size: ICON_SIZE, + style_class: 'contact-icon' }); + if (this.individual.avatar != null) + icon.gicon = this.individual.avatar; + else + icon.icon_name = 'avatar-default'; + + content.add(icon, { x_fill: true, + y_fill: false, + x_align: St.Align.START, + y_align: St.Align.MIDDLE }); + + let details = new St.BoxLayout({ style_class: 'contact-details', + vertical: true }); + content.add(details, { x_fill: true, + y_fill: false, + x_align: St.Align.START, + y_align: St.Align.MIDDLE }); + + let aliasText = this.individual.alias || _("Unknown"); + let aliasLabel = new St.Label({ text: aliasText, + style_class: 'contact-details-alias' }); + details.add(aliasLabel, { x_fill: true, + y_fill: false, + x_align: St.Align.START, + y_align: St.Align.START }); + + let presence = this._createPresence(this.individual.presence_type); + details.add(presence, { x_fill: false, + y_fill: true, + x_align: St.Align.START, + y_align: St.Align.END }); + }, + + _createPresence: function(presence) { + let text; + let iconName; + + switch(presence) { + case Folks.PresenceType.AVAILABLE: + text = _("Available"); + iconName = 'user-available'; + break; + case Folks.PresenceType.AWAY: + case Folks.PresenceType.EXTENDED_AWAY: + text = _("Away"); + iconName = 'user-away'; + break; + case Folks.PresenceType.BUSY: + text = _("Busy"); + iconName = 'user-busy'; + break; + default: + text = _("Offline"); + iconName = 'user-offline'; + } + + let icon = new St.Icon({ icon_name: iconName, + icon_type: St.IconType.FULLCOLOR, + icon_size: 16, + style_class: 'contact-details-status-icon' }); + let label = new St.Label({ text: text }); + + let box = new St.BoxLayout({ vertical: false, + style_class: 'contact-details-status' }); + box.add(icon, { x_fill: true, + y_fill: false, + x_align: St.Align.START, + y_align: St.Align.START }); + + box.add(label, { x_fill: true, + y_fill: false, + x_align: St.Align.END, + y_align: St.Align.START }); + + return box; + }, + + createIcon: function(size) { + let tc = St.TextureCache.get_default(); + let icon = this.individual.avatar; + + if (icon != null) { + return tc.load_gicon(null, icon, size); + } else { + return tc.load_icon_name(null, 'avatar-default', St.IconType.FULLCOLOR, size); + } + }, +}; + + +/* Searches for and returns contacts */ +function ContactSearchProvider() { + this._init(); +} + +ContactSearchProvider.prototype = { + __proto__: Search.SearchProvider.prototype, + + _init: function() { + Search.SearchProvider.prototype._init.call(this, _("CONTACTS")); + this._contactSys = Shell.ContactSystem.get_default(); + }, + + getResultMeta: function(id) { + let contact = new Contact(id); + return { 'id': id, + 'name': contact.alias, + 'createIcon': function(size) { + return contact.createIcon(size); + } + }; + }, + + getInitialResultSet: function(terms) { + return this._contactSys.initial_search(terms); + }, + + getSubsearchResultSet: function(previousResults, terms) { + return this._contactSys.subsearch(previousResults, terms); + }, + + createResultActor: function(resultMeta, terms) { + let contact = new Contact(resultMeta.id); + return contact.actor; + }, + + createResultContainerActor: function() { + let grid = new IconGrid.IconGrid({ rowLimit: MAX_SEARCH_RESULTS_ROWS, + xAlign: St.Align.START }); + grid.actor.style_class = 'contact-grid'; + + let actor = new SearchDisplay.GridSearchResults(this, grid); + return actor; + }, + + activateResult: function(id, params) { + launchContact(id); + } +}; diff --git a/js/ui/overview.js b/js/ui/overview.js index 9da431a13..9555a710e 100644 --- a/js/ui/overview.js +++ b/js/ui/overview.js @@ -11,6 +11,7 @@ const Shell = imports.gi.Shell; const Gdk = imports.gi.Gdk; const AppDisplay = imports.ui.appDisplay; +const ContactDisplay = imports.ui.contactDisplay; const Dash = imports.ui.dash; const DND = imports.ui.dnd; const DocDisplay = imports.ui.docDisplay; @@ -211,6 +212,7 @@ Overview.prototype = { this._viewSelector.addSearchProvider(new AppDisplay.SettingsSearchProvider()); this._viewSelector.addSearchProvider(new PlaceDisplay.PlaceSearchProvider()); this._viewSelector.addSearchProvider(new DocDisplay.DocSearchProvider()); + this._viewSelector.addSearchProvider(new ContactDisplay.ContactSearchProvider()); // TODO - recalculate everything when desktop size changes this._dash = new Dash.Dash(); diff --git a/src/Makefile.am b/src/Makefile.am index 73e56dfee..3ff043f61 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -102,6 +102,7 @@ shell_public_headers_h = \ shell-app-system.h \ shell-app-usage.h \ shell-arrow.h \ + shell-contact-system.h \ shell-doc-system.h \ shell-embedded-window.h \ shell-generic-container.h \ @@ -137,6 +138,7 @@ libgnome_shell_la_SOURCES = \ shell-app-system.c \ shell-app-usage.c \ shell-arrow.c \ + shell-contact-system.c \ shell-doc-system.c \ shell-embedded-window.c \ shell-generic-container.c \ @@ -269,7 +271,7 @@ libgnome_shell_la_LIBADD = \ libgnome_shell_la_CPPFLAGS = $(gnome_shell_cflags) Shell-0.1.gir: libgnome-shell.la St-1.0.gir -Shell_0_1_gir_INCLUDES = Clutter-1.0 ClutterX11-1.0 Meta-3.0 TelepathyGLib-0.12 TelepathyLogger-0.2 Soup-2.4 GMenu-3.0 NetworkManager-1.0 NMClient-1.0 +Shell_0_1_gir_INCLUDES = Clutter-1.0 ClutterX11-1.0 Meta-3.0 TelepathyGLib-0.12 TelepathyLogger-0.2 Soup-2.4 GMenu-3.0 NetworkManager-1.0 NMClient-1.0 Folks-0.6 Shell_0_1_gir_CFLAGS = $(libgnome_shell_la_CPPFLAGS) -I $(srcdir) Shell_0_1_gir_LIBS = libgnome-shell.la Shell_0_1_gir_FILES = $(libgnome_shell_la_gir_sources) diff --git a/src/shell-contact-system.c b/src/shell-contact-system.c new file mode 100644 index 000000000..971a16499 --- /dev/null +++ b/src/shell-contact-system.c @@ -0,0 +1,350 @@ +/* This implements a complete suite for caching and searching contacts in the + * Shell. We retrieve contacts from libfolks asynchronously and we search + * these for display to the user. */ + +#include "shell-contact-system.h" + +#include +#include +#include +#include +#include + +#include "shell-global.h" +#include "shell-util.h" +#include "st.h" + +G_DEFINE_TYPE (ShellContactSystem, shell_contact_system, G_TYPE_OBJECT); + +#define ALIAS_PREFIX_MATCH_WEIGHT 100 +#define ALIAS_SUBSTRING_MATCH_WEIGHT 90 +#define IM_PREFIX_MATCH_WEIGHT 10 +#define IM_SUBSTRING_MATCH_WEIGHT 5 + + +/* Callbacks */ + +static void +prepare_individual_aggregator_cb (GObject *obj, + GAsyncResult *res, + gpointer user_data) +{ + FolksIndividualAggregator *aggregator = FOLKS_INDIVIDUAL_AGGREGATOR (obj); + + folks_individual_aggregator_prepare_finish (aggregator, res, NULL); +} + + +/* Internal stuff */ + +typedef struct { + gchar *key; + guint weight; +} ContactSearchResult; + +struct _ShellContactSystemPrivate { + FolksIndividualAggregator *aggregator; +}; + +static void +shell_contact_system_constructed (GObject *obj) +{ + ShellContactSystem *self = SHELL_CONTACT_SYSTEM (obj); + + G_OBJECT_CLASS (shell_contact_system_parent_class)->constructed (obj); + + /* We intentionally do not care about the "individuals-changed" signal, as + * we don't intend to update searches after they've been performed. + * Therefore, we will simply retrieve the "individuals" property which + * represents a snapshot of the individuals in the aggregator. + */ + self->priv->aggregator = folks_individual_aggregator_new (); + folks_individual_aggregator_prepare (self->priv->aggregator, prepare_individual_aggregator_cb, NULL); +} + +static void +shell_contact_system_finalize (GObject *obj) +{ + ShellContactSystem *self = SHELL_CONTACT_SYSTEM (obj); + + g_object_unref (self->priv->aggregator); + + G_OBJECT_CLASS (shell_contact_system_parent_class)->finalize (obj); +} + +static void +shell_contact_system_init (ShellContactSystem *self) +{ + self->priv = G_TYPE_INSTANCE_GET_PRIVATE (self, SHELL_TYPE_CONTACT_SYSTEM, ShellContactSystemPrivate); +} + +static void +shell_contact_system_class_init (ShellContactSystemClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS (klass); + + object_class->constructed = shell_contact_system_constructed; + object_class->finalize = shell_contact_system_finalize; + + g_type_class_add_private (object_class, sizeof (ShellContactSystemPrivate)); +} + +/** + * normalize_terms: + * @terms: (element-type utf8): Input search terms + * + * Returns: (element-type utf8) (transfer full): Unicode-normalized and lowercased terms + */ +static GSList * +normalize_terms (GSList *terms) +{ + GSList *normalized_terms = NULL; + GSList *iter; + for (iter = terms; iter; iter = iter->next) + { + const char *term = iter->data; + normalized_terms = g_slist_prepend (normalized_terms, shell_util_normalize_and_casefold (term)); + } + return normalized_terms; +} + +static guint +do_match (ShellContactSystem *self, + FolksIndividual *individual, + GSList *terms) +{ + GSList *term_iter; + guint weight = 0; + + char *alias = shell_util_normalize_and_casefold (folks_alias_details_get_alias (FOLKS_ALIAS_DETAILS (individual))); + + GeeMultiMap *im_addr_map = folks_im_details_get_im_addresses (FOLKS_IM_DETAILS (individual)); + GeeCollection *im_addrs = gee_multi_map_get_values (im_addr_map); + GeeIterator *im_addrs_iter; + + gboolean have_alias_prefix = FALSE; + gboolean have_alias_substring = FALSE; + + gboolean have_im_prefix = FALSE; + gboolean have_im_substring = FALSE; + + for (term_iter = terms; term_iter; term_iter = term_iter->next) + { + const char *term = term_iter->data; + const char *p; + + /* Match on alias */ + p = strstr (alias, term); + if (p == alias) + have_alias_prefix = TRUE; + else if (p != NULL) + have_alias_substring = TRUE; + + /* Match on one or more IM addresses */ + im_addrs_iter = gee_iterable_iterator (GEE_ITERABLE (im_addrs)); + + while (gee_iterator_next (im_addrs_iter)) + { + const gchar *addr = gee_iterator_get (im_addrs_iter); + + p = strstr (addr, term); + if (p == addr) + have_im_prefix = TRUE; + else if (p != NULL) + have_im_substring = TRUE; + } + + g_object_unref (im_addrs_iter); + } + + if (have_alias_prefix) + weight += ALIAS_PREFIX_MATCH_WEIGHT; + else if (have_alias_substring) + weight += ALIAS_SUBSTRING_MATCH_WEIGHT; + + if (have_im_prefix) + weight += IM_PREFIX_MATCH_WEIGHT; + else if (have_im_substring) + weight += IM_SUBSTRING_MATCH_WEIGHT; + + g_free (alias); + g_object_unref (im_addrs); + + return weight; +} + +static gint +compare_results (gconstpointer a, + gconstpointer b) +{ + ContactSearchResult *first = (ContactSearchResult *) a; + ContactSearchResult *second = (ContactSearchResult *) b; + + if (first->weight > second->weight) + return 1; + else if (first->weight < second->weight) + return -1; + else + return 0; +} + +static void +free_result (gpointer data, + gpointer user_data) +{ + g_slice_free (ContactSearchResult, data); +} + +/* modifies and frees @results */ +static GSList * +sort_and_prepare_results (GSList *results) +{ + GSList *iter; + GSList *sorted_results = NULL; + + results = g_slist_sort (results, compare_results); + + for (iter = results; iter; iter = iter->next) + { + ContactSearchResult *result = iter->data; + gchar *id = result->key; + sorted_results = g_slist_prepend (sorted_results, id); + } + + g_slist_foreach (results, (GFunc) free_result, NULL); + + return sorted_results; +} + + +/* Methods */ + +/** + * shell_contact_system_get_default: + * + * Return Value: (transfer none): The global #ShellContactSystem singleton + */ +ShellContactSystem * +shell_contact_system_get_default (void) +{ + static ShellContactSystem *instance = NULL; + + if (instance == NULL) + instance = g_object_new (SHELL_TYPE_CONTACT_SYSTEM, NULL); + + return instance; +} + +/** + * shell_contact_system_get_all: + * @self: A #ShellContactSystem + * + * Returns: (transfer none): All individuals + */ +GeeMap * +shell_contact_system_get_all (ShellContactSystem *self) +{ + GeeMap *individuals; + + g_return_val_if_fail (SHELL_IS_CONTACT_SYSTEM (self), NULL); + + individuals = folks_individual_aggregator_get_individuals (self->priv->aggregator); + + return individuals; +} + +/** + * shell_contact_system_get_individual: + * @self: A #ShellContactSystem + * @id: A #gchar with the ID of the FolksIndividual to be returned. + * + * Returns: (transfer full): A #FolksIndividual or NULL if @id could not be found. + */ +FolksIndividual * +shell_contact_system_get_individual (ShellContactSystem *self, + gchar *id) +{ + GeeMap *individuals; + gpointer key, value; + + key = (gpointer) id; + + g_return_val_if_fail (SHELL_IS_CONTACT_SYSTEM (self), NULL); + + individuals = folks_individual_aggregator_get_individuals (self->priv->aggregator); + + value = gee_map_get (individuals, key); + + return FOLKS_INDIVIDUAL (value); +} + +/** + * shell_contact_system_initial_search: + * @shell: A #ShellContactSystem + * @terms: (element-type utf8): List of terms, logical AND + * + * Search through contacts for the given search terms. + * + * Returns: (transfer container) (element-type utf8): List of contact + * identifiers + */ +GSList * +shell_contact_system_initial_search (ShellContactSystem *self, + GSList *terms) +{ + FolksIndividual *individual; + GSList *results = NULL; + GeeMap *individuals = NULL; + ContactSearchResult *result; + GeeMapIterator *iter; + gpointer key; + guint weight; + GSList *normalized_terms = normalize_terms (terms); + + g_return_val_if_fail (SHELL_IS_CONTACT_SYSTEM (self), NULL); + + individuals = folks_individual_aggregator_get_individuals (self->priv->aggregator); + + iter = gee_map_map_iterator (individuals); + + while (gee_map_iterator_next (iter)) + { + individual = gee_map_iterator_get_value (iter); + weight = do_match (self, individual, normalized_terms); + + if (weight != 0) + { + key = gee_map_iterator_get_key (iter); + + result = g_slice_new (ContactSearchResult); + result->key = (gchar *) key; + result->weight = weight; + + results = g_slist_append (results, result); + } + + g_object_unref (individual); + } + + return sort_and_prepare_results (results); +} + +/** + * shell_contact_system_subsearch: + * @shell: A #ShellContactSystem + * @previous_results: (element-type utf8): List of previous results + * @terms: (element-type utf8): List of terms, logical AND + * + * Search through a previous result set; for more information see + * js/ui/search.js. + * + * Returns: (transfer container) (element-type utf8): List of contact + * identifiers + */ +GSList * +shell_contact_system_subsearch (ShellContactSystem *self, + GSList *previous_results, + GSList *terms) +{ + return shell_contact_system_initial_search (self, terms); +} diff --git a/src/shell-contact-system.h b/src/shell-contact-system.h new file mode 100644 index 000000000..def382df7 --- /dev/null +++ b/src/shell-contact-system.h @@ -0,0 +1,50 @@ +/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*- */ +#ifndef __SHELL_CONTACT_SYSTEM_H__ +#define __SHELL_CONTACT_SYSTEM_H__ + +#include +#include +#include + +#define SHELL_TYPE_CONTACT_SYSTEM (shell_contact_system_get_type ()) +#define SHELL_CONTACT_SYSTEM(obj) (G_TYPE_CHECK_INSTANCE_CAST ((obj), SHELL_TYPE_CONTACT_SYSTEM, ShellContactSystem)) +#define SHELL_CONTACT_SYSTEM_CLASS(klass) (G_TYPE_CHECK_CLASS_CAST ((klass), SHELL_TYPE_CONTACT_SYSTEM, ShellContactSystemClass)) +#define SHELL_IS_CONTACT_SYSTEM(obj) (G_TYPE_CHECK_INSTANCE_TYPE ((obj), SHELL_TYPE_CONTACT_SYSTEM)) +#define SHELL_IS_CONTACT_SYSTEM_CLASS(klass) (G_TYPE_CHECK_CLASS_TYPE ((klass), SHELL_TYPE_CONTACT_SYSTEM)) +#define SHELL_CONTACT_SYSTEM_GET_CLASS(obj) (G_TYPE_INSTANCE_GET_CLASS ((obj), SHELL_TYPE_CONTACT_SYSTEM, ShellContactSystemClass)) + +typedef struct _ShellContactSystem ShellContactSystem; +typedef struct _ShellContactSystemClass ShellContactSystemClass; +typedef struct _ShellContactSystemPrivate ShellContactSystemPrivate; + +struct _ShellContactSystem +{ + GObject parent; + + ShellContactSystemPrivate *priv; +}; + +struct _ShellContactSystemClass +{ + GObjectClass parent_class; +}; + +GType shell_contact_system_get_type (void) G_GNUC_CONST; + +/* Methods */ + +ShellContactSystem * shell_contact_system_get_default (void); + +GeeMap *shell_contact_system_get_all (ShellContactSystem *self); + +FolksIndividual *shell_contact_system_get_individual (ShellContactSystem *self, + gchar *id); + +GSList * shell_contact_system_initial_search (ShellContactSystem *shell, + GSList *terms); + +GSList * shell_contact_system_subsearch (ShellContactSystem *shell, + GSList *previous_results, + GSList *terms); + +#endif /* __SHELL_CONTACT_SYSTEM_H__ */ diff --git a/tools/build/gnome-shell.modules b/tools/build/gnome-shell.modules index 91b79b20a..51cef3205 100644 --- a/tools/build/gnome-shell.modules +++ b/tools/build/gnome-shell.modules @@ -16,6 +16,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -320,8 +352,10 @@ + +