Ctrl K
ComunidadGuías…

src/libs/collect.c

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
    This file is part of darktable,
    Copyright (C) 2010-2026 darktable developers.

    darktable 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 3 of the License, or
    (at your option) any later version.

    darktable 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 darktable.  If not, see <http://www.gnu.org/licenses/>.
*/

#include "common/gdk_event_utils.h"

#include "libs/collect.h"
#include "bauhaus/bauhaus.h"
#include "common/collection.h"
#include "common/collection_navigation.h"
#include "common/darktable.h"
#include "common/datetime.h"
#include "common/debug.h"
#include "common/film.h"
#include "common/map_locations.h"
#include "common/metadata.h"
#include "common/utility.h"
#include "control/conf.h"
#include "control/control.h"
#include "dtgtk/button.h"
#include "dtgtk/thumbtable.h"
#include "gui/accelerators.h"
#include "gui/gtk.h"
#include "gui/preferences_dialogs.h"
#include "libs/lib.h"
#include "libs/lib_api.h"
#include "views/view.h"

#include <glib-2.0/gio/gmenu.h>
#include <glib-2.0/glib-object.h>
#include <glib-2.0/glib.h>

#ifndef _WIN32
#include <gio/gunixmounts.h>
#endif

#ifdef GDK_WINDOWING_QUARTZ
#include "osx/osx.h"
#endif

DT_MODULE(3)

#define MAX_RULES 10

#define PARAM_STRING_SIZE 256 // FIXME: is this enough !?

typedef struct _datetime_range_t
{
  GTimeSpan nb1;
  GTimeSpan nb2;
  char *operator;
  GtkTreePath *path1;
  GtkTreePath *path2;
} _datetime_range_t;

typedef struct dt_lib_collect_rule_t
{
  int num;
  GtkWidget *hbox;
  GtkWidget *combo;
  GtkWidget *text;
  GtkWidget *button;
  gboolean typing;
  gchar *searchstring;
  gboolean startwildcard;
  gboolean sensitive;
  _datetime_range_t datetime_range;
  GtkTreePath *expanded_path;
} dt_lib_collect_rule_t;

typedef struct dt_lib_collect_t
{
  dt_lib_collect_rule_t rule[MAX_RULES];
  int active_rule;
  int nb_rules;

  GtkTreeView *view;
  int view_rule;

  GtkTreeModel *treefilter;
  GtkTreeModel *listfilter;

  gboolean singleclick;
  struct dt_lib_collect_params_t *params;
#ifdef _WIN32
  GVolumeMonitor *vmonitor;
#else
  GUnixMountMonitor *vmonitor;
#endif

  gboolean inited;

  GtkWidget *history_box, *history_button;
  GtkWidget *back, *forward;

  GSimpleActionGroup *action_group;
} dt_lib_collect_t;

typedef struct dt_lib_collect_params_rule_t
{
  uint32_t item : 16;
  uint32_t mode : 16;
  char string[PARAM_STRING_SIZE];
} dt_lib_collect_params_rule_t;

typedef struct dt_lib_collect_params_t
{
  uint32_t rules;
  dt_lib_collect_params_rule_t rule[MAX_RULES];
} dt_lib_collect_params_t;

typedef enum dt_lib_collect_cols_t
{
  DT_LIB_COLLECT_COL_TEXT = 0,
  DT_LIB_COLLECT_COL_ID,
  DT_LIB_COLLECT_COL_TOOLTIP,
  DT_LIB_COLLECT_COL_PATH,
  DT_LIB_COLLECT_COL_VISIBLE,
  DT_LIB_COLLECT_COL_UNREACHABLE,
  DT_LIB_COLLECT_COL_COUNT,
  DT_LIB_COLLECT_COL_INDEX,
  DT_LIB_COLLECT_NUM_COLS
} dt_lib_collect_cols_t;

typedef struct _range_t
{
  gchar *start;
  gchar *stop;
  GtkTreePath *path1;
  GtkTreePath *path2;
} _range_t;

typedef struct filmroll_row_t
{
  char *folder;
  char *value;
  int id;
  int count;
  int status;
} filmroll_row_t;

static void _lib_collect_gui_update(dt_lib_module_t *self);

static void _lib_folders_update_collection(const gchar *filmroll);

static void entry_changed(GtkEntry *entry, dt_lib_collect_rule_t *dr);

static void collection_updated(gpointer instance,
                               dt_collection_change_t query_change,
                               dt_collection_properties_t changed_property,
                               gpointer imgs,
                               int next,
                               gpointer self);

static void row_activated_with_event(GtkTreeView *view,
                                     GtkTreePath *path,
                                     GtkTreeViewColumn *col,
                                     GdkEventButton *event,
                                     dt_lib_collect_t *d);

static int _is_time_property(const int property);

static void _populate_collect_combo(GtkWidget *w);

static gint _sort_filmroll_by_display_name(gconstpointer a, gconstpointer b)
{
  const filmroll_row_t *ra = a;
  const filmroll_row_t *rb = b;
  return g_ascii_strcasecmp(ra->folder, rb->folder);
}

static gint _sort_filmroll_rows(gconstpointer a, gconstpointer b)
{
  const filmroll_row_t *ra = a;
  const filmroll_row_t *rb = b;
  return g_ascii_strcasecmp(ra->value, rb->value);
}

static gint _sort_filmroll_by_id(gconstpointer a, gconstpointer b)
{
  const filmroll_row_t *ra = a;
  const filmroll_row_t *rb = b;
  return ra->id - rb->id;
}

int last_state = 0;

const char *name(dt_lib_module_t *self)
{
  return _("collections");
}

const char *description(dt_lib_module_t *self)
{
  return _("define search criteria for images\n"
           "to be displayed or edited");
}

void *legacy_params(struct dt_lib_module_t *self,
                    const void *const old_params,
                    const size_t old_params_size,
                    const int old_version,
                    int *new_version,
                    size_t *new_size)
{
  if(old_version == 1)
  {
    /* from v1 to v2 we have reordered the filters */
    dt_lib_collect_params_t *o = (dt_lib_collect_params_t *)old_params;

    if(o->rules > MAX_RULES)
      /* preset is corrupted, return NULL and drop the preset */
      return NULL;

    dt_lib_collect_params_t *n = malloc(old_params_size);

    const int table[DT_COLLECTION_PROP_LAST] =
      {
        DT_COLLECTION_PROP_FILMROLL,
        DT_COLLECTION_PROP_FOLDERS,
        DT_COLLECTION_PROP_CAMERA,
        DT_COLLECTION_PROP_TAG,
        DT_COLLECTION_PROP_DAY,
        DT_COLLECTION_PROP_TIME,
        DT_COLLECTION_PROP_HISTORY,
        DT_COLLECTION_PROP_COLORLABEL,

        // spaces for the metadata, see metadata.h
        DT_COLLECTION_PROP_COLORLABEL + 1,
        DT_COLLECTION_PROP_COLORLABEL + 2,
        DT_COLLECTION_PROP_COLORLABEL + 3,
        DT_COLLECTION_PROP_COLORLABEL + 4,
        DT_COLLECTION_PROP_COLORLABEL + 5,

        DT_COLLECTION_PROP_LENS,
        DT_COLLECTION_PROP_FOCAL_LENGTH,
        DT_COLLECTION_PROP_ISO,
        DT_COLLECTION_PROP_APERTURE,
        DT_COLLECTION_PROP_EXPOSURE,
        DT_COLLECTION_PROP_ASPECT_RATIO,
        DT_COLLECTION_PROP_FILENAME,
        DT_COLLECTION_PROP_GEOTAGGING,
        DT_COLLECTION_PROP_GROUPING,
        DT_COLLECTION_PROP_LOCAL_COPY,
        DT_COLLECTION_PROP_MODULE,
        DT_COLLECTION_PROP_ORDER
      };

    n->rules = o->rules;

    for(int r=0; r<o->rules; r++)
    {
      n->rule[r].item = table[o->rule[r].item];
      n->rule[r].mode = o->rule[r].mode;
      memcpy(n->rule[r].string, o->rule[r].string, PARAM_STRING_SIZE);
    }

    *new_size = old_params_size;
    *new_version = 2;

    return (void *)n;
  }
  else if(old_version == 2)
  {
    /* from v2 to v3 we have added 4 new timestamp filters and 2 metadata filters */
    dt_lib_collect_params_t *old = (dt_lib_collect_params_t *)old_params;

    if(old->rules > MAX_RULES)
      /* preset is corrupted, return NULL and drop the preset */
      return NULL;

    dt_lib_collect_params_t *new = malloc(old_params_size);

    const int table[DT_COLLECTION_PROP_LAST] =
      {
        DT_COLLECTION_PROP_FILMROLL,
        DT_COLLECTION_PROP_FOLDERS,
        DT_COLLECTION_PROP_FILENAME,
        DT_COLLECTION_PROP_CAMERA,
        DT_COLLECTION_PROP_LENS,
        DT_COLLECTION_PROP_APERTURE,
        DT_COLLECTION_PROP_EXPOSURE,
        DT_COLLECTION_PROP_FOCAL_LENGTH,
        DT_COLLECTION_PROP_ISO,
        DT_COLLECTION_PROP_DAY,
        DT_COLLECTION_PROP_TIME,
        DT_COLLECTION_PROP_GEOTAGGING,
        DT_COLLECTION_PROP_ASPECT_RATIO,
        DT_COLLECTION_PROP_TAG,
        DT_COLLECTION_PROP_COLORLABEL,

        // spaces for the metadata, see metadata.h
        DT_COLLECTION_PROP_COLORLABEL + 1,
        DT_COLLECTION_PROP_COLORLABEL + 2,
        DT_COLLECTION_PROP_COLORLABEL + 3,
        DT_COLLECTION_PROP_COLORLABEL + 4,
        DT_COLLECTION_PROP_COLORLABEL + 5,

        DT_COLLECTION_PROP_GROUPING,
        DT_COLLECTION_PROP_LOCAL_COPY,
        DT_COLLECTION_PROP_HISTORY,
        DT_COLLECTION_PROP_MODULE,
        DT_COLLECTION_PROP_ORDER
      };

    new->rules = old->rules;

    for(int r = 0; r < old->rules; r++)
    {
      new->rule[r].item = table[old->rule[r].item];
      new->rule[r].mode = old->rule[r].mode;
      memcpy(new->rule[r].string, old->rule[r].string, PARAM_STRING_SIZE);
    }

    *new_size = old_params_size;
    *new_version = 3;

    return (void *)new;
  }

  return NULL;
}

void init_presets(dt_lib_module_t *self)
{
}

/* Update the params struct with active ruleset */
static void _lib_collect_update_params(dt_lib_collect_t *d)
{
  /* reset params */
  dt_lib_collect_params_t *p = d->params;
  memset(p, 0, sizeof(dt_lib_collect_params_t));

  /* for each active rule set update params */
  const int _a = dt_conf_get_int("plugins/lighttable/collect/num_rules") - 1;
  const int active = CLAMP(_a, 0, (MAX_RULES - 1));
  char confname[200] = { 0 };
  for(int i = 0; i <= active; i++)
  {
    /* get item */
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/item%1d", i);
    p->rule[i].item = dt_conf_get_int(confname);

    /* get mode */
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/mode%1d", i);
    p->rule[i].mode = dt_conf_get_int(confname);

    /* get string */
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/string%1d", i);
    const char *string = dt_conf_get_string_const(confname);
    if(string != NULL)
    {
      g_strlcpy(p->rule[i].string, string, PARAM_STRING_SIZE);
    }
  }

  p->rules = active + 1;
}

void *get_params(dt_lib_module_t *self, int *size)
{
  _lib_collect_update_params(self->data);

  /* allocate a copy of params to return, freed by caller */
  *size = sizeof(dt_lib_collect_params_t);
  void *p = malloc(*size);
  memcpy(p, ((dt_lib_collect_t *)self->data)->params, *size);
  return p;
}

int set_params(dt_lib_module_t *self,
               const void *params,
               const int size)
{
  /* update conf settings from params */
  dt_lib_collect_t *d = (dt_lib_collect_t *)self->data;
  dt_lib_collect_params_t *p = (dt_lib_collect_params_t *)params;
  char confname[200] = { 0 };

  gboolean reset_view_filter = FALSE;
  for(uint32_t i = 0; i < p->rules; i++)
  {
    /* set item */
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/item%1u", i);
    dt_conf_set_int(confname, p->rule[i].item);

    /* set mode */
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/mode%1u", i);
    dt_conf_set_int(confname, p->rule[i].mode);

    /* set string */
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/string%1u", i);
    dt_conf_set_string(confname, p->rule[i].string);

    /* if one of the rules is a rating filter, the view rating filter
     * will be reset to all */
    if(p->rule[i].item == DT_COLLECTION_PROP_RATING)
    {
      reset_view_filter = TRUE;
    }
  }

  if(reset_view_filter)
  {
    dt_view_filtering_reset(darktable.view_manager, FALSE);
  }

  /* set number of rules */
  g_strlcpy(confname, "plugins/lighttable/collect/num_rules", sizeof(confname));
  dt_conf_set_int(confname, p->rules);
  d->view_rule = -1;

  /* update internal params */
  _lib_collect_update_params(self->data);

  /* update ui */
  _lib_collect_gui_update(self);

  /* update view */
  dt_collection_update_query(darktable.collection,
                             DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
  return 0;
}


dt_view_type_flags_t views(dt_lib_module_t *self)
{
  return DT_VIEW_LIGHTTABLE | DT_VIEW_MAP | DT_VIEW_PRINT;
}

uint32_t container(dt_lib_module_t *self)
{
  return DT_UI_CONTAINER_PANEL_LEFT_CENTER;
}

static void _view_popup_menu_onSearchFilmroll(GSimpleAction *action,
                                              GVariant *parameter,
                                              gpointer user_data)
{
  GtkTreeView *treeview = GTK_TREE_VIEW(user_data);
  GtkWidget *win = dt_ui_main_window(darktable.gui->ui);

  GtkTreeSelection *selection;
  GtkTreeIter iter, child;
  GtkTreeModel *model;

  gchar *tree_path = NULL;
  gchar *new_path = NULL;

  model = gtk_tree_view_get_model(treeview);
  selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(treeview));
  if(!gtk_tree_selection_get_selected(selection, &model, &iter))
    return;

  child = iter;
  gtk_tree_model_iter_parent(model, &iter, &child);
  gtk_tree_model_get(model, &child, DT_LIB_COLLECT_COL_PATH, &tree_path, -1);

  GtkFileChooserNative *filechooser = gtk_file_chooser_native_new
    (_("update path to files"),
     GTK_WINDOW(win), GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER,
     _("_open"), _("_cancel"));

  if(tree_path != NULL)
    gtk_file_chooser_set_current_folder(GTK_FILE_CHOOSER(filechooser), tree_path);
  else
    goto error;

  // run the dialog
  if(gtk_native_dialog_run(GTK_NATIVE_DIALOG(filechooser)) == GTK_RESPONSE_ACCEPT)
  {
    gint id = -1;
    sqlite3_stmt *stmt;
    gchar *query = NULL;

    gchar *uri = NULL;
    uri = gtk_file_chooser_get_uri(GTK_FILE_CHOOSER(filechooser));
    new_path = g_filename_from_uri(uri, NULL, NULL);
    g_free(uri);
    if(new_path)
    {
      gchar *old = NULL;

      gchar *q_tree_path = g_strdup_printf("%s%%", tree_path);
      query = "SELECT id, folder FROM main.film_rolls WHERE folder LIKE ?1";
      DT_DEBUG_SQLITE3_PREPARE_V2(dt_database_get(darktable.db), query, -1, &stmt, NULL);
      DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 1, q_tree_path, -1, SQLITE_TRANSIENT);
      g_free(q_tree_path);
      q_tree_path = NULL;
      query = NULL;

      while(sqlite3_step(stmt) == SQLITE_ROW)
      {
        id = sqlite3_column_int(stmt, 0);
        old = (gchar *)sqlite3_column_text(stmt, 1);

        query = g_strdup("UPDATE main.film_rolls SET folder=?1 WHERE id=?2");

        gchar trailing[1024] = { 0 };
        gchar final[1024] = { 0 };

        if(g_strcmp0(old, tree_path))
        {
          g_strlcpy(trailing, old + strlen(tree_path) + 1, sizeof(trailing));
          g_snprintf(final, sizeof(final), "%s/%s", new_path, trailing);
        }
        else
        {
          g_strlcpy(final, new_path, sizeof(final));
        }

        sqlite3_stmt *stmt2;
        DT_DEBUG_SQLITE3_PREPARE_V2(dt_database_get(darktable.db), query, -1, &stmt2, NULL);
        DT_DEBUG_SQLITE3_BIND_TEXT(stmt2, 1, final, -1, SQLITE_STATIC);
        DT_DEBUG_SQLITE3_BIND_INT(stmt2, 2, id);
        sqlite3_step(stmt2);
        sqlite3_finalize(stmt2);
      }
      sqlite3_finalize(stmt);
      g_free(query);

      /* reset filter so that view isn't empty */
      dt_view_filtering_reset(darktable.view_manager, FALSE);

      // refresh the folders status
      dt_film_set_folder_status();

      /* update collection to view missing filmroll */
      _lib_folders_update_collection(new_path);

      DT_CONTROL_SIGNAL_RAISE(DT_SIGNAL_FILMROLLS_CHANGED);
    }
    else
      goto error;
  }
  g_free(tree_path);
  g_free(new_path);
  g_object_unref(filechooser);
  return;

error:
  /* Something wrong happened */
  g_object_unref(filechooser);
  dt_control_log(_("problem selecting new path for the filmroll in %s"), tree_path);

  g_free(tree_path);
  g_free(new_path);
}

static void _view_popup_menu_onRemove(GSimpleAction *action,
                                      GVariant *parameter,
                                      gpointer user_data)
{
  GtkTreeView *treeview = GTK_TREE_VIEW(user_data);

  GtkTreeSelection *selection;
  GtkTreeIter iter, model_iter;
  GtkTreeModel *model;

  /* Get info about the filmroll (or parent) selected */
  model = gtk_tree_view_get_model(treeview);
  selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(treeview));
  if(gtk_tree_selection_get_selected(selection, &model, &iter))
  {
    gchar *filmroll_path = NULL;
    gchar *fullq = NULL;

    gtk_tree_model_get(model, &iter, DT_LIB_COLLECT_COL_PATH, &filmroll_path, -1);
    char *escaped_filmroll_path = sqlite3_mprintf("%q", filmroll_path);

    /* Clean selected images, and add to the table those which are going to be deleted */
    DT_DEBUG_SQLITE3_EXEC(dt_database_get(darktable.db),
                          "DELETE FROM main.selected_images", NULL, NULL, NULL);

    // clang-format off
    fullq = g_strdup_printf
      ("INSERT INTO main.selected_images (imgid)"
       " SELECT id"
       " FROM main.images"
       " WHERE film_id IN (SELECT id FROM main.film_rolls WHERE folder LIKE '%s%%')",
       escaped_filmroll_path);
    // clang-format on

    DT_DEBUG_SQLITE3_EXEC(dt_database_get(darktable.db), fullq, NULL, NULL, NULL);
    g_free(filmroll_path);
    sqlite3_free(escaped_filmroll_path);

    if(dt_control_remove_images())
    {
      gtk_tree_model_filter_convert_iter_to_child_iter
        (GTK_TREE_MODEL_FILTER(model), &model_iter, &iter);

      if(gtk_tree_model_get_flags(model) == GTK_TREE_MODEL_LIST_ONLY)
      {
        gtk_list_store_remove
          (GTK_LIST_STORE(gtk_tree_model_filter_get_model(GTK_TREE_MODEL_FILTER(model))),
           &model_iter);
      }
      else
      {
        gtk_tree_store_remove
          (GTK_TREE_STORE(gtk_tree_model_filter_get_model(GTK_TREE_MODEL_FILTER(model))),
           &model_iter);
      }
    }

    g_free(fullq);
  }
}

static void _view_popup_menu(GtkWidget *treeview,
                             GtkTreePath *path,
                             dt_lib_collect_t *d)
{
  GActionGroup *action_group = gtk_widget_get_action_group(treeview, "collections");
  if(action_group == NULL)
  {
    GActionEntry action_entries[] =
    {
      { "update", _view_popup_menu_onSearchFilmroll, NULL, NULL },
      { "remove", _view_popup_menu_onRemove,         NULL, NULL }
    };

    action_group = G_ACTION_GROUP(g_simple_action_group_new());
    g_action_map_add_action_entries(G_ACTION_MAP(action_group),
                                    action_entries,
                                    G_N_ELEMENTS(action_entries),
                                    treeview);
    gtk_widget_insert_action_group(treeview, 
                                   "collections",
                                   G_ACTION_GROUP(action_group));
    g_object_unref(action_group);
  }

  GMenu *menu = g_menu_new();
  g_menu_append(menu, _("update path to files..."), "collections.update");
  g_menu_append(menu, _("remove..."), "collections.remove");

  // popup the menu
  GtkWidget *popover_menu = dt_gui_popover_menu_from_model(GTK_WIDGET(treeview), menu);
  g_object_unref(menu);
  if(path)
  {
    GdkRectangle rect;
    GtkTreeViewColumn *column = gtk_tree_view_get_column(GTK_TREE_VIEW(treeview), 0);
    gtk_tree_view_get_background_area(GTK_TREE_VIEW(treeview), path, column, &rect);
    gtk_popover_set_pointing_to(GTK_POPOVER(popover_menu), &rect);
  }
  gtk_popover_popup(GTK_POPOVER(popover_menu));
}

/* Claim the press only when the collections handler fully owns it, so a
 * plain click still reaches the treeview's internal bubble-phase gesture:
 * single-click expander toggles, focus grab and cursor placement keep
 * working (the pre-migration button-press-event handler returned FALSE for
 * those presses).  The presses that must claim are the ones the handler
 * resolves itself and that the internal gesture would fight: modifier
 * presses (ctrl/shift selection, shift-range, ctrl+shift view switch),
 * single-click mode, the MONTH rule's single-press activation, and the
 * folder/filmroll context menu.  Same selective pattern as in
 * gui/accelerators.c. */
static void _gesture_begin_claim(GtkGesture *gesture,
                                 GdkEventSequence *sequence,
                                 gpointer user_data)
{
  const dt_lib_collect_t *d = user_data;

  const GdkModifierType state =
    dt_gui_get_current_event_state(GTK_EVENT_CONTROLLER(gesture));

  const gboolean modifier = dt_modifier_is(state, GDK_SHIFT_MASK)
    || dt_modifier_is(state, GDK_CONTROL_MASK)
    || dt_modifier_is(state, GDK_SHIFT_MASK | GDK_CONTROL_MASK);
  const guint button = gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture));
  const gboolean folder_menu
    = (d->view_rule == DT_COLLECTION_PROP_FOLDERS
       || d->view_rule == DT_COLLECTION_PROP_FILMROLL)
      && button == GDK_BUTTON_SECONDARY && !modifier;

  if(modifier || d->singleclick || d->view_rule == DT_COLLECTION_PROP_MONTH || folder_menu)
    gtk_gesture_set_sequence_state(gesture, sequence, GTK_EVENT_SEQUENCE_CLAIMED);
}

/* Replicates GTK's private coords_are_over_arrow(): TRUE when the bin-window
 * coordinates hit the expander arrow of a parent row.  Needed to tell apart
 * a double-click on the arrow (the treeview's internal gesture toggles it on
 * every primary release, so ours must claim the second press and not add a
 * third toggle) from a double-click on the row body (the internal gesture
 * does not toggle there, ours must). */
static gboolean _coords_are_over_arrow(GtkTreeView *view, gint bin_x, gint bin_y)
{
  GtkTreePath *path = NULL;
  GtkTreeViewColumn *column = NULL;
  gint cell_x, cell_y;

  if(!gtk_tree_view_get_path_at_pos(view, bin_x, bin_y, &path, &column, &cell_x, &cell_y))
    return FALSE;

  GtkTreeIter iter;
  GtkTreeModel *model = gtk_tree_view_get_model(view);
  const gboolean over_parent = gtk_tree_model_get_iter(model, &iter, path)
    && gtk_tree_model_iter_has_child(model, &iter);
  const gboolean in_expander_column = column == gtk_tree_view_get_expander_column(view);
  if(!over_parent || !in_expander_column)
  {
    gtk_tree_path_free(path);
    return FALSE;
  }

  gboolean indent_expanders = TRUE;
  gint expander_size = 12, horizontal_separator = 0;
  gtk_widget_style_get(GTK_WIDGET(view),
                       "indent-expanders", &indent_expanders,
                       "horizontal-separator", &horizontal_separator,
                       "expander-size", &expander_size, NULL);

  /* GTK draws the arrow one expander width in per tree level below the
   * top level when indent-expanders is set: gtk_tree_view_get_arrow_xrange
   * offsets by expander_size * (rbtree_depth - 1) (the root rbtree has
   * depth 0), and the rbtree depth is path depth - 1. */
  const gint depth = gtk_tree_path_get_depth(path);
  const gint x = indent_expanders ? expander_size * (depth - 1) : 0;
  const gint x1 = x + horizontal_separator / 2;
  const gint x2 = x + expander_size;
  gtk_tree_path_free(path);

  return cell_x >= x1 && cell_x < x2;
}

static void view_onButtonPressed_cb(GtkGestureSingle *gesture, int n_press,
                                        double x, double y,
                                        dt_lib_collect_t *d)
{
  GtkWidget *treeview = dt_gui_get_widget(gesture);
  static GdkModifierType last_mod_state = 0;

  const GdkEvent *event = gtk_gesture_get_last_event(GTK_GESTURE(gesture), NULL);

  /* gesture coordinates are relative to the widget allocation, while
   * gtk_tree_view_get_path_at_pos() expects bin-window coordinates */
  gint bin_x, bin_y;
  gtk_tree_view_convert_widget_to_bin_window_coords(GTK_TREE_VIEW(treeview),
                                                    (gint)x, (gint)y, &bin_x, &bin_y);

  /* Get tree path for row that was clicked */
  GtkTreePath *path = NULL;
  const gboolean get_path = gtk_tree_view_get_path_at_pos(GTK_TREE_VIEW(treeview),
                                                          bin_x, bin_y,
                                                          &path, NULL, NULL, NULL);

  const GdkModifierType mod_state =
    dt_gui_get_current_event_state(GTK_EVENT_CONTROLLER(gesture));
  const guint button = gtk_gesture_single_get_current_button(gesture);
  const gboolean modifier = dt_modifier_is(mod_state, GDK_SHIFT_MASK)
    || dt_modifier_is(mod_state, GDK_CONTROL_MASK)
    || dt_modifier_is(mod_state, GDK_SHIFT_MASK | GDK_CONTROL_MASK);

  const gboolean plain_primary
    = button == GDK_BUTTON_PRIMARY && !modifier && !d->singleclick
      && d->view_rule != DT_COLLECTION_PROP_MONTH;
  const gboolean over_arrow = path && _coords_are_over_arrow(GTK_TREE_VIEW(treeview), bin_x, bin_y);

  /* the treeview's internal gesture toggles the expander on every primary
   * release over the arrow, so it owns all arrow toggles; ours must not add
   * a toggle for any n_press >= 2 primary press on the arrow (a double
   * click would otherwise get release1 + press2 + release2 = 3 toggles).
   * Only the second press of a fast pair is claimed, so the internal
   * gesture never sees it: a double-click then toggles exactly once (on the
   * first release) with no flicker, while every later press of a longer
   * burst is still handled by the internal gesture and keeps responding. */
  const gboolean arrow_second_press = n_press == 2 && plain_primary && over_arrow;
  if(arrow_second_press)
  {
    GdkEventSequence *sequence = gtk_gesture_single_get_current_sequence(GTK_GESTURE_SINGLE(gesture));
    gtk_gesture_set_sequence_state(GTK_GESTURE(gesture), sequence, GTK_EVENT_SEQUENCE_CLAIMED);
  }

  if((n_press >= 2 || d->singleclick) && path && !(n_press >= 2 && plain_primary && over_arrow))
  {
    if(mod_state == last_mod_state)
    {
      if(gtk_tree_view_row_expanded(GTK_TREE_VIEW(treeview), path))
        gtk_tree_view_collapse_row(GTK_TREE_VIEW(treeview), path);
      else
        gtk_tree_view_expand_row(GTK_TREE_VIEW(treeview), path, FALSE);
    }
    last_mod_state = mod_state;
  }

  // case of a range selection
  GtkTreeSelection *selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(treeview));
  if(get_path && dt_modifier_is(mod_state, GDK_SHIFT_MASK)
     && gtk_tree_selection_count_selected_rows(selection) > 0
     && (d->view_rule == DT_COLLECTION_PROP_DAY
         || d->view_rule == DT_COLLECTION_PROP_MONTH
         || _is_time_property(d->view_rule)
         || d->view_rule == DT_COLLECTION_PROP_APERTURE
         || d->view_rule == DT_COLLECTION_PROP_FOCAL_LENGTH
         || d->view_rule == DT_COLLECTION_PROP_ISO
         || d->view_rule == DT_COLLECTION_PROP_EXPOSURE
         || d->view_rule == DT_COLLECTION_PROP_ASPECT_RATIO
         || d->view_rule == DT_COLLECTION_PROP_RATING))
  {
    // range selection
    GList *sels = gtk_tree_selection_get_selected_rows(selection, NULL);
    GtkTreePath *path2 = (GtkTreePath *)sels->data;
    gtk_tree_selection_unselect_all(selection);
    if(gtk_tree_path_compare(path, path2) > 0)
      gtk_tree_selection_select_range(selection, path, path2);
    else
      gtk_tree_selection_select_range(selection, path2, path);
    g_list_free_full(sels, (GDestroyNotify)gtk_tree_path_free);

    row_activated_with_event(GTK_TREE_VIEW(treeview), path, NULL, (GdkEventButton *)event, d);

    gtk_tree_path_free(path);
    return;
  }

  if(path)
  {
    gtk_tree_selection_unselect_all(selection);
    gtk_tree_selection_select_path(selection, path);
  }

  // case of a context-menu (folder/filmroll)
  if(((d->view_rule == DT_COLLECTION_PROP_FOLDERS)
      || (d->view_rule == DT_COLLECTION_PROP_FILMROLL))
     && (n_press == 1 && button == GDK_BUTTON_SECONDARY)
     && !(dt_modifier_is(mod_state, GDK_SHIFT_MASK)
          || dt_modifier_is(mod_state, GDK_CONTROL_MASK)))
  {
    row_activated_with_event(GTK_TREE_VIEW(treeview), path, NULL, (GdkEventButton *)event, d);
    _view_popup_menu(treeview, path, d);

    if(path) gtk_tree_path_free(path);
    return;
  }

  // case of a activation
  if((!d->singleclick && n_press >= 2 && button == GDK_BUTTON_PRIMARY)
     || (d->singleclick && n_press == 1 && button == GDK_BUTTON_PRIMARY)
     || (!d->singleclick && n_press == 1 && button == GDK_BUTTON_PRIMARY && modifier)
     || (d->view_rule == DT_COLLECTION_PROP_MONTH
         && n_press == 1 && button == GDK_BUTTON_PRIMARY))
  {
    row_activated_with_event(GTK_TREE_VIEW(treeview), path, NULL, (GdkEventButton *)event, d);

    if(path) gtk_tree_path_free(path);
    return;
  }

  if(path) gtk_tree_path_free(path);
}

static gboolean view_onPopupMenu(GtkWidget *treeview, dt_lib_collect_t *d)
{
  if(d->view_rule != DT_COLLECTION_PROP_FOLDERS)
  {
    return FALSE;
  }

  _view_popup_menu(treeview, NULL, d);

  return TRUE; /* we handled this */
}

static dt_lib_collect_t *get_collect(dt_lib_collect_rule_t *r)
{
  dt_lib_collect_t *d =
    (dt_lib_collect_t *)(((char *)r) - r->num * sizeof(dt_lib_collect_rule_t));
  return d;
}

static void _scroll_to_position(GtkTreeView *treeview,
                                GtkTreePath *path,
                                GtkTreeViewColumn *column,
                                gboolean use_align,
                                float row_align,
                                float col_align)
{
  gtk_tree_view_scroll_to_cell(treeview, path, column, use_align, row_align, col_align);

  // Force value refresh of the vertical scrollbar.
  // This is a workaround for a (possible) GTK bug. When returning from darkroom
  // the treeview is not scrolled to the previous position.
  g_signal_emit_by_name(gtk_scrollable_get_vadjustment(GTK_SCROLLABLE(treeview)),
                                                       "value-changed");
}

static gboolean list_select(GtkTreeModel *model,
                            GtkTreePath *path,
                            GtkTreeIter *iter,
                            gpointer data)
{
  dt_lib_collect_rule_t *dr = (dt_lib_collect_rule_t *)data;
  dt_lib_collect_t *d = get_collect(dr);
  gchar *str = NULL;

  gtk_tree_model_get(model, iter, DT_LIB_COLLECT_COL_PATH, &str, -1);

  gchar *haystack = g_utf8_strdown(str, -1);
  gchar *needle = g_utf8_strdown(gtk_entry_get_text(GTK_ENTRY(dr->text)), -1);

  if(strcmp(haystack, needle) == 0)
  {
    gtk_tree_selection_select_path(gtk_tree_view_get_selection(d->view), path);
    _scroll_to_position(d->view, path, NULL, FALSE, 0.2, 0);
  }

  g_free(haystack);
  g_free(needle);
  g_free(str);

  return FALSE;
}

static gboolean range_select(GtkTreeModel *model,
                             GtkTreePath *path,
                             GtkTreeIter *iter,
                             gpointer data)
{
  _range_t *range = (_range_t *)data;
  gchar *str = NULL;

  gtk_tree_model_get(model, iter, DT_LIB_COLLECT_COL_PATH, &str, -1);

  gchar *haystack = g_utf8_strdown(str, -1);
  gchar *needle;
  if(range->path1)
    needle = g_utf8_strdown(range->stop, -1);
  else
    needle = g_utf8_strdown(range->start, -1);

  if(strcmp(haystack, needle) == 0)
  {
    if(range->path1)
    {
      range->path2 = gtk_tree_path_copy(path);
      g_free(haystack);
      g_free(needle);
      g_free(str);
      return TRUE;
    }
    else
      range->path1 = gtk_tree_path_copy(path);
  }

  g_free(haystack);
  g_free(needle);
  g_free(str);

  return FALSE;
}

static gboolean _datetime_range_select(GtkTreeModel *model,
                                       GtkTreePath *path,
                                       GtkTreeIter *iter,
                                       gpointer data)
{
  dt_lib_collect_rule_t *dr = (dt_lib_collect_rule_t *)data;
  gchar *str;

  gtk_tree_model_get(model, iter, DT_LIB_COLLECT_COL_PATH, &str, -1);
  const GTimeSpan nb = dt_datetime_exif_to_gtimespan(str);
  g_free(str);
  GTimeSpan target = dr->datetime_range.path1 == NULL
    ? dr->datetime_range.nb1
    : dr->datetime_range.nb2;

  if(nb >= target)
  {
    if(dr->datetime_range.path1)
      return TRUE;
    else
      dr->datetime_range.path1 = gtk_tree_path_copy(path);
  }
  dr->datetime_range.path2 = gtk_tree_path_copy(path);

  return FALSE;
}

int _combo_get_active_collection(GtkWidget *combo)
{
  return GPOINTER_TO_UINT(dt_bauhaus_combobox_get_data(combo)) - 1;
}

gboolean _combo_set_active_collection(GtkWidget *combo,
                                      const int property)
{
  const gboolean found = dt_bauhaus_combobox_set_from_value(combo, property + 1);
  // make sure we have a valid collection
  if(!found)
    dt_bauhaus_combobox_set_from_value(combo, DT_COLLECTION_PROP_FILMROLL + 1);
  return found;
}

static gboolean tree_expand(GtkTreeModel *model,
                            GtkTreePath *path,
                            GtkTreeIter *iter,
                            gpointer data)
{
  dt_lib_collect_rule_t *dr = (dt_lib_collect_rule_t *)data;
  gchar *str = NULL;
  gboolean expanded = FALSE;

  gtk_tree_model_get(model, iter, DT_LIB_COLLECT_COL_PATH, &str, -1);
  gchar *haystack = dr->sensitive ? g_strdup(str) : g_utf8_strdown(str, -1);
  gchar *needle = g_strdup(dr->searchstring);

  int collection = _combo_get_active_collection(dr->combo);
  switch(collection)
  {
    case DT_COLLECTION_PROP_TAG:
    case DT_COLLECTION_PROP_GEOTAGGING:
      if(g_str_has_suffix(needle, "*")) needle[strlen(needle) - 1] = '\0';
      if(g_str_has_suffix(needle, "|")) needle[strlen(needle) - 1] = '\0';
      if(g_str_has_suffix(haystack, "|")) haystack[strlen(haystack) - 1] = '\0';
      break;
    case DT_COLLECTION_PROP_FOLDERS:
      if(g_str_has_suffix(needle, "*")) needle[strlen(needle) - 1] = '\0';
      if(g_str_has_suffix(needle, "/")) needle[strlen(needle) - 1] = '\0';
      if(g_str_has_suffix(haystack, "/")) haystack[strlen(haystack) - 1] = '\0';
      break;
    case DT_COLLECTION_PROP_DAY:
    case DT_COLLECTION_PROP_TIME:
    case DT_COLLECTION_PROP_IMPORT_TIMESTAMP:
    case DT_COLLECTION_PROP_CHANGE_TIMESTAMP:
    case DT_COLLECTION_PROP_EXPORT_TIMESTAMP:
    case DT_COLLECTION_PROP_PRINT_TIMESTAMP:
      if(g_str_has_suffix(needle, ":")) needle[strlen(needle) - 1] = '\0';
      if(g_str_has_suffix(haystack, ":")) haystack[strlen(haystack) - 1] = '\0';
      break;
  }

  if(!needle || !needle[0])
  {
    //nothing to do, we keep the tree collapsed
  }
  else if((dr->startwildcard && g_strrstr(haystack, needle))
          || (!dr->startwildcard && g_str_has_prefix(haystack, needle)))
  {
    dr->expanded_path = gtk_tree_path_copy(path);
    expanded = TRUE;
  }
  g_free(haystack);
  g_free(needle);
  g_free(str);

  return expanded; // if we expanded the path, we can stop iteration (saves half on average)
}

static gboolean list_match_string(GtkTreeModel *model,
                                  GtkTreePath *path,
                                  GtkTreeIter *iter,
                                  gpointer data)
{
  dt_lib_collect_rule_t *dr = (dt_lib_collect_rule_t *)data;
  gchar *str = NULL;
  gboolean visible = FALSE;
  gboolean was_visible;
  gtk_tree_model_get(model, iter,
                     DT_LIB_COLLECT_COL_PATH, &str,
                     DT_LIB_COLLECT_COL_VISIBLE, &was_visible, -1);

  gchar *haystack = g_utf8_strdown(str, -1);
  const gchar *needle = dr->searchstring;

  const int property = _combo_get_active_collection(dr->combo);
  if(property == DT_COLLECTION_PROP_APERTURE
     || property == DT_COLLECTION_PROP_FOCAL_LENGTH
     || property == DT_COLLECTION_PROP_ISO
     || property == DT_COLLECTION_PROP_RATING)
  {
    // handle of numeric value, which can have some operator before the text
    visible = TRUE;
    gchar *operator, *number, *number2;
    dt_collection_split_operator_number(needle, &number, &number2, &operator);
    if(number)
    {
      const float nb1 = g_strtod(number, NULL);
      const float nb2 = g_strtod(haystack, NULL);
      if(operator && strcmp(operator, ">") == 0)
      {
        visible = (nb2 > nb1);
      }
      else if(operator && strcmp(operator, ">=") == 0)
      {
        visible = (nb2 >= nb1);
      }
      else if(operator && strcmp(operator, "<") == 0)
      {
        visible = (nb2 < nb1);
      }
      else if(operator && strcmp(operator, "<=") == 0)
      {
        visible = (nb2 <= nb1);
      }
      else if(operator && strcmp(operator, "<>") == 0)
      {
        visible = (nb1 != nb2);
      }
      else if(operator && number2 && strcmp(operator, "[]") == 0)
      {
        const float nb3 = g_strtod(number2, NULL);
        visible = (nb2 >= nb1 && nb2 <= nb3);
      }
      else
      {
        visible = (nb1 == nb2);
      }
    }
    g_free(operator);
    g_free(number);
    g_free(number2);
  }
  else if(property == DT_COLLECTION_PROP_FILENAME && strchr(needle,',') != NULL)
  {
    GList *list = dt_util_str_to_glist(",", needle);

    for(const GList *l = list; l; l = g_list_next(l))
    {
      const char *name = (char *)l->data;
      if((visible = (g_strrstr(haystack, name + (name[0]=='%')) != NULL))) break;
    }

    g_list_free_full(list, g_free);

  }
  else
  {
    if(needle[0] == '%')
      needle++;

    if(!needle[0])
    {
      // empty search string matches all
      visible = TRUE;
    }
    else if(!needle[1])
    {
      // single-char search, use faster strchr instead of strstr
      visible = (strchr(haystack, needle[0]) != NULL);
    }
    else
    {
      visible = (g_strrstr(haystack, needle) != NULL);
    }
  }

  g_free(haystack);
  g_free(str);

  if(visible != was_visible)
    gtk_list_store_set(GTK_LIST_STORE(model), iter,
                       DT_LIB_COLLECT_COL_VISIBLE, visible, -1);
  return FALSE;
}

static gboolean tree_match_string(GtkTreeModel *model,
                                  GtkTreePath *path,
                                  GtkTreeIter *iter,
                                  gpointer data)
{
  dt_lib_collect_rule_t *dr = (dt_lib_collect_rule_t *)data;
  gchar *str = NULL;
  gboolean cur_state, visible;

  gtk_tree_model_get(model, iter,
                     DT_LIB_COLLECT_COL_PATH, &str,
                     DT_LIB_COLLECT_COL_VISIBLE, &cur_state, -1);

  if(dr->typing == FALSE && !cur_state)
  {
    visible = TRUE;
  }
  else
  {
    gchar *haystack = dr->sensitive ? g_strdup(str) : g_utf8_strdown(str, -1);
    const int property = _combo_get_active_collection(dr->combo);
    if(_is_time_property(property) || property == DT_COLLECTION_PROP_DAY)
    {
      // handle of numeric value, which can have some operator before the text
      visible = TRUE;
      if(dr->datetime_range.nb1)
      {
        const GTimeSpan nb = dt_datetime_exif_to_gtimespan(haystack);

        if(!dr->datetime_range.operator)
          visible = (nb >= dr->datetime_range.nb1 && nb <= dr->datetime_range.nb2);
        else if(strcmp(dr->datetime_range.operator, ">") == 0)
          visible = (nb > dr->datetime_range.nb1);
        else if(strcmp(dr->datetime_range.operator, ">=") == 0)
          visible = (nb >= dr->datetime_range.nb1);
        else if(strcmp(dr->datetime_range.operator, "<") == 0)
          visible = (nb < dr->datetime_range.nb1);
        else if(strcmp(dr->datetime_range.operator, "<=") == 0)
          visible = (nb <= dr->datetime_range.nb1);
        else if(strcmp(dr->datetime_range.operator, "<>") == 0)
          visible = (nb != dr->datetime_range.nb1);
        else if(dr->datetime_range.nb2)
          visible = (nb >= dr->datetime_range.nb1 && nb <= dr->datetime_range.nb2);
      }
    }
    else if(!dr->searchstring || !dr->searchstring[0])
      visible = TRUE;
    else if(dr->startwildcard)
      visible = g_strrstr(haystack, dr->searchstring) != NULL;
    else
      visible = g_str_has_prefix(haystack, dr->searchstring);
    g_free(haystack);
  }

  g_free(str);

  gtk_tree_store_set(GTK_TREE_STORE(model), iter,
                     DT_LIB_COLLECT_COL_VISIBLE, visible, -1);
  return FALSE;
}

static gboolean tree_reveal_func(GtkTreeModel *model,
                                 GtkTreePath *path,
                                 GtkTreeIter *iter,
                                 gpointer data)
{
  gboolean state;
  GtkTreeIter parent, child = *iter;

  gtk_tree_model_get(model, iter, DT_LIB_COLLECT_COL_VISIBLE, &state, -1);
  if(!state) return FALSE;

  while(gtk_tree_model_iter_parent(model, &parent, &child))
  {
    gtk_tree_model_get(model, &parent, DT_LIB_COLLECT_COL_VISIBLE, &state, -1);
    gtk_tree_store_set(GTK_TREE_STORE(model), &parent,
                       DT_LIB_COLLECT_COL_VISIBLE, TRUE, -1);
    child = parent;
  }

  return FALSE;
}

static void tree_set_visibility(GtkTreeModel *model, gpointer data)
{
  gtk_tree_model_foreach(model, (GtkTreeModelForeachFunc)tree_match_string, data);

  gtk_tree_model_foreach(model, (GtkTreeModelForeachFunc)tree_reveal_func, NULL);
}

static void _lib_folders_update_collection(const gchar *filmroll)
{

  // remove from selected images where not in this query.
  sqlite3_stmt *stmt = NULL;
  const gchar *cquery = dt_collection_get_query(darktable.collection);
  if(cquery && cquery[0] != '\0')
  {
    gchar *complete_query = g_strdup_printf(
                          "DELETE FROM main.selected_images WHERE imgid NOT IN (%s)",
                          cquery);
    DT_DEBUG_SQLITE3_PREPARE_V2(dt_database_get(darktable.db),
                                complete_query, -1, &stmt, NULL);
    DT_DEBUG_SQLITE3_BIND_INT(stmt, 1, 0);
    DT_DEBUG_SQLITE3_BIND_INT(stmt, 2, -1);
    sqlite3_step(stmt);
    sqlite3_finalize(stmt);

    /* free allocated strings */
    g_free(complete_query);
  }

  /* raise signal of collection change, only if this is an original */
  if(!darktable.collection->clone)
  {
    dt_collection_memory_update();
    DT_CONTROL_SIGNAL_RAISE(DT_SIGNAL_COLLECTION_CHANGED,
                            DT_COLLECTION_CHANGE_NEW_QUERY,
                            DT_COLLECTION_PROP_UNDEF, (GList *)NULL, -1);
  }
}

static void set_properties(dt_lib_collect_rule_t *dr)
{
  const int property = _combo_get_active_collection(dr->combo);
  const gchar *text = gtk_entry_get_text(GTK_ENTRY(dr->text));

  char confname[200] = { 0 };
  snprintf(confname, sizeof(confname),
           "plugins/lighttable/collect/string%1d", dr->num);
  dt_conf_set_string(confname, text);
  snprintf(confname, sizeof(confname),
           "plugins/lighttable/collect/item%1d", dr->num);
  dt_conf_set_int(confname, property);
}

static GtkTreeModel *_create_filtered_model(GtkTreeModel *model,
                                            dt_lib_collect_rule_t *dr)
{
  GtkTreeModel *filter = NULL;
  GtkTreePath *path = NULL;

  if(_combo_get_active_collection(dr->combo) == DT_COLLECTION_PROP_FOLDERS)
  {
    // we search a common path to all the folders
    // we'll use it as root
    GtkTreeIter child, iter;
    int level = 0;

    while(gtk_tree_model_iter_n_children(model, level > 0 ? &iter : NULL) > 0)
    {
      if(level > 0)
      {
        sqlite3_stmt *stmt = NULL;
        gchar *pth = NULL;
        int id = -1;
        // Check if this path also matches a filmroll
        gtk_tree_model_get(model, &iter, DT_LIB_COLLECT_COL_PATH, &pth, -1);
        DT_DEBUG_SQLITE3_PREPARE_V2
          (dt_database_get(darktable.db),
           "SELECT id FROM main.film_rolls WHERE folder LIKE ?1", -1, &stmt, NULL);
        DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 1, pth, -1, SQLITE_TRANSIENT);
        if(sqlite3_step(stmt) == SQLITE_ROW) id = sqlite3_column_int(stmt, 0);
        sqlite3_finalize(stmt);

        g_free(pth);

        if(id != -1)
        {
          // we go back to the parent, in order to show this folder
          if(!gtk_tree_model_iter_parent(model, &child, &iter)) level = 0;
          iter = child;
          break;
        }
      }
      if(gtk_tree_model_iter_n_children(model, level > 0 ? &iter : NULL) != 1)
        break;

      gtk_tree_model_iter_children(model, &child, level > 0 ? &iter : NULL);
      iter = child;
      level++;
    }

    if(level > 0)
    {
      if(gtk_tree_model_iter_n_children(model, &iter) == 0
         && gtk_tree_model_iter_parent(model, &child, &iter))
      {
        path = gtk_tree_model_get_path(model, &child);
      }
      else
      {
        path = gtk_tree_model_get_path(model, &iter);
      }
    }
  }

  // Create filter and set virtual root
  filter = gtk_tree_model_filter_new(model, path);
  gtk_tree_path_free(path);

  gtk_tree_model_filter_set_visible_column(GTK_TREE_MODEL_FILTER(filter),
                                           DT_LIB_COLLECT_COL_VISIBLE);

  return filter;
}

static int string_array_length(char **list)
{
  int length = 0;
  for(; *list; list++) length++;
  return length;
}

// returns a NULL terminated array of path components
static char **split_path(const char *path)
{
  if(!path || !*path) return NULL;

  char **result;
  char **tokens = g_strsplit(path, G_DIR_SEPARATOR_S, -1);

#ifdef _WIN32

  if(! (g_ascii_isalpha(tokens[0][0]) && tokens[0][strlen(tokens[0]) - 1] == ':') )
  {
    g_strfreev(tokens);
    tokens = NULL;
  }

  result = tokens;

#else

  // there are size + 1 elements in tokens -- the final NULL! we want to ignore it.
  const unsigned int size = g_strv_length(tokens);

  result = malloc(sizeof(char *) * size);
  for(unsigned int i = 0; i < size; i++)
    result[i] = tokens[i + 1];

  g_free(tokens[0]);
  g_free(tokens);

#endif

  return result;
}

typedef struct name_key_tuple_t
{
  char *name, *collate_key;
  int count, status;
} name_key_tuple_t;

static void free_tuple(gpointer data)
{
  name_key_tuple_t *tuple = (name_key_tuple_t *)data;
  g_free(tuple->name);
  g_free(tuple->collate_key);
  free(tuple);
}

static gint _sort_folder_tag(gconstpointer a, gconstpointer b)
{
  const name_key_tuple_t *tuple_a = (const name_key_tuple_t *)a;
  const name_key_tuple_t *tuple_b = (const name_key_tuple_t *)b;

  return g_strcmp0(tuple_a->collate_key, tuple_b->collate_key);
}

// create a key such that  _("not tagged") & "darktable|" are coming first,
// and the rest is ordered such that sub tags are coming directly behind their parent
static char *tag_collate_key(char *tag)
{
  const size_t len = strlen(tag);
  char *result = g_malloc(len + 2);
  if(!g_strcmp0(tag, _("not tagged")))
    *result = ' ';  // 1
  else if(g_str_has_prefix(tag, "darktable|"))
    *result = '!';  // 2
  else
    *result = '"';  // 3
  memcpy(result + 1, tag, len + 1);
  for(char *iter = result + 1; *iter; iter++)
    if(*iter == '|') *iter = ' ';
  return result;
}


void tree_count_show(GtkTreeViewColumn *col,
                     GtkCellRenderer *renderer,
                     GtkTreeModel *model,
                     GtkTreeIter *iter,
                     gpointer data)
{
  gchar *name;
  guint count;

  gtk_tree_model_get(model, iter,
                     DT_LIB_COLLECT_COL_TEXT, &name,
                     DT_LIB_COLLECT_COL_COUNT, &count, -1);
  if(!count)
  {
    g_object_set(renderer, "text", name, NULL);
  }
  else
  {
    gchar *coltext = g_strdup_printf("%s (%u)", name, count);
    g_object_set(renderer, "text", coltext, NULL);
    g_free(coltext);
  }

  g_free(name);
}

static void _expand_select_tree_path(GtkTreePath *path1,
                                     GtkTreePath *path2,
                                     dt_lib_collect_t *d)
{
  GtkTreePath *p1 =
    gtk_tree_model_filter_convert_child_path_to_path(GTK_TREE_MODEL_FILTER(d->treefilter),
                                                     path1);
  if(!p1) return;

  GtkTreePath *p2 = path2
    ? gtk_tree_model_filter_convert_child_path_to_path(GTK_TREE_MODEL_FILTER(d->treefilter),
                                                       path2)
    : NULL;
  GtkTreePath *p3 = NULL;
  GtkTreeIter iter;
  if(gtk_tree_model_get_iter(d->treefilter, &iter, p1))
  {
    GtkTreeIter parent;
    if(gtk_tree_model_iter_parent(d->treefilter, &parent, &iter))
      p3 = gtk_tree_model_get_path(d->treefilter, &parent);
  }
  gtk_tree_view_expand_to_path(d->view, p3 ? p3 : p1);
  _scroll_to_position(d->view, p1, NULL, TRUE, 0.5, 0.5);

  if(path2)
    gtk_tree_selection_select_range(gtk_tree_view_get_selection(d->view), p1, p2);
  else
    gtk_tree_selection_select_path(gtk_tree_view_get_selection(d->view), p1);

  gtk_tree_path_free(p1);

  if(p2)
    gtk_tree_path_free(p2);
  if(p3)
    gtk_tree_path_free(p3);
}

static const char *UNCATEGORIZED_TAG = N_("uncategorized");

static void _update_parent_count(GtkTreeModel *model,
                                 GtkTreeIter *iter,
                                 const int count)
{
  GtkTreeIter parent = *iter;
  GtkTreeIter child;
  guint parentcount;

  do
  {
    gtk_tree_model_get(model, &parent,
                       DT_LIB_COLLECT_COL_COUNT, &parentcount, -1);

    gtk_tree_store_set(GTK_TREE_STORE(model), &parent,
                       DT_LIB_COLLECT_COL_COUNT, parentcount + count, -1);
    child = parent;
  } while (gtk_tree_model_iter_parent(model, &parent, &child));
}

static void _tree_view(dt_lib_collect_rule_t *dr)
{
  // update related list
  dt_lib_collect_t *d = get_collect(dr);
  const int property = _combo_get_active_collection(dr->combo);
  char *format_separator = "";

  switch(property)
  {
    case DT_COLLECTION_PROP_FOLDERS:
      format_separator = "%s" G_DIR_SEPARATOR_S;
      break;
    case DT_COLLECTION_PROP_TAG:
    case DT_COLLECTION_PROP_GEOTAGGING:
      format_separator = "%s|";
      break;
    case DT_COLLECTION_PROP_DAY:
    case DT_COLLECTION_PROP_MONTH:
    case DT_COLLECTION_PROP_TIME:
    case DT_COLLECTION_PROP_IMPORT_TIMESTAMP:
    case DT_COLLECTION_PROP_CHANGE_TIMESTAMP:
    case DT_COLLECTION_PROP_EXPORT_TIMESTAMP:
    case DT_COLLECTION_PROP_PRINT_TIMESTAMP:
      format_separator = "%s:";
      break;
  }

  set_properties(dr);

  GtkTreeModel *model =
    gtk_tree_model_filter_get_model(GTK_TREE_MODEL_FILTER(d->treefilter));
  // since we'll be inserting elements in the same order that we want
  // them displayed, there is no need for the overhead of having the
  // tree view sort itself
  gtk_tree_sortable_set_sort_column_id(GTK_TREE_SORTABLE(model),
                                       GTK_TREE_SORTABLE_UNSORTED_SORT_COLUMN_ID,
                                       GTK_SORT_ASCENDING);

  if(d->view_rule != property)
  {
    // tree creation/recreation
    sqlite3_stmt *stmt;
    GtkTreeIter uncategorized = { 0 };
    GtkTreeIter temp;

    g_object_ref(model);
    g_object_unref(d->treefilter);
    gtk_tree_view_set_model(GTK_TREE_VIEW(d->view), NULL);
    gtk_tree_store_clear(GTK_TREE_STORE(model));
    gtk_widget_hide(GTK_WIDGET(d->view));

    /* query construction */
    gchar *where_ext = dt_collection_get_extended_where(darktable.collection, dr->num);
    gchar *query = NULL;
    switch(property)
    {
      case DT_COLLECTION_PROP_FOLDERS:
        // clang-format off
        query = g_strdup_printf
          ("SELECT folder, film_rolls_id, COUNT(*) AS count, status"
           " FROM main.images AS mi"
           " JOIN (SELECT fr.id AS film_rolls_id, folder, status"
           "       FROM main.film_rolls AS fr"
           "       JOIN memory.film_folder AS ff"
           "       ON fr.id = ff.id)"
           "   ON film_id = film_rolls_id "
           " WHERE %s"
           " GROUP BY folder, film_rolls_id", where_ext);
          // clang-format on
        break;

      case DT_COLLECTION_PROP_TAG:
      {
        const gboolean is_insensitive =
          dt_conf_is_equal("plugins/lighttable/tagging/case_sensitivity", "insensitive");

        if(is_insensitive)
          // clang-format off
          query = g_strdup_printf
            ("SELECT name, 1 AS tagid, SUM(count) AS count"
             " FROM (SELECT tagid, COUNT(*) as count"
             "   FROM main.images AS mi"
             "   JOIN main.tagged_images"
             "     ON id = imgid "
             "   WHERE %s"
             "   GROUP BY tagid)"
             " JOIN (SELECT lower(name) AS name, id AS tag_id FROM data.tags)"
             "   ON tagid = tag_id"
             "   GROUP BY name", where_ext);
          // clang-format on
        else
          // clang-format off
          query = g_strdup_printf
            ("SELECT name, tagid, count"
             " FROM (SELECT tagid, COUNT(*) AS count"
             "  FROM main.images AS mi"
             "  JOIN main.tagged_images"
             "     ON id = imgid "
             "  WHERE %s"
             "  GROUP BY tagid)"
             " JOIN (SELECT name, id AS tag_id FROM data.tags)"
             "   ON tagid = tag_id"
             , where_ext);
          // clang-format on

        // clang-format off
        dt_util_str_cat
          (&query, " UNION ALL "
           "SELECT '%s' AS name, 0 as id, COUNT(*) AS count "
           "FROM main.images AS mi "
           "WHERE mi.id NOT IN"
           "  (SELECT DISTINCT imgid FROM main.tagged_images AS ti"
           "   WHERE ti.tagid NOT IN memory.darktable_tags)",
           _("not tagged"));
        // clang-format on
      }
      break;

      case DT_COLLECTION_PROP_GEOTAGGING:
        // clang-format off
        query = g_strdup_printf
          ("SELECT "
           " CASE WHEN mi.longitude IS NULL"
           "           OR mi.latitude IS null THEN \'%s\'"
           "      ELSE CASE WHEN ta.imgid IS NULL THEN \'%s\'"
           "                ELSE \'%s\' || ta.tagname"
           "                END"
           "      END AS name,"
           " ta.tagid AS tag_id, COUNT(*) AS count"
           " FROM main.images AS mi"
           " LEFT JOIN (SELECT imgid, t.id AS tagid, SUBSTR(t.name, %d) AS tagname"
           "   FROM main.tagged_images AS ti"
           "   JOIN data.tags AS t"
           "     ON ti.tagid = t.id"
           "   JOIN data.locations AS l"
           "     ON l.tagid = t.id"
           "   ) AS ta ON ta.imgid = mi.id"
           " WHERE %s"
           " GROUP BY name, tag_id",
           _("not tagged"), _("tagged"), _("tagged"),
           (int)strlen(dt_map_location_data_tag_root()) + 1, where_ext);
        // clang-format on
        break;

      case DT_COLLECTION_PROP_DAY:
        // clang-format off
        query = g_strdup_printf
          ("SELECT (datetime_taken / 86400000000) * 86400000000 AS date, 1,"
           "        COUNT(*) AS count"
           " FROM main.images AS mi"
           " WHERE datetime_taken IS NOT NULL AND datetime_taken <> 0"
           " AND %s"
           " GROUP BY date", where_ext);
        // clang-format on
        break;

      case DT_COLLECTION_PROP_MONTH:
        // clang-format off
        query = g_strdup_printf
          ("SELECT CAST(strftime('%%m', datetime_taken / 86400000000.0"
           "            + julianday('0001-01-01')) AS INTEGER) AS month_num,"
           "        1, COUNT(*) AS count"
           " FROM main.images AS mi"
           " WHERE datetime_taken IS NOT NULL AND datetime_taken <> 0"
           " AND %s"
           " GROUP BY month_num"
           " ORDER BY month_num", where_ext);
        // clang-format on
        break;

      case DT_COLLECTION_PROP_TIME:
      case DT_COLLECTION_PROP_IMPORT_TIMESTAMP:
      case DT_COLLECTION_PROP_CHANGE_TIMESTAMP:
      case DT_COLLECTION_PROP_EXPORT_TIMESTAMP:
      case DT_COLLECTION_PROP_PRINT_TIMESTAMP:
        {
          const int local_property = property;
          char *colname = NULL;

        switch(local_property)
        {
          case DT_COLLECTION_PROP_TIME:
            colname = "datetime_taken";
            break;
          case DT_COLLECTION_PROP_IMPORT_TIMESTAMP:
            colname = "import_timestamp";
            break;
          case DT_COLLECTION_PROP_CHANGE_TIMESTAMP:
            colname = "change_timestamp";
            break;
          case DT_COLLECTION_PROP_EXPORT_TIMESTAMP:
            colname = "export_timestamp";
            break;
          case DT_COLLECTION_PROP_PRINT_TIMESTAMP:
            colname = "print_timestamp";
            break;
        }
        // clang-format off
        query = g_strdup_printf("SELECT %s AS date, 1, COUNT(*) AS count"
                                " FROM main.images AS mi"
                                " WHERE %s IS NOT NULL AND %s <> 0"
                                " AND %s"
                                " GROUP BY date", colname, colname, colname, where_ext);
        // clang-format on
        break;
        }
    }

    g_free(where_ext);

    DT_DEBUG_SQLITE3_PREPARE_V2(dt_database_get(darktable.db), query, -1, &stmt, NULL);

    char **last_tokens = NULL;
    int last_tokens_length = 0;
    GtkTreeIter last_parent = { 0 };

    // we need to sort the names ourselves and not let sqlite handle this
    // because it knows nothing about path separators.
    GList *sorted_names = NULL;
    guint index = 0;
    while(sqlite3_step(stmt) == SQLITE_ROW)
    {
      char *name;
      if(_is_time_property(property) || property == DT_COLLECTION_PROP_DAY)
      {
        char sdt[DT_DATETIME_EXIF_LENGTH] = {0};
        dt_datetime_gtimespan_to_exif(sdt, sizeof(sdt), sqlite3_column_int64(stmt, 0));
        if(property == DT_COLLECTION_PROP_DAY)
          sdt[10] = '\0';
        name = g_strdup(sdt);
      }
      else if(property == DT_COLLECTION_PROP_MONTH)
      {
        const int month_num = sqlite3_column_int(stmt, 0);
        if(month_num >= 1 && month_num <= 12)
          name = g_strdup_printf("%02d - %s", month_num, _(dt_month_names[month_num - 1]));
        else
          name = g_strdup(_("unknown"));
      }
      else
      {
        const char* sqlite_name = (const char *)sqlite3_column_text(stmt, 0);
        name = sqlite_name == NULL ? g_strdup("") : g_strdup(sqlite_name);
      }
      gchar *collate_key = NULL;

      const int count = sqlite3_column_int(stmt, 2);

      if(property == DT_COLLECTION_PROP_FOLDERS)
      {
        char *name_folded = g_utf8_casefold(name, -1);
        char *name_folded_slash = g_strconcat(name_folded, G_DIR_SEPARATOR_S, NULL);
        collate_key = g_utf8_collate_key_for_filename(name_folded_slash, -1);
        g_free(name_folded_slash);
        g_free(name_folded);
      }
      else
      {
        char *tck = tag_collate_key(name);
        collate_key = g_utf8_collate_key_for_filename(tck, -1);
        g_free(tck);
      }

      name_key_tuple_t *tuple = malloc(sizeof(name_key_tuple_t));
      tuple->name = name;
      tuple->collate_key = collate_key;
      tuple->count = count;
      tuple->status = property == DT_COLLECTION_PROP_FOLDERS
                        ? sqlite3_column_int(stmt, 3)
                        : -1;
      sorted_names = g_list_prepend(sorted_names, tuple);
    }
    sqlite3_finalize(stmt);
    g_free(query);

    // this order should not be altered. the right feeding of the tree relies on it.
    sorted_names = g_list_sort(sorted_names, _sort_folder_tag);
    const gboolean sort_descend = dt_conf_get_bool("plugins/collect/descending");
    if(!sort_descend)
      sorted_names = g_list_reverse(sorted_names);

    const gboolean no_uncategorized =
      (property == DT_COLLECTION_PROP_TAG)
      ? dt_conf_get_bool("plugins/lighttable/tagging/no_uncategorized")
      : TRUE;

    for(GList *names = sorted_names; names; names = g_list_next(names))
    {
      name_key_tuple_t *tuple = (name_key_tuple_t *)names->data;
      char *name = tuple->name;
      const int count = tuple->count;
      const int status = tuple->status;

      if(name == NULL) continue; // safeguard against degenerated db entries

      // this is just for tags
      gboolean uncategorized_found = FALSE;
      if(!no_uncategorized && strchr(name, '|') == NULL)
      {
        char *next_name = g_strdup(names->next
                                   ? ((name_key_tuple_t *)names->next->data)->name
                                   : "");

        if(strlen(next_name) >= strlen(name) + 1 && next_name[strlen(name)] == '|')
          next_name[strlen(name)] = '\0';

        if(g_strcmp0(next_name, name) && g_strcmp0(name, _("not tagged")))
        {
          /* add uncategorized root iter if not exists */
          if(!uncategorized.stamp)
          {
            gtk_tree_store_insert_with_values
              (GTK_TREE_STORE(model), &uncategorized, NULL, -1,
               DT_LIB_COLLECT_COL_TEXT, _(UNCATEGORIZED_TAG),
               DT_LIB_COLLECT_COL_PATH, "", DT_LIB_COLLECT_COL_VISIBLE, TRUE,
               DT_LIB_COLLECT_COL_INDEX, index, -1);
            index++;
          }

          /* adding an uncategorized tag */
          gtk_tree_store_insert_with_values
            (GTK_TREE_STORE(model), &temp, &uncategorized, 0,
             DT_LIB_COLLECT_COL_TEXT, name,
             DT_LIB_COLLECT_COL_PATH, name,
             DT_LIB_COLLECT_COL_VISIBLE, TRUE,
             DT_LIB_COLLECT_COL_COUNT, count,
             DT_LIB_COLLECT_COL_INDEX, index, -1);
          uncategorized_found = TRUE;
          index++;
        }
        g_free(next_name);
      }

      if(!uncategorized_found)
      {
        char **tokens;
        if(property == DT_COLLECTION_PROP_FOLDERS)
          tokens = split_path(name);
        else if(property == DT_COLLECTION_PROP_DAY)
          tokens = g_strsplit(name, ":", -1);
        else if(_is_time_property(property))
          tokens = g_strsplit_set(name, ": ", 4);
        else
          tokens = g_strsplit(name, "|", -1);

        if(tokens != NULL)
        {
          // find the number of common parts at the beginning of tokens and
          // last_tokens
          GtkTreeIter parent = last_parent;
          const int tokens_length = string_array_length(tokens);
          int common_length = 0;
          if(last_tokens)
          {
            while(tokens[common_length]
                  && last_tokens[common_length]
                  && !g_strcmp0(tokens[common_length], last_tokens[common_length]))
            {
              common_length++;
            }
            // point parent iter to where the entries should be added
            for(int i = common_length; i < last_tokens_length; i++)
            {
              gtk_tree_model_iter_parent(model, &parent, &last_parent);
              last_parent = parent;
            }
          }

          // insert everything from tokens past the common part

          char *pth = NULL;
#ifndef _WIN32
          if(property == DT_COLLECTION_PROP_FOLDERS) pth = g_strdup("/");
#endif
          for(int i = 0; i < common_length; i++)
            dt_util_str_cat(&pth, format_separator, tokens[i]);

          // if we are at the end of the path, and this is a folder, we need to
          // add the count to all parents. common_length == 0 means 'parent' was
          // never set to a valid node, and there is no node to update anyway.
          if(common_length > 0 && !tokens[common_length]
             && property == DT_COLLECTION_PROP_FOLDERS)
          {
            _update_parent_count(model, &parent, count);
          }

          for(char **token = &tokens[common_length]; *token; token++)
          {
            GtkTreeIter iter;

            dt_util_str_cat(&pth, format_separator, *token);
            if(_is_time_property(property) && !*(token + 1)) pth[10] = ' ';

            gchar *pth2 = g_strdup(pth);
            pth2[strlen(pth2) - 1] = '\0';
            gtk_tree_store_insert_with_values
              (GTK_TREE_STORE(model), &iter, common_length > 0 ? &parent : NULL, 0,
               DT_LIB_COLLECT_COL_TEXT, *token,
               DT_LIB_COLLECT_COL_PATH, pth2, DT_LIB_COLLECT_COL_VISIBLE, TRUE,
               DT_LIB_COLLECT_COL_COUNT, (*(token + 1)?0:count),
               DT_LIB_COLLECT_COL_INDEX, index,
               DT_LIB_COLLECT_COL_UNREACHABLE, (*(token + 1) ? 0 : !status), -1);
            index++;
            // also add the item count to parents
            if((property == DT_COLLECTION_PROP_DAY
                || property == DT_COLLECTION_PROP_FOLDERS
                || _is_time_property(property)) && !*(token + 1))
            {
              GtkTreeIter p;
              // a top-level node has no parent: gtk_tree_model_iter_parent
              // then returns FALSE and leaves 'p' uninitialized, so passing it
              // on would cause a crash when dereferenced later
              if(gtk_tree_model_iter_parent(model, &p, &iter))
                _update_parent_count(model, &p, count);
            }

            common_length++;
            parent = iter;
            g_free(pth2);
          }

          g_free(pth);

          // remember things for the next round
          if(last_tokens) g_strfreev(last_tokens);
          last_tokens = tokens;
          last_parent = parent;
          last_tokens_length = tokens_length;
        }
      }
    }
    g_list_free_full(sorted_names, free_tuple);

    gtk_tree_view_set_tooltip_column(GTK_TREE_VIEW(d->view), DT_LIB_COLLECT_COL_TOOLTIP);

    d->treefilter = _create_filtered_model(model, dr);

    GtkTreeSelection *selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(d->view));
    if(property == DT_COLLECTION_PROP_DAY || _is_time_property(property))
    {
      gtk_tree_selection_set_mode(selection, GTK_SELECTION_MULTIPLE);
    }
    else
    {
      gtk_tree_selection_set_mode(selection, GTK_SELECTION_SINGLE);
    }

    gtk_tree_view_set_model(GTK_TREE_VIEW(d->view), d->treefilter);
    gtk_widget_set_no_show_all(GTK_WIDGET(d->view), FALSE);
    gtk_widget_show_all(GTK_WIDGET(d->view));

    g_object_unref(model);
    g_strfreev(last_tokens);
    d->view_rule = property;
  }

  // we update tree expansion and selection
  gtk_tree_selection_unselect_all(gtk_tree_view_get_selection(d->view));
  gtk_tree_view_collapse_all(d->view);

  // prepare datetime entry data
  if(_is_time_property(property) || property == DT_COLLECTION_PROP_DAY)
  {
    gchar *number1, *number2;
    dt_collection_split_operator_datetime(gtk_entry_get_text(GTK_ENTRY(dr->text)),
                                          &number1, &number2, &dr->datetime_range.operator);
    if(number1 && number1[strlen(number1) - 1] == '%')
      number1[strlen(number1) - 1] = '\0';
    dr->datetime_range.nb1 = number1 ? dt_datetime_exif_to_gtimespan(number1) : 0;
    dr->datetime_range.nb2 = number2 ? dt_datetime_exif_to_gtimespan(number2) : 0;
    g_free(number1);
    g_free(number2);
  }

  dr->sensitive = (property == DT_COLLECTION_PROP_TAG &&
     dt_conf_is_equal("plugins/lighttable/tagging/case_sensitivity", "sensitive"));
  gchar *needle = dr->sensitive
    ? g_strdup(gtk_entry_get_text(GTK_ENTRY(dr->text)))
    : g_utf8_strdown(gtk_entry_get_text(GTK_ENTRY(dr->text)), -1);

  if(g_str_has_suffix(needle, "%"))
    needle[strlen(needle) - 1] = '\0';
  dr->startwildcard = needle[0] == '%';
  dr->searchstring =  needle[0] == '%' ? &needle[1] : needle;
  dr->expanded_path = NULL;

  // if needed, we restrict the tree to matching entries
  if(dr->typing)
    tree_set_visibility(model, dr);

  if(!needle[0]) {} // do nothing
  // select/expand items corresponding to entry
  else if(property == DT_COLLECTION_PROP_DAY || _is_time_property(property))
  {
    if(strcmp(dr->datetime_range.operator, "[]") == 0)
    {
      dr->datetime_range.path1 = NULL;
      dr->datetime_range.path2 = NULL;

      gtk_tree_model_foreach(model, (GtkTreeModelForeachFunc)_datetime_range_select, dr);
      if(dr->datetime_range.path1 && dr->datetime_range.path2)
        _expand_select_tree_path(dr->datetime_range.path1, dr->datetime_range.path2, d);
      if(dr->datetime_range.path1)
        gtk_tree_path_free(dr->datetime_range.path1);
      if(dr->datetime_range.path2)
        gtk_tree_path_free(dr->datetime_range.path2);
    }
    else
      gtk_tree_model_foreach(model, (GtkTreeModelForeachFunc)tree_expand, dr);
    g_free(dr->datetime_range.operator);
  }
  else
    gtk_tree_model_foreach(model, (GtkTreeModelForeachFunc)tree_expand, dr);

  if(dr->expanded_path)
  {
    _expand_select_tree_path(dr->expanded_path, NULL, d);
    gtk_tree_path_free(dr->expanded_path);
  }
  dr->searchstring = NULL;
  g_free(needle);
}

static void _list_view(dt_lib_collect_rule_t *dr)
{
  // update related list
  dt_lib_collect_t *d = get_collect(dr);
  const int property = _combo_get_active_collection(dr->combo);

  set_properties(dr);

  GtkTreeModel *model =
    gtk_tree_model_filter_get_model(GTK_TREE_MODEL_FILTER(d->listfilter));
  if(d->view_rule != property)
  {
    const gboolean sort_descending = dt_conf_get_bool("plugins/collect/descending");

    sqlite3_stmt *stmt;
    g_object_unref(d->listfilter);
    g_object_ref(model);
    gtk_tree_view_set_model(GTK_TREE_VIEW(d->view), NULL);
    gtk_list_store_clear(GTK_LIST_STORE(model));
    gtk_widget_hide(GTK_WIDGET(d->view));
    gchar *where_ext = dt_collection_get_extended_where(darktable.collection, dr->num);

    char query[1024] = { 0 };

    switch(property)
    {
      case DT_COLLECTION_PROP_CAMERA:; // camera
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT TRIM(cm.maker || ' ' || cm.model) AS camera,"
                   "       1, COUNT(*) AS count"
                   "  FROM main.images AS mi, main.cameras AS cm"
                   "  WHERE mi.camera_id = cm.id"
                   "    AND %s "
                   "  GROUP BY LOWER(camera)"
                   "  ORDER BY LOWER(camera) %s",
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_HISTORY: // History
        // images without history are counted as if they were basic
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT CASE"
                   "       WHEN basic_hash == current_hash THEN '%s'"
                   "       WHEN auto_hash == current_hash THEN '%s'"
                   "       WHEN current_hash IS NOT NULL THEN '%s'"
                   "       ELSE '%s'"
                   "     END as altered, 1, COUNT(*) AS count,"
                   "     CASE"
                   "       WHEN basic_hash == current_hash THEN 0"
                   "       WHEN auto_hash == current_hash THEN 1"
                   "       WHEN current_hash IS NOT NULL THEN 3"
                   "       ELSE 2"
                   "     END AS force_order"
                   " FROM main.images AS mi"
                   " LEFT JOIN (SELECT DISTINCT imgid, basic_hash, auto_hash, current_hash"
                   "            FROM main.history_hash) ON id = imgid"
                   " WHERE %s"
                   " GROUP BY force_order"
                   " ORDER BY force_order %s",
                   _("basic"), _("auto applied"), _("altered"), _("basic"),
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_LOCAL_COPY: // local copy, 2 hardcoded alternatives
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT CASE "
                   "         WHEN (flags & %d) THEN '%s'"
                   "         ELSE '%s'"
                   "       END as lcp, 1, COUNT(*) AS count,"
                   "       CASE "
                   "         WHEN (flags & %d) THEN 0"
                   "         ELSE 1"
                   "       END as force_order"
                   " FROM main.images AS mi "
                   " WHERE %s"
                   " GROUP BY force_order ORDER BY force_order %s",
                   DT_IMAGE_LOCAL_COPY,
                   _("copied locally"),
                   _("not copied locally"),
                   DT_IMAGE_LOCAL_COPY, where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_ASPECT_RATIO: // aspect ratio, 3 hardcoded alternatives
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT ROUND(aspect_ratio,2), 1, COUNT(*) AS count"
                   " FROM main.images AS mi "
                   " WHERE %s"
                   " GROUP BY ROUND(aspect_ratio,2)"
                   " ORDER BY ROUND(aspect_ratio,2) %s",
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_COLORLABEL: // colorlabels
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT CASE color"
                   "         WHEN 0 THEN '%s'"
                   "         WHEN 1 THEN '%s'"
                   "         WHEN 2 THEN '%s'"
                   "         WHEN 3 THEN '%s'"
                   "         WHEN 4 THEN '%s'"
                   "         ELSE ''"
                   "       END, color, COUNT(*) AS count"
                   " FROM main.images AS mi"
                   " JOIN "
                   "   (SELECT imgid AS color_labels_id, color FROM main.color_labels)"
                   " ON id = color_labels_id "
                   " WHERE %s"
                   " GROUP BY color"
                   " ORDER BY color %s",
                   _("red"), _("yellow"), _("green"), _("blue"), _("purple"),
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_LENS: // lens
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT CASE LOWER(TRIM(ln.name))"
                   "         WHEN 'n/a' THEN ''"
                   "         ELSE ln.name"
                   "       END AS lens, 1, COUNT(*) AS count"
                   "  FROM main.images AS mi, main.lens AS ln"
                   "  WHERE mi.lens_id = ln.id"
                   "    AND %s"
                   "  GROUP BY LOWER(lens)"
                   "  ORDER BY LOWER(lens) %s", where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_WHITEBALANCE: // white balance
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT wb.name AS whitebalance, 1, COUNT(*) AS count"
                   "  FROM main.images AS mi, main.whitebalance AS wb"
                   "  WHERE mi.whitebalance_id = wb.id"
                   "    AND %s"
                   "  GROUP BY LOWER(whitebalance)"
                   "  ORDER BY LOWER(whitebalance) %s", where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_FLASH: // white balance
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT fl.name AS flash, 1, COUNT(*) AS count"
                   "  FROM main.images AS mi, main.flash AS fl"
                   "  WHERE mi.flash_id = fl.id"
                   "    AND %s"
                   "  GROUP BY LOWER(flash)"
                   "  ORDER BY LOWER(flash) %s", where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_EXPOSURE_PROGRAM: // exposure program
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT ep.name AS exposure_program, 1, COUNT(*) AS count"
                   "  FROM main.images AS mi, main.exposure_program AS ep"
                   "  WHERE mi.exposure_program_id = ep.id"
                   "    AND %s"
                   "  GROUP BY LOWER(exposure_program)"
                   "  ORDER BY LOWER(exposure_program) %s", where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_METERING_MODE: // metering mode
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT mm.name AS metering_mode, 1, COUNT(*) AS count"
                   "  FROM main.images AS mi, main.metering_mode AS mm"
                   "  WHERE mi.metering_mode_id = mm.id"
                   "    AND %s"
                   "  GROUP BY LOWER(metering_mode)"
                   "  ORDER BY LOWER(metering_mode) %s", where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_FOCAL_LENGTH: // focal length
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT CAST(focal_length AS INTEGER) AS focal_length, 1,"
                   "       COUNT(*) AS count"
                   " FROM main.images AS mi"
                   " WHERE %s"
                   " GROUP BY CAST(focal_length AS INTEGER)"
                   " ORDER BY CAST(focal_length AS INTEGER) %s",
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_ISO: // iso
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT CAST(iso AS INTEGER) AS iso, 1, COUNT(*) AS count"
                   " FROM main.images AS mi"
                   " WHERE %s"
                   " GROUP BY iso"
                   " ORDER BY iso %s",
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_APERTURE: // aperture
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT ROUND(aperture,1) AS aperture, 1, COUNT(*) AS count"
                   " FROM main.images AS mi"
                   " WHERE %s"
                   " GROUP BY aperture"
                   " ORDER BY aperture %s",
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_EXPOSURE: // exposure
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT CASE"
                   "         WHEN (exposure < 0.4) THEN '1/' || CAST(1/exposure + 0.9 AS INTEGER) "
                   "         ELSE ROUND(exposure,2) || '\"'"
                   "       END AS _exposure, 1, COUNT(*) AS count"
                   " FROM main.images AS mi"
                   " WHERE %s"
                   " GROUP BY _exposure"
                   " ORDER BY exposure %s",
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_EXPOSURE_BIAS: // exposure bias
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT ROUND(exposure_bias,2), 1, COUNT(*) AS count"
                   " FROM main.images AS mi"
                   " WHERE %s"
                   " GROUP BY ROUND(exposure_bias,2)"
                   " ORDER BY ROUND(exposure_bias,2) %s",
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_FILENAME: // filename
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT filename, 1, COUNT(*) AS count"
                   " FROM main.images AS mi"
                   " WHERE %s"
                   " GROUP BY lower(filename)"
                   " ORDER BY lower(filename) %s",
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_GROUP_ID: // group_id
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT mi.group_id, 1, COUNT(*) AS count"
                   "  FROM main.images AS mi"
                   "  WHERE %s"
                   "  GROUP BY group_id"
                   "  HAVING COUNT(*) > 1"
                   "  ORDER BY group_id %s", where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_MODULE: // module
        // clang-format off
        snprintf(query, sizeof(query),
                 "SELECT m.name AS module_name, 1, COUNT(*) AS count"
                 " FROM main.images AS mi"
                 " JOIN (SELECT DISTINCT imgid, operation"
                 "       FROM main.history"
                 "       WHERE enabled = 1) AS h"
                 "  ON h.imgid = mi.id"
                 " JOIN memory.darktable_iop_names AS m"
                 "  ON m.operation = h.operation"
                 " WHERE %s"
                 " GROUP BY lower(module_name)"
                 " ORDER BY lower(module_name) %s",
                 where_ext,
                 sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      case DT_COLLECTION_PROP_ORDER: // modules order
        {
          char *orders = NULL;
          for(int i = 0; i < DT_IOP_ORDER_LAST; i++)
          {
            dt_util_str_cat(&orders, "WHEN mo.version = %d THEN '%s' ",
                                     i, _(dt_iop_order_string(i)));
          }
          dt_util_str_cat(&orders, "ELSE '%s' ", _("none"));
          // clang-format off
          snprintf(query, sizeof(query),
                   "SELECT CASE %s END as ver, 1, COUNT(*) AS count"
                   " FROM main.images AS mi"
                   " LEFT JOIN (SELECT imgid, version FROM main.module_order) mo"
                   "  ON mo.imgid = mi.id"
                   " WHERE %s"
                   " GROUP BY ver"
                   " ORDER BY ver %s",
                   orders, where_ext,
                   sort_descending ? "DESC" : "ASC");
          // clang-format on
          g_free(orders);
        }
        break;

      case DT_COLLECTION_PROP_RATING: // image rating
        {
        // clang-format off
          g_snprintf(query, sizeof(query),
                     "SELECT CASE WHEN (flags & 8) == 8"
                     "        THEN -1 ELSE (flags & 7) END AS rating, 1,"
                     " COUNT(*) AS count"
                     " FROM main.images AS mi"
                     " WHERE %s"
                     " GROUP BY rating"
                     " ORDER BY rating %s", where_ext,
                     sort_descending ? "DESC" : "ASC");
          // clang-format on
        }
        break;

      case DT_COLLECTION_PROP_MONTH: // capture month
        // clang-format off
        g_snprintf(query, sizeof(query),
                   "SELECT CAST(strftime('%%m', datetime_taken / 86400000000.0"
                   "            + julianday('0001-01-01')) AS INTEGER) AS month_num,"
                   "        1, COUNT(*) AS count"
                   " FROM main.images AS mi"
                   " WHERE datetime_taken IS NOT NULL AND datetime_taken <> 0"
                   " AND %s"
                   " GROUP BY month_num"
                   " ORDER BY month_num %s",
                   where_ext,
                   sort_descending ? "DESC" : "ASC");
        // clang-format on
        break;

      default:
        if(property >= DT_COLLECTION_PROP_METADATA_OFFSET)
        {
          const int keyid = property - DT_COLLECTION_PROP_METADATA_OFFSET;
          // clang-format off
          snprintf(query, sizeof(query),
                    "SELECT"
                    " CASE WHEN value IS NULL THEN '%s' ELSE value END AS value,"
                    " 1, COUNT(*) AS count,"
                    " CASE WHEN value IS NULL THEN 0 ELSE 1 END AS force_order"
                    " FROM main.images AS mi"
                    " LEFT JOIN (SELECT id AS meta_data_id, value"
                    "            FROM main.meta_data"
                    "            WHERE key = %d)"
                    "  ON id = meta_data_id"
                    " WHERE %s"
                    " GROUP BY lower(value)"
                    " ORDER BY force_order ASC, lower(value) %s",
                    _("not defined"), keyid, where_ext,
                    sort_descending ? "DESC" : "ASC");
          // clang-format on
        }
        else
        // filmroll
        {
          gchar *order_by = NULL;

          const gboolean sort_by_import_time =
            dt_conf_is_equal("plugins/collect/filmroll_sort", "import time");

          if(sort_by_import_time)
          {
            order_by = g_strdup(sort_descending ? "film_rolls_id DESC" : "film_rolls_id ASC");
          }
          else
          {
            order_by = g_strdup(sort_descending ? "lower(folder) DESC" : "lower(folder) ASC");
          }

          // clang-format off
          g_snprintf(query, sizeof(query),
                     "SELECT folder, film_rolls_id, COUNT(*) AS count, status"
                     " FROM main.images AS mi"
                     " JOIN (SELECT fr.id AS film_rolls_id, folder, status"
                     "       FROM main.film_rolls AS fr"
                     "        JOIN memory.film_folder AS ff"
                     "        ON ff.id = fr.id)"
                     "   ON film_id = film_rolls_id "
                     " WHERE %s"
                     " GROUP BY folder"
                     " ORDER BY %s", where_ext, order_by);
          // clang-format on

          g_free(order_by);
        }
        break;
    }

    g_free(where_ext);

    if(strlen(query) > 0)
    {
      DT_DEBUG_SQLITE3_PREPARE_V2(dt_database_get(darktable.db), query, -1, &stmt, NULL);

      GList *rows = NULL;

      while(sqlite3_step(stmt) == SQLITE_ROW)
      {
        const gchar *value = (gchar *)sqlite3_column_text(stmt, 0);
        const char *folder = (const char *)sqlite3_column_text(stmt, 0);
        const int count = sqlite3_column_int(stmt, 2);

        if(folder == NULL) continue; // safeguard against degenerated db entries

        int status = 0;

        if(property == DT_COLLECTION_PROP_FILMROLL)
        {
          folder = dt_image_film_roll_name(folder);
          status = !sqlite3_column_int(stmt, 3);

          filmroll_row_t *r = g_malloc(sizeof(*r));
          r->folder = g_strdup(folder);
          r->value = g_strdup(value);
          r->id = sqlite3_column_int(stmt, 1);
          r->count = count;
          r->status = status;

          rows = g_list_prepend(rows, r);

          continue;
        }
        else if(property == DT_COLLECTION_PROP_RATING)
        {
          const int rating = sqlite3_column_int(stmt, 0);
          switch(rating)
          {
             case -1:
               folder = _("rejected");
               break;
             case 0:
               folder = _("not rated");
               break;
             case 1:
               folder = "★";
               break;
             case 2:
               folder = "★★";
               break;
             case 3:
               folder = "★★★";
               break;
             case 4:
               folder = "★★★★";
               break;
             case 5:
               folder = "★★★★★";
               break;
          }
        }
        else if(property == DT_COLLECTION_PROP_MONTH)
        {
          const int month_num = sqlite3_column_int(stmt, 0);
          if(month_num >= 1 && month_num <= 12)
            folder = _(dt_month_names[month_num - 1]);
          else
            folder = _("unknown");
        }

        // check if name is empty string

        gboolean only_spaces = TRUE;
        for(int k=0; k < strlen(folder); k++)
        {
          if(folder[k] != ' ' && folder[k] != '\t')
          {
            only_spaces = FALSE;
            break;
          }
        }

        if(only_spaces)
        {
          folder = _("unnamed");
          value = _("unnamed");
        }

        // replace invalid utf8 characters if any
        gchar *text = g_strdup(value);
        gchar *ptr = text;
        while(!g_utf8_validate(ptr, -1, (const gchar **)&ptr))
          ptr[0] = '?';

        gchar *escaped_text = g_markup_escape_text(text, -1);

        gtk_list_store_insert_with_values(GTK_LIST_STORE(model), NULL, -1,
                           DT_LIB_COLLECT_COL_TEXT, folder,
                           DT_LIB_COLLECT_COL_ID, sqlite3_column_int(stmt, 1),
                           DT_LIB_COLLECT_COL_TOOLTIP, escaped_text,
                           DT_LIB_COLLECT_COL_PATH, value,
                           DT_LIB_COLLECT_COL_VISIBLE, TRUE,
                           DT_LIB_COLLECT_COL_COUNT, count,
                           DT_LIB_COLLECT_COL_UNREACHABLE, status,
                           -1);

        g_free(text);
        g_free(escaped_text);
      }

      if(property == DT_COLLECTION_PROP_FILMROLL)
      {
        const gboolean sort_by_import_time =
          dt_conf_is_equal("plugins/collect/filmroll_sort", "import time");
        const gboolean sort_by_folder_name =
          dt_conf_is_equal("plugins/collect/filmroll_sort", "folder name");

        if(sort_by_import_time)
        {
          rows = g_list_sort(rows, _sort_filmroll_by_id);
        }
        else if(sort_by_folder_name)
        {
          rows = g_list_sort(rows, _sort_filmroll_rows);
        }
        else
        {
          rows = g_list_sort(rows, _sort_filmroll_by_display_name);
        }

        if(sort_descending)
          rows = g_list_reverse(rows);

        for(GList *l = rows; l; l = l->next)
        {
          filmroll_row_t *r = l->data;

          gchar *text = g_strdup(r->value);
          gchar *ptr = text;
          while(!g_utf8_validate(ptr, -1, (const gchar **)&ptr))
            ptr[0] = '?';

          gchar *escaped_text = g_markup_escape_text(text, -1);

          gtk_list_store_insert_with_values(GTK_LIST_STORE(model), NULL, -1,
                                            DT_LIB_COLLECT_COL_TEXT, r->folder,
                                            DT_LIB_COLLECT_COL_ID, r->id,
                                            DT_LIB_COLLECT_COL_TOOLTIP, escaped_text,
                                            DT_LIB_COLLECT_COL_PATH, r->value,
                                            DT_LIB_COLLECT_COL_VISIBLE, TRUE,
                                            DT_LIB_COLLECT_COL_COUNT, r->count,
                                            DT_LIB_COLLECT_COL_UNREACHABLE, r->status,
                                            -1);

          g_free(text);
          g_free(escaped_text);
          g_free(r->folder);
          g_free(r->value);
          g_free(r);
        }

        g_list_free(rows);
        rows = NULL;
      }

      sqlite3_finalize(stmt);
    }

    gtk_tree_view_set_tooltip_column(GTK_TREE_VIEW(d->view), DT_LIB_COLLECT_COL_TOOLTIP);

    d->listfilter = _create_filtered_model(model, dr);

    GtkTreeSelection *selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(d->view));
    if(property == DT_COLLECTION_PROP_APERTURE
       || property == DT_COLLECTION_PROP_FOCAL_LENGTH
       || property == DT_COLLECTION_PROP_ISO
       || property == DT_COLLECTION_PROP_EXPOSURE
       || property == DT_COLLECTION_PROP_ASPECT_RATIO
       || property == DT_COLLECTION_PROP_RATING)
    {
      gtk_tree_selection_set_mode(selection, GTK_SELECTION_MULTIPLE);
    }
    else
    {
      gtk_tree_selection_set_mode(selection, GTK_SELECTION_SINGLE);
    }

    gtk_tree_view_set_model(GTK_TREE_VIEW(d->view), d->listfilter);
    gtk_widget_set_no_show_all(GTK_WIDGET(d->view), FALSE);
    gtk_widget_show_all(GTK_WIDGET(d->view));

    g_object_unref(model);

    d->view_rule = property;
  }

  // if needed, we restrict the tree to matching entries
  if(dr->typing
     && (property == DT_COLLECTION_PROP_CAMERA
         || property == DT_COLLECTION_PROP_FILENAME
         || property == DT_COLLECTION_PROP_FILMROLL
         || property == DT_COLLECTION_PROP_LENS
         || property == DT_COLLECTION_PROP_APERTURE
         || property == DT_COLLECTION_PROP_FOCAL_LENGTH
         || property == DT_COLLECTION_PROP_ISO
         || property == DT_COLLECTION_PROP_MODULE
         || property == DT_COLLECTION_PROP_ORDER
         || property == DT_COLLECTION_PROP_RATING
         || property >= DT_COLLECTION_PROP_METADATA_OFFSET))
  {
    gchar *needle = g_utf8_strdown(gtk_entry_get_text(GTK_ENTRY(dr->text)), -1);
    if(g_str_has_suffix(needle, "%"))
      needle[strlen(needle) - 1] = '\0';

    dr->searchstring = needle;
    gtk_tree_model_foreach(model, (GtkTreeModelForeachFunc)list_match_string, dr);
    dr->searchstring = NULL;
    g_free(needle);
  }
  // we update list selection
  gtk_tree_selection_unselect_all(gtk_tree_view_get_selection(d->view));

  if(property == DT_COLLECTION_PROP_APERTURE
     || property == DT_COLLECTION_PROP_FOCAL_LENGTH
     || property == DT_COLLECTION_PROP_ISO
     || property == DT_COLLECTION_PROP_EXPOSURE
     || property == DT_COLLECTION_PROP_ASPECT_RATIO
     || property == DT_COLLECTION_PROP_RATING)
  {
    // test selection range [xxx;xxx]
    GRegex *regex;
    GMatchInfo *match_info;

    regex = g_regex_new("^\\s*\\[\\s*(.*)\\s*;\\s*(.*)\\s*\\]\\s*$", 0, 0, NULL);
    g_regex_match_full(regex, gtk_entry_get_text(GTK_ENTRY(dr->text)), -1, 0, 0,
                       &match_info, NULL);
    const int match_count = g_match_info_get_match_count(match_info);

    if(match_count == 3)
    {
      _range_t *range = calloc(1, sizeof(_range_t));
      range->start = g_match_info_fetch(match_info, 1);
      range->stop = g_match_info_fetch(match_info, 2);

      gtk_tree_model_foreach(d->listfilter, (GtkTreeModelForeachFunc)range_select, range);
      if(range->path1 && range->path2)
      {
        gtk_tree_selection_select_range(gtk_tree_view_get_selection(d->view),
                                        range->path1, range->path2);
      }
      g_free(range->start);
      g_free(range->stop);
      gtk_tree_path_free(range->path1);
      gtk_tree_path_free(range->path2);
      free(range);
    }
    else
      gtk_tree_model_foreach(d->listfilter, (GtkTreeModelForeachFunc)list_select, dr);

    g_match_info_free(match_info);
    g_regex_unref(regex);
  }
  else
    gtk_tree_model_foreach(d->listfilter, (GtkTreeModelForeachFunc)list_select, dr);
}

static void update_view(dt_lib_collect_rule_t *dr)
{
  const int property = _combo_get_active_collection(dr->combo);

  if(property == DT_COLLECTION_PROP_FOLDERS
     || property == DT_COLLECTION_PROP_TAG
     || property == DT_COLLECTION_PROP_GEOTAGGING
     || property == DT_COLLECTION_PROP_DAY
     || _is_time_property(property))
    _tree_view(dr);
  else
    _list_view(dr);
}

static void _set_tooltip(dt_lib_collect_rule_t *d)
{
  const int property = _combo_get_active_collection(d->combo);

  if(property == DT_COLLECTION_PROP_APERTURE
     || property == DT_COLLECTION_PROP_FOCAL_LENGTH
     || property == DT_COLLECTION_PROP_ISO
     || property == DT_COLLECTION_PROP_ASPECT_RATIO
     || property == DT_COLLECTION_PROP_EXPOSURE)
  {
    gtk_widget_set_tooltip_text(d->text, _("use <, <=, >, >=, <>, =, [;] as operators"));
  }
  else if(property == DT_COLLECTION_PROP_RATING)
  {
    gtk_widget_set_tooltip_text(d->text, _("use <, <=, >, >=, <>, =, [;] as operators\n"
                                           "star rating: 0-5\n"
                                           "rejected images: -1"));
  }
  else if(property == DT_COLLECTION_PROP_DAY || _is_time_property(property))
  {
    gtk_widget_set_tooltip_text
      (d->text,
       _("use <, <=, >, >=, <>, =, [;] as operators\n"
         "type dates in the form: YYYY:MM:DD hh:mm:ss.sss (only the year is mandatory)"));
  }
  else if(property == DT_COLLECTION_PROP_FILENAME)
  {
    gtk_widget_set_tooltip_text(d->text,
                                /* xgettext:no-c-format */
                                _("use `%' as wildcard and `,' to separate values"));
  }
  else if(property == DT_COLLECTION_PROP_TAG)
  {
    gtk_widget_set_tooltip_text
      (d->text,
       /* xgettext:no-c-format */
       _("use `%' as wildcard\n"
         "click to include hierarchy + sub-hierarchies (suffix `*')\n"
         "shift+click to include only the current hierarchy (no suffix)\n"
         "ctrl+click to include only sub-hierarchies (suffix `|%')"));
  }
  else if(property == DT_COLLECTION_PROP_GEOTAGGING)
  {
    gtk_widget_set_tooltip_text
      (d->text,
       /* xgettext:no-c-format */
       _("use `%' as wildcard\n"
         "click to include location + sub-locations (suffix `*')\n"
         "shift+click to include only the current location (no suffix)\n"
         "ctrl+click to include only sub-locations (suffix `|%')"));
  }
  else if(property == DT_COLLECTION_PROP_FOLDERS)
  {
    gtk_widget_set_tooltip_text
      (d->text,
       /* xgettext:no-c-format */
       _("use `%' as wildcard\n"
         "click to include current + sub-folders (suffix `*')\n"
         "shift+click to include only the current folder (no suffix)\n"
         "ctrl+click to include only sub-folders (suffix `|%')"));
  }
  else
  {
    /* xgettext:no-c-format */
    gtk_widget_set_tooltip_text(d->text, _("use `%' as wildcard"));
  }

  //set the combobox tooltip as well
  gchar *tip = gtk_widget_get_tooltip_text(d->text);
  gtk_widget_set_tooltip_text(GTK_WIDGET(d->combo), tip);
  g_free(tip);
}

void view_enter(struct dt_lib_module_t *self, struct dt_view_t *old_view, struct dt_view_t *new_view)
{
  dt_lib_collect_t *d = self->data;
  const gboolean hide = dt_lib_is_visible(darktable.view_manager->proxy.module_recentcollect.module);
  gtk_widget_set_visible(d->history_button, !hide);
}

void gui_update(dt_lib_module_t *self)
{
  dt_lib_collect_t *d = self->data;
  update_view(d->rule + d->active_rule);
  dt_gui_widget_reallocate_now(GTK_WIDGET(d->view));
}

static void _lib_collect_gui_update(dt_lib_module_t *self)
{
  dt_lib_collect_t *const d = self->data;

  // we check if something has changed since last call
  if(d->view_rule != -1) return;

  DT_ENTER_GUI_UPDATE();
  const int _a = dt_conf_get_int("plugins/lighttable/collect/num_rules") - 1;
  const int active = CLAMP(_a, 0, (MAX_RULES - 1));
  d->nb_rules = active + 1;
  char confname[200] = { 0 };

  gtk_widget_set_no_show_all(GTK_WIDGET(d->view), TRUE);

  for(int i = 0; i < MAX_RULES; i++)
  {
    gtk_widget_set_no_show_all(d->rule[i].hbox, TRUE);
    gtk_widget_set_visible(d->rule[i].hbox, FALSE);
  }
  for(int i = 0; i <= active; i++)
  {
    gtk_widget_set_no_show_all(d->rule[i].hbox, FALSE);
    gtk_widget_set_visible(d->rule[i].hbox, TRUE);
    gtk_widget_show_all(d->rule[i].hbox);
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/item%1d", i);
    _combo_set_active_collection(d->rule[i].combo, dt_conf_get_int(confname));
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/string%1d", i);
    const char *text = dt_conf_get_string_const(confname);

    if(text)
    {
      g_signal_handlers_block_matched(d->rule[i].text,
                                      G_SIGNAL_MATCH_FUNC, 0, 0, NULL,
                                      entry_changed, NULL);
      gtk_entry_set_text(GTK_ENTRY(d->rule[i].text), text);
      gtk_editable_set_position(GTK_EDITABLE(d->rule[i].text), -1);
      g_signal_handlers_unblock_matched(d->rule[i].text,
                                        G_SIGNAL_MATCH_FUNC, 0, 0, NULL,
                                        entry_changed, NULL);
      d->rule[i].typing = FALSE;
    }

    GtkDarktableButton *button = DTGTK_BUTTON(d->rule[i].button);
    if(i == MAX_RULES - 1)
    {
      // only clear
      button->icon = dtgtk_cairo_paint_cancel;
      gtk_widget_set_tooltip_text(GTK_WIDGET(button), _("clear this rule"));
    }
    else if(i == active)
    {
      gtk_widget_set_tooltip_text(GTK_WIDGET(button),
                                  _("clear this rule or add new rules"));
      dtgtk_button_set_paint(button, dtgtk_cairo_paint_solid_arrow,
                             CPF_DIRECTION_DOWN, NULL);
    }
    else
    {
      snprintf(confname, sizeof(confname), "plugins/lighttable/collect/mode%1d", i + 1);
      const int mode = dt_conf_get_int(confname);

      if(mode == DT_LIB_COLLECT_MODE_AND)
        button->icon = dtgtk_cairo_paint_intersection;
      else if(mode == DT_LIB_COLLECT_MODE_OR)
        button->icon = dtgtk_cairo_paint_union;
      else if(mode == DT_LIB_COLLECT_MODE_AND_NOT)
        button->icon = dtgtk_cairo_paint_andnot;

      gtk_widget_set_tooltip_text(GTK_WIDGET(button), _("clear this rule"));
    }

    _set_tooltip(d->rule + i);
  }

  // update list of proposals if the module's contents are visible
  d->active_rule = active;
  dt_lib_gui_queue_update(self);
  DT_LEAVE_GUI_UPDATE();
}

void gui_reset(dt_lib_module_t *self)
{
  dt_conf_set_int("plugins/lighttable/collect/num_rules", 1);
  dt_conf_set_int("plugins/lighttable/collect/item0", DT_COLLECTION_PROP_FILMROLL);
  dt_conf_set_int("plugins/lighttable/collect/mode0", 0);
  dt_conf_set_string("plugins/lighttable/collect/string0", "");
  dt_lib_collect_t *d = self->data;
  d->active_rule = 0;
  d->view_rule = -1;
  dt_collection_set_query_flags(darktable.collection, COLLECTION_QUERY_FULL);
  dt_collection_update_query(darktable.collection, DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
}

// Returns TRUE if the folder still corresponds to at least one film roll,
// either directly or as an ancestor of one. This mirrors the folders tree,
// where a node exists as long as a film roll lives at or below it.
static gboolean _folder_exists(const char *folder)
{
  if(!folder || !*folder) return FALSE;

  gboolean exists = FALSE;
  sqlite3_stmt *stmt = NULL;
  gchar *child = g_strconcat(folder, G_DIR_SEPARATOR_S, "%", NULL);
  DT_DEBUG_SQLITE3_PREPARE_V2
    (dt_database_get(darktable.db),
     "SELECT 1 FROM main.film_rolls WHERE folder = ?1 OR folder LIKE ?2 LIMIT 1",
     -1, &stmt, NULL);
  DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 1, folder, -1, SQLITE_TRANSIENT);
  DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 2, child, -1, SQLITE_TRANSIENT);
  if(sqlite3_step(stmt) == SQLITE_ROW) exists = TRUE;
  sqlite3_finalize(stmt);
  g_free(child);
  return exists;
}

// Resolve a folder path to the film roll that best corresponds to it, so
// that switching from the folders to the film roll view keeps the user near
// where they were instead of forcing them to retraverse the tree.
// Returns a newly allocated film roll folder path, or NULL if none exists.
// Preference order: an exact match (1:1), else the closest film roll that
// contains the folder (nearest ancestor), else the closest film roll
// contained in the folder (nearest descendant).
static gchar *_filmroll_for_folder(const char *folder)
{
  if(!folder || !*folder) return NULL;

  gchar *result = NULL;
  sqlite3_stmt *stmt = NULL;

  // 1:1 exact match
  DT_DEBUG_SQLITE3_PREPARE_V2
    (dt_database_get(darktable.db),
     "SELECT folder FROM main.film_rolls WHERE folder = ?1", -1, &stmt, NULL);
  DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 1, folder, -1, SQLITE_TRANSIENT);
  if(sqlite3_step(stmt) == SQLITE_ROW)
    result = g_strdup((const char *)sqlite3_column_text(stmt, 0));
  sqlite3_finalize(stmt);
  if(result) return result;

  // closest containing film roll (nearest ancestor)
  DT_DEBUG_SQLITE3_PREPARE_V2
    (dt_database_get(darktable.db),
     "SELECT folder FROM main.film_rolls"
     " WHERE ?1 LIKE folder || ?2"
     " ORDER BY LENGTH(folder) DESC LIMIT 1", -1, &stmt, NULL);
  DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 1, folder, -1, SQLITE_TRANSIENT);
  DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 2, G_DIR_SEPARATOR_S "%", -1, SQLITE_TRANSIENT);
  if(sqlite3_step(stmt) == SQLITE_ROW)
    result = g_strdup((const char *)sqlite3_column_text(stmt, 0));
  sqlite3_finalize(stmt);
  if(result) return result;

  // closest contained film roll (nearest descendant)
  gchar *child_pattern = g_strconcat(folder, G_DIR_SEPARATOR_S, "%", NULL);
  DT_DEBUG_SQLITE3_PREPARE_V2
    (dt_database_get(darktable.db),
     "SELECT folder FROM main.film_rolls"
     " WHERE folder LIKE ?1"
     " ORDER BY LENGTH(folder) ASC LIMIT 1", -1, &stmt, NULL);
  DT_DEBUG_SQLITE3_BIND_TEXT(stmt, 1, child_pattern, -1, SQLITE_TRANSIENT);
  if(sqlite3_step(stmt) == SQLITE_ROW)
    result = g_strdup((const char *)sqlite3_column_text(stmt, 0));
  sqlite3_finalize(stmt);
  g_free(child_pattern);

  return result;
}

// Strip the folders-collection suffixes (`*`, `|%`, trailing separator or
// `%`) from a rule value to recover a plain filesystem path. Returns a
// newly allocated string.
static gchar *_folder_plain_path(const char *text)
{
  gchar *folder = g_strdup(text ? text : "");
  size_t len = strlen(folder);
  if(len && folder[len - 1] == '*') folder[--len] = '\0';
  if(len >= 2 && g_str_has_suffix(folder, "|%")) { len -= 2; folder[len] = '\0'; }
  while(len && (folder[len - 1] == G_DIR_SEPARATOR || folder[len - 1] == '%'))
    folder[--len] = '\0';
  return folder;
}

// When a filesystem operation (move/delete/erase) removes the film roll or
// folder the active rule points at, repoint it at the closest still existing
// entry instead of letting the collection module collapse back to the tree
// root. The selection is left untouched while its target still exists.
// Returns TRUE if the rule's stored value was changed.
static gboolean _fixup_stale_selection(dt_lib_collect_t *d)
{
  const int active = d->active_rule;
  const int property = _combo_get_active_collection(d->rule[active].combo);

  if(property != DT_COLLECTION_PROP_FILMROLL
     && property != DT_COLLECTION_PROP_FOLDERS)
    return FALSE;

  char confname[200] = { 0 };
  snprintf(confname, sizeof(confname),
           "plugins/lighttable/collect/string%1d", active);
  gchar *text = dt_conf_get_string(confname);
  if(!text || !text[0])
  {
    g_free(text);
    return FALSE;
  }

  // a restored rule may have changed property before the combobox refreshes
  char itemname[200];
  snprintf(itemname, sizeof(itemname), "plugins/lighttable/collect/item%1d", active);
  if(property != dt_conf_get_int(itemname))
  {
    g_free(text);
    return FALSE;
  }

  gchar *folder = _folder_plain_path(text);
  gboolean changed = FALSE;

  // a folder node survives as long as a film roll lives at or under it, so we
  // only repoint when it has vanished entirely; film rolls are an exact match
  // and _filmroll_for_folder() returns the path unchanged while it exists.
  if(property != DT_COLLECTION_PROP_FOLDERS || !_folder_exists(folder))
  {
    gchar *closest = _filmroll_for_folder(folder);
    if(g_strcmp0(closest, folder) != 0)
    {
      dt_conf_set_string(confname, closest ? closest : "");
      changed = TRUE;
    }
    g_free(closest);
  }

  g_free(folder);
  g_free(text);
  return changed;
}

static void combo_changed(GtkWidget *combo,
                          dt_lib_collect_rule_t *d)
{
  DT_GUARD_GUI_UPDATE();
  dt_lib_collect_t *c = get_collect(d);
  c->active_rule = d->num;
  const int property = _combo_get_active_collection(d->combo);

  // When switching between the film roll and folders views, both store the
  // full folder path as their value, so we carry the current selection over
  // instead of forcing the user to retraverse the tree to find where they
  // were.
  // - film roll -> folders: keep the path verbatim (same exact-match query,
  //   the displayed collection is left unchanged and the tree opens there).
  // - folders -> film roll: resolve the folder to the closest film roll
  //   (1:1 match, else nearest containing/contained film roll).
  char confname[200] = { 0 };
  snprintf(confname, sizeof(confname),
           "plugins/lighttable/collect/item%1d", d->num);
  const int prev_property = dt_conf_get_int(confname);
  const gchar *cur_text = gtk_entry_get_text(GTK_ENTRY(d->text));
  const gboolean has_text = cur_text && cur_text[0];

  const gboolean keep_path = prev_property == DT_COLLECTION_PROP_FILMROLL
                             && property == DT_COLLECTION_PROP_FOLDERS
                             && has_text;

  gchar *resolved_filmroll = NULL;
  if(prev_property == DT_COLLECTION_PROP_FOLDERS
     && property == DT_COLLECTION_PROP_FILMROLL
     && has_text)
  {
    gchar *folder = _folder_plain_path(cur_text);
    resolved_filmroll = _filmroll_for_folder(folder);
    g_free(folder);
  }

  g_signal_handlers_block_matched(d->text, G_SIGNAL_MATCH_FUNC, 0, 0, NULL,
                                  entry_changed, NULL);
  if(resolved_filmroll)
    gtk_entry_set_text(GTK_ENTRY(d->text), resolved_filmroll);
  else if(!keep_path)
    gtk_entry_set_text(GTK_ENTRY(d->text), "");
  g_signal_handlers_unblock_matched(d->text, G_SIGNAL_MATCH_FUNC, 0, 0, NULL,
                                    entry_changed, NULL);
  g_free(resolved_filmroll);

  if(property == DT_COLLECTION_PROP_FOLDERS
     || property == DT_COLLECTION_PROP_TAG
     || property == DT_COLLECTION_PROP_GEOTAGGING
     || property == DT_COLLECTION_PROP_DAY
     || _is_time_property(property))
  {
    d->typing = FALSE;
  }

  _set_tooltip(d);

  gchar *order = NULL;
  if(c->active_rule == 0)
  {
    if(prev_property != DT_COLLECTION_PROP_TAG
       && property == DT_COLLECTION_PROP_TAG)
    {
      // save global order
      char buf[4096] = { 0 };
      dt_collection_sort_serialize(buf, sizeof(buf));
      dt_conf_set_string("plugins/lighttable/collect/lastorder", buf);
    }
    else if(prev_property == DT_COLLECTION_PROP_TAG
            && property != DT_COLLECTION_PROP_TAG)
    {
      // restore global order
      order = dt_conf_get_string("plugins/lighttable/collect/lastorder");
      dt_collection_set_tag_id((dt_collection_t *)darktable.collection, 0);
    }
  }

  set_properties(d);
  c->view_rule = -1;
  if(order) DT_CONTROL_SIGNAL_RAISE(DT_SIGNAL_IMAGES_ORDER_CHANGE, order);
  dt_collection_update_query(darktable.collection, DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
}

static void row_activated_with_event(GtkTreeView *view,
                                     GtkTreePath *path,
                                     GtkTreeViewColumn *col,
                                     GdkEventButton *event,
                                     dt_lib_collect_t *d)
{
  GtkTreeIter iter;
  GtkTreeModel *model = NULL;

  GtkTreeSelection *selection = gtk_tree_view_get_selection(GTK_TREE_VIEW(view));

  if(gtk_tree_selection_count_selected_rows(selection) < 1)
    return;

  GList *sels = gtk_tree_selection_get_selected_rows(selection, &model);
  GtkTreePath *path1 = (GtkTreePath *)sels->data;

  if(!gtk_tree_model_get_iter(model, &iter, path1))
  {
    g_list_free_full(sels, (GDestroyNotify)gtk_tree_path_free);
    return;
  }

  gchar *text;
  gchar *order = NULL;

  const int active = d->active_rule;
  d->rule[active].typing = FALSE;

  gboolean force_update_view = FALSE;

  const int item = _combo_get_active_collection(d->rule[active].combo);
  gtk_tree_model_get(model, &iter, DT_LIB_COLLECT_COL_PATH, &text, -1);

  if(text && strlen(text) > 0)
  {
    if(dt_modifier_is(dt_gdk_event_get_state(event), GDK_SHIFT_MASK | GDK_CONTROL_MASK))
    {
      if(item == DT_COLLECTION_PROP_FILMROLL)
      {
        // go to corresponding folder collection
        _combo_set_active_collection(d->rule[active].combo, DT_COLLECTION_PROP_FOLDERS);
      }
      else if(item == DT_COLLECTION_PROP_FOLDERS)
      {
        // go to corresponding filmroll collection
        _combo_set_active_collection(d->rule[active].combo, DT_COLLECTION_PROP_FILMROLL);
        force_update_view = TRUE;
      }
    }
    else if(gtk_tree_selection_count_selected_rows(selection) > 1
            && (item == DT_COLLECTION_PROP_DAY
                || _is_time_property(item)
                || item == DT_COLLECTION_PROP_APERTURE
                || item == DT_COLLECTION_PROP_FOCAL_LENGTH
                || item == DT_COLLECTION_PROP_ISO
                || item == DT_COLLECTION_PROP_EXPOSURE
                || item == DT_COLLECTION_PROP_ASPECT_RATIO
                || item == DT_COLLECTION_PROP_RATING))
    {
      /* this is a range selection */
      GtkTreeIter iter2;
      GtkTreePath *path2 = (GtkTreePath *)g_list_last(sels)->data;
      if(!gtk_tree_model_get_iter(model, &iter2, path2)) return;

      gchar *text2;
      gtk_tree_model_get(model, &iter2, DT_LIB_COLLECT_COL_PATH, &text2, -1);

      gchar *n_text;
      const gboolean sort_descending = dt_conf_get_bool("plugins/collect/descending");

      if(sort_descending)
        n_text = g_strdup_printf("[%s;%s]", text2, text);
      else
        n_text = g_strdup_printf("[%s;%s]", text, text2);

      g_free(text);
      g_free(text2);
      text = n_text;
    }
    else if(item == DT_COLLECTION_PROP_TAG
            || item == DT_COLLECTION_PROP_GEOTAGGING
            || item == DT_COLLECTION_PROP_FOLDERS)
    {
      if(gtk_tree_model_iter_has_child(model, &iter))
      {
        /* if a tag has children, ctrl-clicking on a parent node
         * should display all images under this hierarchy. */
        if(dt_modifier_is(dt_gdk_event_get_state(event), GDK_CONTROL_MASK))
        {
          gchar *n_text = g_strconcat(text, "|%", NULL);
          g_free(text);
          text = n_text;
        }
        /* if a tag has children, left-clicking on a parent node
         * should display all images in and under this hierarchy. */
        else if(!dt_modifier_is(dt_gdk_event_get_state(event), GDK_SHIFT_MASK))
        {
          gchar *n_text = g_strconcat(text, "*", NULL);
          g_free(text);
          text = n_text;
        }
      }
      else if(active == 0 && g_strcmp0(text, _("not tagged")))
      {
        // // first filter is tag and the row is a leave
        const uint32_t tagid = dt_tag_get_tag_id_by_name(text);
        if(tagid)
          dt_collection_set_tag_id((dt_collection_t *)darktable.collection, tagid);
        else
          dt_collection_set_tag_id((dt_collection_t *)darktable.collection, 0);
      }
    }
  }
  g_list_free_full(sels, (GDestroyNotify)gtk_tree_path_free);

  g_signal_handlers_block_matched(d->rule[active].text,
                                  G_SIGNAL_MATCH_FUNC, 0, 0, NULL, entry_changed, NULL);
  gtk_entry_set_text(GTK_ENTRY(d->rule[active].text), text);
  gtk_editable_set_position(GTK_EDITABLE(d->rule[active].text), -1);
  g_signal_handlers_unblock_matched(d->rule[active].text,
                                    G_SIGNAL_MATCH_FUNC, 0, 0, NULL, entry_changed, NULL);
  g_free(text);

  if(item == DT_COLLECTION_PROP_TAG
     || (item == DT_COLLECTION_PROP_FOLDERS && !force_update_view)
     || item == DT_COLLECTION_PROP_DAY
     || _is_time_property(item)
     || item == DT_COLLECTION_PROP_COLORLABEL
     || item == DT_COLLECTION_PROP_GEOTAGGING
     || item == DT_COLLECTION_PROP_HISTORY
     || item == DT_COLLECTION_PROP_LOCAL_COPY)
  {
    set_properties(d->rule + active); // we just have to set the selection
  }
  else
    update_view(d->rule + active); // we have to update visible items too

  dt_control_signal_block_by_func(darktable.signals, G_CALLBACK(collection_updated),
                                  darktable.view_manager->proxy.module_collect.module);
  if(order)
    DT_CONTROL_SIGNAL_RAISE(DT_SIGNAL_IMAGES_ORDER_CHANGE, order);

  dt_collection_update_query(darktable.collection,
                             DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
  dt_control_signal_unblock_by_func(darktable.signals, G_CALLBACK(collection_updated),
                                    darktable.view_manager->proxy.module_collect.module);
  gtk_widget_grab_focus(dt_ui_center(darktable.gui->ui));
  dt_control_queue_redraw_center();
}

static void entry_activated(GtkWidget *entry,
                            dt_lib_collect_rule_t *d)
{
  GtkTreeView *view;
  GtkTreeModel *model;
  int rows;

  update_view(d);
  dt_lib_collect_t *c = get_collect(d);

  const int property = _combo_get_active_collection(d->combo);

  if(property != DT_COLLECTION_PROP_FOLDERS
     && property != DT_COLLECTION_PROP_TAG
     && property != DT_COLLECTION_PROP_GEOTAGGING
     && property != DT_COLLECTION_PROP_DAY
     && !_is_time_property(property))
  {
    view = c->view;
    model = gtk_tree_view_get_model(GTK_TREE_VIEW(view));

    rows = gtk_tree_model_iter_n_children(model, NULL);

    // if only one row, press enter in entry box to fill it with the row value
    if(rows == 1)
    {
      GtkTreeIter iter;
      if(gtk_tree_model_get_iter_first(model, &iter))
      {
        gchar *text;
        gtk_tree_model_get(model, &iter, DT_LIB_COLLECT_COL_PATH, &text, -1);

        g_signal_handlers_block_matched(d->text,
                                        G_SIGNAL_MATCH_FUNC,
                                        0, 0, NULL, entry_changed, NULL);
        gtk_entry_set_text(GTK_ENTRY(d->text), text);
        gtk_editable_set_position(GTK_EDITABLE(d->text), -1);
        g_signal_handlers_unblock_matched(d->text,
                                          G_SIGNAL_MATCH_FUNC,
                                          0, 0, NULL, entry_changed, NULL);
        g_free(text);
        update_view(d);
      }
    }
  }
  dt_control_signal_block_by_func(darktable.signals,
                                  G_CALLBACK(collection_updated),
                                  darktable.view_manager->proxy.module_collect.module);
  dt_collection_update_query(darktable.collection,
                             DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
  dt_control_signal_unblock_by_func(darktable.signals,
                                    G_CALLBACK(collection_updated),
                                    darktable.view_manager->proxy.module_collect.module);
  d->typing = FALSE;

  dt_control_queue_redraw_center();
}

static void entry_changed(GtkEntry *entry,
                          dt_lib_collect_rule_t *dr)
{
  dr->typing = TRUE;
  update_view(dr);
}

int position(const dt_lib_module_t *self)
{
  return 400;
}

static void entry_focus_in_callback(GtkEventController *controller,
                                    dt_lib_collect_rule_t *d)
{
  dt_lib_collect_t *c = get_collect(d);
  if(c->active_rule != d->num)
  {
    c->active_rule = d->num;
    update_view(c->rule + c->active_rule);
  }
}

static gboolean _process_variant_params(GVariant *parameter,
                                        gpointer userdata,
                                        dt_lib_collect_mode_t *mode,
                                        dt_lib_collect_rule_t **d)
{
  const gsize nb = g_variant_n_children(parameter);

  if(nb != 2)
    return FALSE;

  dt_lib_collect_t *m = (dt_lib_collect_t *)userdata;

  GVariant *v = g_variant_get_child_value(parameter, 0);
  *mode = g_variant_get_int32(v);
  g_variant_unref(v);
  
  v = g_variant_get_child_value(parameter, 1);
  const int rule_index = g_variant_get_int32(v);
  g_variant_unref(v);

  *d = &m->rule[rule_index];

  return TRUE;
}

static void _menuitem_mode(GSimpleAction *action,
                           GVariant *parameter,
                           gpointer userdata)
{
  dt_lib_collect_mode_t mode;
  dt_lib_collect_rule_t *d = NULL;

  if(!_process_variant_params(parameter, userdata, &mode, &d))
    return;

  // add next row with and operator
  const int _a = dt_conf_get_int("plugins/lighttable/collect/num_rules");
  const int active = CLAMP(_a, 1, MAX_RULES);

  if(active < MAX_RULES)
  {
    char confname[200] = { 0 };
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/mode%1d", active);

    dt_conf_set_int(confname, mode);
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/string%1d", active);
    dt_conf_set_string(confname, "");
    dt_conf_set_int("plugins/lighttable/collect/num_rules", active + 1);
    dt_lib_collect_t *c = get_collect(d);
    c->active_rule = active;
    c->view_rule = -1;
  }
  dt_collection_update_query(darktable.collection,
                             DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
}

static void _menuitem_mode_change(GSimpleAction *action,
                                  GVariant *parameter,
                                  gpointer userdata)
{
  dt_lib_collect_mode_t mode;
  dt_lib_collect_rule_t *d = NULL;

  if(!_process_variant_params(parameter, userdata, &mode, &d))
    return;

  // add next row with and operator
  const int num = d->num + 1;
  if(num < MAX_RULES && num > 0)
  {
    char confname[200] = { 0 };
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/mode%1d", num);
    dt_conf_set_int(confname, mode);
  }
  dt_lib_collect_t *c = get_collect(d);
  c->view_rule = -1;
  dt_collection_update_query(darktable.collection,
                             DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
}

static void _navigation_update(dt_lib_collect_t *d)
{
  gtk_widget_set_sensitive(d->back, dt_collection_navigation_available(-1));
  gtk_widget_set_sensitive(d->forward, dt_collection_navigation_available(1));
}

static void _navigation_changed(gpointer instance,
                                 const dt_collection_change_t query_change,
                                 const dt_collection_properties_t changed_property,
                                 gpointer imgs,
                                 const int next,
                                 gpointer self)
{
  _navigation_update(((dt_lib_module_t *)self)->data);
}

static void _navigation_back(GtkWidget *widget, gpointer user_data)
{
  dt_collection_navigation_go(-1);
  _navigation_update(((dt_lib_module_t *)user_data)->data);
}

static void _navigation_forward(GtkWidget *widget, gpointer user_data)
{
  dt_collection_navigation_go(1);
  _navigation_update(((dt_lib_module_t *)user_data)->data);
}

static void collection_updated(gpointer instance,
                               const dt_collection_change_t query_change,
                               const dt_collection_properties_t changed_property,
                               gpointer imgs,
                               const int next,
                               gpointer self)
{
  dt_lib_module_t *dm = (dt_lib_module_t *)self;
  dt_lib_collect_t *d = dm->data;

  // update tree
  d->view_rule = -1;
  d->rule[d->active_rule].typing = FALSE;

  // if a move/delete/erase removed the selected film roll/folder, repoint the
  // rule at the closest survivor so we don't collapse back to the tree root
  const gboolean repointed = _fixup_stale_selection(d);

  // determine if we want to refresh the tree or not
  gboolean refresh = TRUE;
  if(!repointed
     && query_change == DT_COLLECTION_CHANGE_RELOAD
     && changed_property != DT_COLLECTION_PROP_UNDEF)
  {
    // if we only reload the collection, that means that we don't
    // change the query itself so we only rebuild the treeview if a
    // used property has changed
    refresh = FALSE;
    for(int i = 0; i <= d->active_rule; i++)
    {
      const int item = _combo_get_active_collection(d->rule[i].combo);
      if(item == changed_property)
      {
        refresh = TRUE;
        break;
      }
    }
  }

  if(refresh)
    _lib_collect_gui_update(self);

  if(repointed)
  {
    // the collection was queried from the now-stale rule; re-run it from the
    // corrected value so the displayed images follow the new selection. Block
    // ourselves to avoid recursing through this same handler.
    dt_control_signal_block_by_func(darktable.signals, G_CALLBACK(collection_updated),
                                    darktable.view_manager->proxy.module_collect.module);
    dt_collection_update_query(darktable.collection, DT_COLLECTION_CHANGE_NEW_QUERY,
                               DT_COLLECTION_PROP_UNDEF, NULL);
    dt_control_signal_unblock_by_func(darktable.signals, G_CALLBACK(collection_updated),
                                      darktable.view_manager->proxy.module_collect.module);
  }
}


static void filmrolls_updated(gpointer instance,
                              gpointer self)
{
  // TODO: We should update the count of images here
  _lib_collect_gui_update(self);
}

static void filmrolls_imported(gpointer instance,
                               const int film_id,
                               gpointer self)
{
  dt_lib_module_t *dm = (dt_lib_module_t *)self;
  dt_lib_collect_t *d = dm->data;

  // update tree
  d->view_rule = -1;
  d->rule[d->active_rule].typing = FALSE;
  _lib_collect_gui_update(self);
}

static void preferences_changed(gpointer instance,
                                gpointer self)
{
  dt_collection_update_query(darktable.collection,
                             DT_COLLECTION_CHANGE_RELOAD,
                             DT_COLLECTION_PROP_UNDEF, NULL);
}

static void filmrolls_removed(gpointer instance,
                              gpointer self)
{
  dt_lib_module_t *dm = (dt_lib_module_t *)self;
  dt_lib_collect_t *d = dm->data;

  // update tree
  if(d->view_rule != DT_COLLECTION_PROP_FOLDERS)
  {
    d->view_rule = -1;
  }
  d->rule[d->active_rule].typing = FALSE;
  _lib_collect_gui_update(self);
}

static void tag_changed(gpointer instance,
                        gpointer self)
{
  dt_lib_module_t *dm = (dt_lib_module_t *)self;
  dt_lib_collect_t *d = dm->data;
  // update tree
  if(_combo_get_active_collection(d->rule[d->active_rule].combo) == DT_COLLECTION_PROP_TAG)
  {
    d->view_rule = -1;
    d->rule[d->active_rule].typing = FALSE;

    // need to reload collection since we have tags as active collection filter
    dt_control_signal_block_by_func(darktable.signals,
                                    G_CALLBACK(collection_updated),
                                    darktable.view_manager->proxy.module_collect.module);
    dt_collection_update_query(darktable.collection,
                               DT_COLLECTION_CHANGE_RELOAD, DT_COLLECTION_PROP_TAG, NULL);
    dt_control_signal_unblock_by_func(darktable.signals,
                                      G_CALLBACK(collection_updated),
                                      darktable.view_manager->proxy.module_collect.module);
  }
  else
  {
    // currently tag filter isn't the one selected but it might be in
    // one of rules. needs check
    gboolean needs_update = FALSE;
    for(int i = 0; i < d->nb_rules && !needs_update; i++)
    {
      needs_update = needs_update
        || _combo_get_active_collection(d->rule[i].combo) == DT_COLLECTION_PROP_TAG;
    }
    if(needs_update)
    {
      // we have tags as one of rules, needs reload.
      dt_control_signal_block_by_func
        (darktable.signals,
         G_CALLBACK(collection_updated),
         darktable.view_manager->proxy.module_collect.module);
      dt_collection_update_query(darktable.collection,
                                 DT_COLLECTION_CHANGE_RELOAD, DT_COLLECTION_PROP_TAG, NULL);
      dt_control_signal_unblock_by_func
        (darktable.signals,
         G_CALLBACK(collection_updated),
         darktable.view_manager->proxy.module_collect.module);
    }
  }
}

static void _geotag_changed(gpointer instance,
                            GList *imgs,
                            const int locid,
                            gpointer self)
{
  // if locid <> NULL this event doesn't concern collect module
  if(!locid)
  {
    dt_lib_module_t *dm = (dt_lib_module_t *)self;
    dt_lib_collect_t *d = dm->data;
    // update tree
    if(_combo_get_active_collection(d->rule[d->active_rule].combo)
       == DT_COLLECTION_PROP_GEOTAGGING)
    {
      d->view_rule = -1;
      d->rule[d->active_rule].typing = FALSE;
      _lib_collect_gui_update(self);

      //need to reload collection since we have geotags as active collection filter
      dt_control_signal_block_by_func
        (darktable.signals,
         G_CALLBACK(collection_updated),
         darktable.view_manager->proxy.module_collect.module);
      dt_collection_update_query(darktable.collection,
                                 DT_COLLECTION_CHANGE_RELOAD, DT_COLLECTION_PROP_GEOTAGGING,
                                 NULL);
      dt_control_signal_unblock_by_func
        (darktable.signals,
         G_CALLBACK(collection_updated),
         darktable.view_manager->proxy.module_collect.module);
    }
  }
}

static void _metadata_changed(gpointer instance,
                             int type,
                             gpointer self)
{
  dt_lib_module_t *dm = (dt_lib_module_t *)self;
  dt_lib_collect_t *d = dm->data;

  if(type == DT_METADATA_SIGNAL_PREF_CHANGED)
  {
    // metadata preferences have changed - update the collection list
    for(int i = 0; i < MAX_RULES; i++)
    {
      g_signal_handlers_block_matched(d->rule[i].combo,
                                      G_SIGNAL_MATCH_FUNC, 0, 0, NULL,
                                      combo_changed, NULL);
      const int property = _combo_get_active_collection(d->rule[i].combo);
      dt_bauhaus_combobox_clear(d->rule[i].combo);
      _populate_collect_combo(d->rule[i].combo);
      if(property != -1 && !_combo_set_active_collection(d->rule[i].combo, property))
      {
        // this one has been hidden - remove entry
        g_signal_handlers_block_matched(d->rule[i].text,
                                        G_SIGNAL_MATCH_FUNC, 0, 0, NULL,
                                        entry_changed, NULL);
        gtk_entry_set_text(GTK_ENTRY(d->rule[i].text), "");
        g_signal_handlers_unblock_matched(d->rule[i].text,
                                          G_SIGNAL_MATCH_FUNC, 0, 0, NULL,
                                          entry_changed, NULL);
        d->rule[i].typing = FALSE;
        set_properties(&d->rule[i]);
      }
      g_signal_handlers_unblock_matched(d->rule[i].combo,
                                        G_SIGNAL_MATCH_FUNC, 0, 0, NULL,
                                        combo_changed, NULL);
    }
  }

  // update collection if metadata have been hidden or a metadata collection is active
  const int prop = _combo_get_active_collection(d->rule[d->active_rule].combo);
  if(type == DT_METADATA_SIGNAL_PREF_CHANGED
     || prop >= DT_COLLECTION_PROP_METADATA_OFFSET)
  {
    dt_collection_update_query(darktable.collection,
                               DT_COLLECTION_CHANGE_RELOAD,
                               DT_COLLECTION_PROP_METADATA,
                               NULL);
  }
}

static void _menuitem_clear(GSimpleAction *simple,
                            GVariant *parameter,
                            gpointer userdata)
{
  dt_lib_collect_t *m = (dt_lib_collect_t*) userdata;

  const int index = g_variant_get_int32(parameter);
  dt_lib_collect_rule_t *d = &m->rule[index];

  // remove this row, or if 1st, clear text entry box
  const int _a = dt_conf_get_int("plugins/lighttable/collect/num_rules");
  const int active = CLAMP(_a, 1, MAX_RULES);
  dt_lib_collect_t *c = get_collect(d);
  if(active > 1)
  {
    dt_conf_set_int("plugins/lighttable/collect/num_rules", active - 1);
    if(c->active_rule >= active - 1) c->active_rule = active - 2;
  }
  else
  {
    dt_conf_set_int("plugins/lighttable/collect/mode0", DT_LIB_COLLECT_MODE_AND);
    dt_conf_set_int("plugins/lighttable/collect/item0", 0);
    dt_conf_set_string("plugins/lighttable/collect/string0", "");
    d->typing = FALSE;
  }
  // move up all still active rules by one.
  for(int i = d->num; i < MAX_RULES - 1; i++)
  {
    char confname[200] = { 0 };
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/mode%1d", i + 1);
    const int mode = dt_conf_get_int(confname);
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/item%1d", i + 1);
    const int item = dt_conf_get_int(confname);
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/string%1d", i + 1);
    gchar *string = dt_conf_get_string(confname);
    if(string)
    {
      snprintf(confname, sizeof(confname), "plugins/lighttable/collect/mode%1d", i);
      dt_conf_set_int(confname, mode);
      snprintf(confname, sizeof(confname), "plugins/lighttable/collect/item%1d", i);
      dt_conf_set_int(confname, item);
      snprintf(confname, sizeof(confname), "plugins/lighttable/collect/string%1d", i);
      dt_conf_set_string(confname, string);
      g_free(string);
    }
  }

  c->view_rule = -1;
  dt_collection_update_query(darktable.collection,
                             DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
}

static gboolean _popup_menu_idle(gpointer user_data)
{
  gtk_popover_popup(GTK_POPOVER(user_data));
  return G_SOURCE_REMOVE;
}

static void popup_button_callback_cb(GtkGestureSingle *gesture,
                                     int n_press,
                                     double x,
                                     double y,
                                     dt_lib_collect_rule_t *d)
{
  if(gtk_gesture_single_get_current_button(gesture) != GDK_BUTTON_PRIMARY)
    return;

  GMenu *menu = g_menu_new();
  const int _a = dt_conf_get_int("plugins/lighttable/collect/num_rules");
  const int active = CLAMP(_a, 1, MAX_RULES);

  gchar *action;
  
  action = g_strdup_printf("collect.clear(%d)", d->num);
  g_menu_append(menu, _("clear this rule"), action);
  g_free(action);
  
  if(d->num == active - 1)
  {
    const char *fmt = "collect.mode((%d,%d))";

    action = g_strdup_printf(fmt, DT_LIB_COLLECT_MODE_AND, d->num);
    g_menu_append(menu, _("narrow down search"), action);
    g_free(action);

    action = g_strdup_printf(fmt, DT_LIB_COLLECT_MODE_OR, d->num);
    g_menu_append(menu, _("add more images"), action);
    g_free(action);

    action = g_strdup_printf(fmt, DT_LIB_COLLECT_MODE_AND_NOT, d->num);
    g_menu_append(menu, _("exclude images"), action);
    g_free(action);
  }
  else if(d->num < active - 1)
  {
    const char *fmt = "collect.modechange((%d,%d))";

    action = g_strdup_printf(fmt, DT_LIB_COLLECT_MODE_AND, d->num);
    g_menu_append(menu, _("change to: and"), action);
    g_free(action);

    action = g_strdup_printf(fmt, DT_LIB_COLLECT_MODE_OR, d->num);
    g_menu_append(menu, _("change to: or"), action);
    g_free(action);

    action = g_strdup_printf(fmt, DT_LIB_COLLECT_MODE_AND_NOT, d->num);
    g_menu_append(menu, _("change to: except"), action);
    g_free(action);
  }

  GtkWidget *popover_menu = dt_gui_popover_menu_from_model(d->button, menu);
  g_object_unref(menu);

  // the click gesture still holds the implicit grab while this handler runs,
  // which would keep the popover from taking its own grab; defer the popup
  // to the next main loop iteration
  g_idle_add(_popup_menu_idle, popover_menu);
}

static void view_set_click(gpointer instance,
                           dt_lib_module_t *self)
{
  dt_lib_collect_t *d = self->data;
  d->singleclick = dt_conf_get_bool("plugins/lighttable/collect/single-click");
}

static void _populate_collect_combo(GtkWidget *w)
{
#define ADD_COLLECT_ENTRY(value)                                                              \
  dt_bauhaus_combobox_add_full(w, dt_collection_name(value), \
                               DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,         \
                               GUINT_TO_POINTER(value + 1), NULL, TRUE)

    dt_bauhaus_combobox_add_section(w, _("files"));
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_FILMROLL);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_FOLDERS);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_FILENAME);

    dt_bauhaus_combobox_add_section(w, _("metadata"));
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_TAG);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_RATING);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_COLORLABEL);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_GEOTAGGING);

    dt_pthread_mutex_lock(&darktable.metadata_threadsafe);
    for(GList *iter = dt_metadata_get_list(); iter; iter = iter->next)
    {
      dt_metadata_t *metadata = iter->data;
      if(!metadata->internal && metadata->visible)
        // metadata name is user defined, so no localization here
        dt_bauhaus_combobox_add_full(w, metadata->name,
                                     DT_BAUHAUS_COMBOBOX_ALIGN_RIGHT,
                                     GUINT_TO_POINTER(DT_COLLECTION_PROP_METADATA_OFFSET + metadata->key + 1),
                                     NULL, TRUE);
    }
    dt_pthread_mutex_unlock(&darktable.metadata_threadsafe);

    dt_bauhaus_combobox_add_section(w, _("times"));
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_DAY);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_MONTH);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_TIME);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_IMPORT_TIMESTAMP);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_CHANGE_TIMESTAMP);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_EXPORT_TIMESTAMP);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_PRINT_TIMESTAMP);

    dt_bauhaus_combobox_add_section(w, _("capture details"));
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_CAMERA);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_LENS);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_APERTURE);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_EXPOSURE);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_EXPOSURE_BIAS);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_FOCAL_LENGTH);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_ISO);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_ASPECT_RATIO);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_WHITEBALANCE);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_FLASH);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_EXPOSURE_PROGRAM);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_METERING_MODE);

    dt_bauhaus_combobox_add_section(w, _("darktable"));
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_GROUP_ID);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_LOCAL_COPY);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_HISTORY);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_MODULE);
    ADD_COLLECT_ENTRY(DT_COLLECTION_PROP_ORDER);

#undef ADD_COLLECT_ENTRY
}

static void _menuitem_preferences(GSimpleAction *action,
                                  GVariant *parameter,
                                  gpointer user_data)
{
  GtkWidget *win = dt_ui_main_window(darktable.gui->ui);
  GtkWidget *dialog = gtk_dialog_new_with_buttons
    (_("collections settings"), GTK_WINDOW(win),
     GTK_DIALOG_DESTROY_WITH_PARENT,
     _("_cancel"), GTK_RESPONSE_NONE,
     _("_save"), GTK_RESPONSE_ACCEPT, NULL);
  gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_ACCEPT);
  dt_prefs_init_dialog_collect(dialog);
  dt_gui_connect_key(dialog, dt_handle_dialog_enter, NULL);

#ifdef GDK_WINDOWING_QUARTZ
  dt_osx_disallow_fullscreen(dialog);
#endif
  gtk_widget_show_all(dialog);
  gtk_dialog_run(GTK_DIALOG(dialog));
  gtk_widget_destroy(dialog);
  dt_collection_update_query(darktable.collection,
                             DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
}

void set_preferences(GMenu *menu, GActionGroup *action_group, dt_lib_module_t *self)
{
  GSimpleAction *action = g_simple_action_new("preferences", NULL);
  g_signal_connect(action, "activate", G_CALLBACK(_menuitem_preferences), self);
  g_action_map_add_action(G_ACTION_MAP(action_group), G_ACTION(action));
  g_menu_append(menu, _("preferences..."), "presets.preferences");
}

static gint _sort_model_func(GtkTreeModel *model,
                             GtkTreeIter *a,
                             GtkTreeIter *b,
                             dt_lib_module_t *self)
{
  gint ia, ib;
  gtk_tree_model_get(model, a, DT_LIB_COLLECT_COL_INDEX, &ia, -1);
  gtk_tree_model_get(model, b, DT_LIB_COLLECT_COL_INDEX, &ib, -1);
  return ib - ia;
}

#ifdef _WIN32
static void _mount_changed(GVolumeMonitor *volume_monitor,
                           GMount *mount,
                           dt_lib_module_t *self)
#else
static void _mount_changed(GUnixMountMonitor *monitor,
                           dt_lib_module_t *self)
#endif
{
  dt_lib_collect_t *d = self->data;
  dt_film_set_folder_status();
  // very rough update (rebuild the view). As these events are not too
  // many that remains acceptable adding film_id to treeview and
  // listview would be cleaner to update just the parameter
  // "reachable"
  dt_lib_collect_rule_t *dr = d->rule + d->active_rule;
  const int property = _combo_get_active_collection(dr->combo);
  if(property == DT_COLLECTION_PROP_FOLDERS)
  {
    d->rule[d->active_rule].typing = FALSE;
    d->view_rule = -1;
    _tree_view(dr);
  }
  else if(property == DT_COLLECTION_PROP_FILMROLL)
  {
    d->rule[d->active_rule].typing = FALSE;
    d->view_rule = -1;
    _list_view(dr);
  }
}

static void _history_apply(GSimpleAction *action,
                           GVariant *parameter,
                           gpointer user_data)
{
  const int hid = g_variant_get_int32(parameter);
  
  if(hid < 0
     || hid >= dt_conf_get_int("plugins/lighttable/collect/history_max"))
    return;

  char confname[200];
  snprintf(confname, sizeof(confname), "plugins/lighttable/collect/history_pos%1d", hid);
  const int pos = dt_conf_get_int(confname);
  snprintf(confname, sizeof(confname), "plugins/lighttable/collect/history%1d", hid);
  const char *line = dt_conf_get_string_const(confname);

  if(line && line[0] != '\0')
  {
    // we store the wanted offset which will be set by thumbtable on
    // collection_change signal
    dt_conf_set_int("plugins/lighttable/collect/history_next_pos", pos);

    const int prev_property = dt_conf_get_int("plugins/lighttable/collect/item0");
    dt_collection_deserialize(line, FALSE);

    // for the tag property we need to adjust the order accordingly
    const int new_property = dt_conf_get_int("plugins/lighttable/collect/item0");
    gchar *order = NULL;
    if(prev_property != DT_COLLECTION_PROP_TAG
       && new_property == DT_COLLECTION_PROP_TAG)
    {
      // save global order
      char buf[4096] = { 0 };
      dt_collection_sort_serialize(buf, sizeof(buf));
      dt_conf_set_string("plugins/lighttable/collect/lastorder", buf);
    }
    else if(prev_property == DT_COLLECTION_PROP_TAG
            && new_property != DT_COLLECTION_PROP_TAG)
    {
      // restore global order
      order = dt_conf_get_string("plugins/lighttable/collect/lastorder");
      dt_collection_set_tag_id((dt_collection_t *)darktable.collection, 0);
    }
    if(order)
      DT_CONTROL_SIGNAL_RAISE(DT_SIGNAL_IMAGES_ORDER_CHANGE, order);
  }
}

static void _history_pretty_print(const char *buf,
                                  char *out,
                                  size_t outsize)
{
  memset(out, 0, outsize);

  if(!buf || buf[0] == '\0') return;

  int num_rules = 0;
  char str[400] = { 0 };
  int mode, item;
  int c;
  sscanf(buf, "%d", &num_rules);
  while(buf[0] != '\0' && buf[0] != ':') buf++;
  if(buf[0] == ':') buf++;

  for(int k = 0; k < num_rules; k++)
  {
    const int n = sscanf(buf, "%d:%d:%399[^$]", &mode, &item, str);

    if(n == 3)
    {
      if(k > 0)
      {
        c = g_strlcpy(out, "   ", outsize);
        out += c;
        outsize -= c;
        switch(mode)
        {
          case DT_LIB_COLLECT_MODE_AND:
            c = g_strlcpy(out, _("AND"), outsize);
            out += c;
            outsize -= c;
            break;
          case DT_LIB_COLLECT_MODE_OR:
            c = g_strlcpy(out, _("OR"), outsize);
            out += c;
            outsize -= c;
            break;
          default: // case DT_LIB_COLLECT_MODE_AND_NOT:
            c = g_strlcpy(out, _("BUT NOT"), outsize);
            out += c;
            outsize -= c;
            break;
        }
        c = g_strlcpy(out, "   ", outsize);
        out += c;
        outsize -= c;
      }
      int i = 0;
      while(str[i] != '\0' && str[i] != '$') i++;
      if(str[i] == '$') str[i] = '\0';

      gchar *pretty = NULL;
      if(!g_strcmp0(str, "%"))
        pretty = g_strdup(_("all"));
      else if(item == DT_COLLECTION_PROP_FILMROLL)
        pretty = g_strdup(dt_image_film_roll_name(str));
      else
        pretty = g_strdup(str);

      c = snprintf(out, outsize, "%s: %s",
                   item < DT_COLLECTION_PROP_LAST ? dt_collection_name(item) : "???",
                   pretty);
      g_free(pretty);
      out += c;
      outsize -= c;
    }
    while(buf[0] != '$' && buf[0] != '\0') buf++;
    if(buf[0] == '$') buf++;
  }
}

static void _history_show(GtkWidget *widget,
                          dt_lib_module_t *self)
{
  GActionGroup *action_group = gtk_widget_get_action_group(widget, "history");
  if(action_group == NULL)
  {
    GActionEntry action_entries[] =
    {
      { "activate", _history_apply, "i", NULL }
    };

    action_group = G_ACTION_GROUP(g_simple_action_group_new());
    g_action_map_add_action_entries(G_ACTION_MAP(action_group),
                                    action_entries,
                                    G_N_ELEMENTS(action_entries),
                                    self);
    gtk_widget_insert_action_group(widget, 
                                   "history",
                                   G_ACTION_GROUP(action_group));
    g_object_unref(action_group);
  }

  // we show a popup with all the history entries
  GMenu *menu = g_menu_new();

  const int maxitems = dt_conf_get_int("plugins/lighttable/collect/history_max");

  for(int i = 0; i < maxitems; i++)
  {
    char confname[200];
    snprintf(confname, sizeof(confname), "plugins/lighttable/collect/history%1d", i);
    const char *line = dt_conf_get_string_const(confname);
    if(line && line[0] != '\0')
    {
      char str[2048] = { 0 };
      _history_pretty_print(line, str, sizeof(str));
      gchar *action = g_strdup_printf("history.activate(%i)", i);
      gchar *label = dt_str_unmnemonic(str);
      g_menu_append(menu, label, action);
      g_free(label);
      g_free(action);
    }
    else
      break;
  }

  // popup the menu
  GtkWidget *popover_menu = dt_gui_popover_menu_from_model(GTK_WIDGET(widget), menu);
  g_object_unref(menu);
  gtk_popover_popup(GTK_POPOVER(popover_menu));
}

static void _history_previous(dt_action_t *action)
{
  const char *line = dt_conf_get_string_const("plugins/lighttable/collect/history1");

  if(line && g_strcmp0(line, ""))
  {
    dt_collection_deserialize(line, FALSE);
  }
}

static void _sort_reverse_changed(GtkDarktableToggleButton *widget, gpointer user_data)
{
  const gboolean reverse = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(widget));

  if(reverse)
    dtgtk_togglebutton_set_paint(DTGTK_TOGGLEBUTTON(widget),
                                 dtgtk_cairo_paint_sortby,
                                 CPF_DIRECTION_DOWN, NULL);
  else
    dtgtk_togglebutton_set_paint(DTGTK_TOGGLEBUTTON(widget),
                                 dtgtk_cairo_paint_sortby,
                                 CPF_DIRECTION_UP, NULL);
  dt_conf_set_bool("plugins/collect/descending", reverse);
  gtk_widget_queue_draw((GtkWidget*)widget);

  dt_collection_update_query(darktable.collection,
                             DT_COLLECTION_CHANGE_NEW_QUERY,
                             DT_COLLECTION_PROP_UNDEF, NULL);
}

GtkWidget *gui_tool_box(dt_lib_module_t *self)
{
  // specific header action
  const gboolean sort_descend = dt_conf_get_bool("plugins/collect/descending");

  GtkWidget *sortb = dtgtk_togglebutton_new(dtgtk_cairo_paint_sortby,
                                            sort_descend
                                            ? CPF_DIRECTION_DOWN
                                            : CPF_DIRECTION_UP,
                                            NULL);
  gtk_widget_set_tooltip_text(sortb, _("toggle collection sort order ascending/descending"));
  dt_gui_add_class(sortb, "dt_ignore_fg_state");
  gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(sortb), sort_descend);
  g_signal_connect(G_OBJECT(sortb),
                   "toggled",
                   G_CALLBACK(_sort_reverse_changed), self);
  return sortb;
}

void gui_init(dt_lib_module_t *self)
{
  dt_lib_collect_t *d = self->data = calloc(1, sizeof(dt_lib_collect_t));

  self->widget = dt_gui_vbox();
  dt_gui_add_class(self->widget, "dt_spacing_sw");

  // setup the actions for this module
  const GActionEntry entries[] = {
    { "clear",       _menuitem_clear,       "i"    },
    { "mode",        _menuitem_mode,        "(ii)" },
    { "modechange",  _menuitem_mode_change, "(ii)" }
  };

  d->action_group = g_simple_action_group_new();
  g_action_map_add_action_entries(G_ACTION_MAP(d->action_group), entries, G_N_ELEMENTS(entries), d);
  gtk_widget_insert_action_group(self->widget, "collect", G_ACTION_GROUP(d->action_group));

  d->active_rule = 0;
  d->nb_rules = 0;
  d->params = (dt_lib_collect_params_t *)malloc(sizeof(dt_lib_collect_params_t));
  view_set_click(NULL, self);

  gboolean has_iop_name_rule = FALSE;
  for(int i = 0; i < MAX_RULES; i++)
  {
    d->rule[i].num = i;
    d->rule[i].typing = FALSE;

    GtkWidget *w = NULL;
    d->rule[i].combo = w = dt_bauhaus_combobox_new(NULL);
    dt_bauhaus_combobox_set_selected_text_align(w, DT_BAUHAUS_COMBOBOX_ALIGN_LEFT);
    _populate_collect_combo(w);
    dt_bauhaus_combobox_mute_scrolling(w);
    if(_combo_get_active_collection(w) == DT_COLLECTION_PROP_MODULE)
      has_iop_name_rule = TRUE;
    g_signal_connect(G_OBJECT(w), "value-changed",
                     G_CALLBACK(combo_changed), d->rule + i);

    d->rule[i].text = w = dt_ui_entry_new(10);
    gtk_widget_add_events(w, GDK_FOCUS_CHANGE_MASK | GDK_KEY_PRESS_MASK);
    dt_gui_connect_focus(w, entry_focus_in_callback, NULL, d->rule + i);
    g_signal_connect(G_OBJECT(w), "changed", G_CALLBACK(entry_changed), d->rule + i);
    g_signal_connect(G_OBJECT(w), "activate", G_CALLBACK(entry_activated), d->rule + i);
    gtk_entry_set_width_chars(GTK_ENTRY(w), 5);

    d->rule[i].button = w = dtgtk_button_new(dtgtk_cairo_paint_presets, 0, NULL);
    dt_gui_add_class(GTK_WIDGET(w), "dt_big_btn_canvas");
    dt_gui_connect_click(w, popup_button_callback_cb, NULL, d->rule + i);

    d->rule[i].hbox = dt_gui_hbox(d->rule[i].combo, dt_gui_expand(d->rule[i].text), d->rule[i].button);
    gtk_widget_set_name(d->rule[i].hbox, "lib-dtbutton");
    dt_gui_box_add(self->widget, d->rule[i].hbox);
  }

  GtkTreeView *view = GTK_TREE_VIEW(gtk_tree_view_new());
  d->view_rule = -1;
  d->view = view;
  gtk_tree_view_set_headers_visible(view, FALSE);
  /* the treeview owns an internal bubble-phase GtkGestureMultiPress that
   * would fight our own selection handling: use a CAPTURE-phase gesture
   * that claims the sequence, replicating the event consumption of the
   * pre-migration button-press-event handler */
  GtkGesture *gesture = gtk_gesture_multi_press_new(GTK_WIDGET(view));
  gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(gesture),
                                             GTK_PHASE_CAPTURE);
  dt_gui_add_controller(GTK_WIDGET(view), gesture);
  gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(gesture), 0);
  g_signal_connect(gesture, "pressed", G_CALLBACK(view_onButtonPressed_cb), d);
  g_signal_connect(gesture, "begin", G_CALLBACK(_gesture_begin_claim), d);
  g_signal_connect(G_OBJECT(view), "popup-menu", G_CALLBACK(view_onPopupMenu), d);

  GtkTreeViewColumn *col = gtk_tree_view_column_new();
  gtk_tree_view_append_column(view, col);
  GtkCellRenderer *renderer = gtk_cell_renderer_text_new();
  gtk_tree_view_column_pack_start(col, renderer, TRUE);
  gtk_tree_view_column_set_cell_data_func(col, renderer, tree_count_show, NULL, NULL);
  g_object_set(renderer, "strikethrough", TRUE, "ellipsize",
               PANGO_ELLIPSIZE_MIDDLE, (gchar *)0);
  gtk_tree_view_column_add_attribute(col, renderer, "strikethrough-set",
                                     DT_LIB_COLLECT_COL_UNREACHABLE);

  GtkTreeModel *listmodel = GTK_TREE_MODEL
    (gtk_list_store_new
     (DT_LIB_COLLECT_NUM_COLS, G_TYPE_STRING, G_TYPE_UINT,
      G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN,
      G_TYPE_BOOLEAN, G_TYPE_UINT, G_TYPE_UINT));
  gtk_tree_sortable_set_sort_func(GTK_TREE_SORTABLE(listmodel), DT_LIB_COLLECT_COL_INDEX,
                                  (GtkTreeIterCompareFunc)_sort_model_func, self, NULL);
  d->listfilter = gtk_tree_model_filter_new(listmodel, NULL);
  gtk_tree_model_filter_set_visible_column
    (GTK_TREE_MODEL_FILTER(d->listfilter), DT_LIB_COLLECT_COL_VISIBLE);

  GtkTreeModel *treemodel = GTK_TREE_MODEL
    (gtk_tree_store_new(DT_LIB_COLLECT_NUM_COLS, G_TYPE_STRING, G_TYPE_UINT,
                        G_TYPE_STRING, G_TYPE_STRING, G_TYPE_BOOLEAN,
                        G_TYPE_BOOLEAN, G_TYPE_UINT, G_TYPE_UINT));
  d->treefilter = gtk_tree_model_filter_new(treemodel, NULL);
  gtk_tree_model_filter_set_visible_column(GTK_TREE_MODEL_FILTER(d->treefilter),
                                           DT_LIB_COLLECT_COL_VISIBLE);
  g_object_unref(treemodel);

  // the bottom buttons for the rules. the actions keep their names for
  // shortcuts; the buttons show the symbols, which need no translation
  d->back = dt_action_button_new(self,
                                 N_("back"),
                                 G_CALLBACK(_navigation_back), self,
                                 _("go back to the previous collection"),
                                 0, 0);
  gtk_button_set_label(GTK_BUTTON(d->back), "<");
  d->forward = dt_action_button_new(self,
                                    N_("forward"),
                                    G_CALLBACK(_navigation_forward), self,
                                    _("go forward to the next collection"),
                                    0, 0);
  gtk_button_set_label(GTK_BUTTON(d->forward), ">");
  dt_collection_navigation_record_current();
  _navigation_update(d);

  d->history_button = dt_action_button_new(self,
                                           N_("history"),
                                           G_CALLBACK(_history_show), self,
                                           _("revert to a previous set of rules"),
                                           GDK_KEY_k, GDK_CONTROL_MASK);
  // homogeneous halves align the history button with those in filtering lib
  d->history_box = dt_gui_hbox(dt_gui_hbox(d->back, d->forward), d->history_button);
  gtk_box_set_homogeneous(GTK_BOX(d->history_box), TRUE);
  gtk_widget_show_all(d->history_box);
  gtk_widget_set_no_show_all(d->history_button, TRUE);

  dt_gui_box_add(self->widget, dt_ui_resize_wrap(GTK_WIDGET(view), 200,
                                                 "plugins/lighttable/collect/windowheight"),
                               d->history_box);

  /* setup proxy */
  darktable.view_manager->proxy.module_collect.module = self;
  darktable.view_manager->proxy.module_collect.update = _lib_collect_gui_update;

  _lib_collect_gui_update(self);

  if(_combo_get_active_collection(d->rule[0].combo) == DT_COLLECTION_PROP_TAG)
  {
    const char *tag = dt_conf_get_string_const("plugins/lighttable/collect/string0");
    dt_collection_set_tag_id((dt_collection_t *)darktable.collection,
                             dt_tag_get_tag_id_by_name(tag));
  }

#ifdef _WIN32
  d->vmonitor = g_volume_monitor_get();
  g_signal_connect(G_OBJECT(d->vmonitor), "mount-changed",
                   G_CALLBACK(_mount_changed), self);
  g_signal_connect(G_OBJECT(d->vmonitor), "mount-added",
                   G_CALLBACK(_mount_changed), self);
#else
  d->vmonitor = g_unix_mount_monitor_get();
  g_signal_connect(G_OBJECT(d->vmonitor), "mounts-changed",
                   G_CALLBACK(_mount_changed), self);
#endif

  // force redraw collection images because of late update of the
  // table memory.darktable_iop_names
  if(has_iop_name_rule)
    dt_collection_update_query(darktable.collection,
                               DT_COLLECTION_CHANGE_RELOAD,
                               DT_COLLECTION_PROP_MODULE, NULL);

  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_COLLECTION_CHANGED, collection_updated);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_COLLECTION_CHANGED, _navigation_changed);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_FILMROLLS_CHANGED, filmrolls_updated);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_PREFERENCES_CHANGE, preferences_changed);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_FILMROLLS_IMPORTED, filmrolls_imported);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_FILMROLLS_REMOVED, filmrolls_removed);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_TAG_CHANGED, tag_changed);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_GEOTAG_CHANGED, _geotag_changed);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_METADATA_CHANGED, _metadata_changed);
  DT_CONTROL_SIGNAL_HANDLE(DT_SIGNAL_PREFERENCES_CHANGE, view_set_click);

  dt_action_register(DT_ACTION(self), N_("jump back to previous collection"),
                     _history_previous, GDK_KEY_k,
                     GDK_CONTROL_MASK | GDK_SHIFT_MASK);
}

void gui_cleanup(dt_lib_module_t *self)
{
  dt_lib_collect_t *d = self->data;

  darktable.view_manager->proxy.module_collect.module = NULL;
  free(d->params);

  /* cleanup mem */

  g_object_unref(d->treefilter);
  g_object_unref(d->listfilter);
  g_object_unref(d->vmonitor);

  g_object_unref(d->action_group);

  /* TODO: Make sure we are cleaning up all allocations */

  free(self->data);
  self->data = NULL;
}

static int _is_time_property(int property)
{
  return (property == DT_COLLECTION_PROP_TIME
          || property == DT_COLLECTION_PROP_IMPORT_TIMESTAMP
          || property == DT_COLLECTION_PROP_CHANGE_TIMESTAMP
          || property == DT_COLLECTION_PROP_EXPORT_TIMESTAMP
          || property == DT_COLLECTION_PROP_PRINT_TIMESTAMP);
}

#ifdef USE_LUA
static int new_rule_cb(lua_State*L)
{
  dt_lib_collect_params_rule_t rule;
  memset(&rule, 0, sizeof(dt_lib_collect_params_rule_t));
  luaA_push(L, dt_lib_collect_params_rule_t, &rule);
  return 1;
}

static int filter_cb(lua_State *L)
{
  dt_lib_module_t *self = lua_touserdata(L, lua_upvalueindex(1));

  int size;
  dt_lib_collect_params_t *p = get_params(self, &size);
  // put it in stack so memory is not lost if a lua exception is raised

  if(lua_gettop(L) > 0)
  {
    luaL_checktype(L, 1, LUA_TTABLE);
    dt_lib_collect_params_t *new_p = get_params(self, &size);
    new_p->rules = 0;

    do
    {
      lua_pushinteger(L, new_p->rules + 1);
      lua_gettable(L, 1);
      if(lua_isnil(L, -1)) break;
      luaA_to(L, dt_lib_collect_params_rule_t, &new_p->rule[new_p->rules], -1);
      new_p->rules++;
    } while(new_p->rules < MAX_RULES);

    if(new_p->rules == MAX_RULES) {
      lua_pushinteger(L, new_p->rules + 1);
      lua_gettable(L, 1);
      if(!lua_isnil(L, -1)) {
        luaL_error(L, "Number of rules given exceeds max allowed (%d)", MAX_RULES);
      }
    }
    set_params(self, new_p, size);
    free(new_p);

  }

  lua_newtable(L);
  for(int i = 0; i < p->rules; i++)
  {
    luaA_push(L, dt_lib_collect_params_rule_t, &p->rule[i]);
    lua_seti(L, -2, i + 1);  // lua tables are 1 based
  }
  free(p);
  return 1;
}

static int mode_member(lua_State *L)
{
  dt_lib_collect_params_rule_t *rule =
    luaL_checkudata(L, 1, "dt_lib_collect_params_rule_t");

  if(lua_gettop(L) > 2)
  {
    dt_lib_collect_mode_t value;
    luaA_to(L, dt_lib_collect_mode_t, &value, 3);
    rule->mode = value;
    return 0;
  }

  const dt_lib_collect_mode_t tmp = rule->mode; // temp buffer because
                                                // of bitfield in the
                                                // original struct
  luaA_push(L, dt_lib_collect_mode_t, &tmp);
  return 1;
}

static int item_member(lua_State *L)
{
  dt_lib_collect_params_rule_t *rule =
    luaL_checkudata(L, 1, "dt_lib_collect_params_rule_t");

  if(lua_gettop(L) > 2)
  {
    dt_collection_properties_t value;
    luaA_to(L, dt_collection_properties_t, &value, 3);
    rule->item = value;
    return 0;
  }

  const dt_collection_properties_t tmp = rule->item; // temp buffer
                                                     // because of
                                                     // bitfield in
                                                     // the original
                                                     // struct
  luaA_push(L, dt_collection_properties_t, &tmp);
  return 1;
}

static int data_member(lua_State *L)
{
  dt_lib_collect_params_rule_t *rule =
    luaL_checkudata(L, 1, "dt_lib_collect_params_rule_t");

  if(lua_gettop(L) > 2)
  {
    size_t tgt_size;
    const char*data = luaL_checklstring(L, 3, &tgt_size);
    if(tgt_size > PARAM_STRING_SIZE)
    {
      return luaL_error(L, "string '%s' too long (max is %d)",
                        data, PARAM_STRING_SIZE);
    }
    g_strlcpy(rule->string, data, sizeof(rule->string));
    return 0;
  }

  lua_pushstring(L, rule->string);
  return 1;
}

void init(struct dt_lib_module_t *self)
{
  lua_State *L = darktable.lua_state.state;
  int my_type = dt_lua_module_entry_get_type(L, "lib", self->plugin_name);
  lua_pushlightuserdata(L, self);
  lua_pushcclosure(L, filter_cb, 1);
  dt_lua_gtk_wrap(L);
  lua_pushcclosure(L, dt_lua_type_member_common, 1);
  dt_lua_type_register_const_type(L, my_type, "filter");
  lua_pushcfunction(L, new_rule_cb);
  lua_pushcclosure(L, dt_lua_type_member_common, 1);
  dt_lua_type_register_const_type(L, my_type, "new_rule");

  dt_lua_init_type(L, dt_lib_collect_params_rule_t);
  lua_pushcfunction(L, mode_member);
  dt_lua_type_register(L, dt_lib_collect_params_rule_t, "mode");
  lua_pushcfunction(L, item_member);
  dt_lua_type_register(L, dt_lib_collect_params_rule_t, "item");
  lua_pushcfunction(L, data_member);
  dt_lua_type_register(L, dt_lib_collect_params_rule_t, "data");


  luaA_enum(L, dt_lib_collect_mode_t);
  luaA_enum_value(L, dt_lib_collect_mode_t, DT_LIB_COLLECT_MODE_AND);
  luaA_enum_value(L, dt_lib_collect_mode_t, DT_LIB_COLLECT_MODE_OR);
  luaA_enum_value(L, dt_lib_collect_mode_t, DT_LIB_COLLECT_MODE_AND_NOT);

  luaA_enum(L, dt_collection_properties_t);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_FILMROLL);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_FOLDERS);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_CAMERA);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_TAG);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_DAY);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_TIME);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_IMPORT_TIMESTAMP);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_CHANGE_TIMESTAMP);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_EXPORT_TIMESTAMP);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_PRINT_TIMESTAMP);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_HISTORY);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_RATING);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_COLORLABEL);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_WHITEBALANCE);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_FLASH);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_EXPOSURE_PROGRAM);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_METERING_MODE);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_GROUP_ID);

  dt_pthread_mutex_lock(&darktable.metadata_threadsafe);
  for(GList *iter = dt_metadata_get_list(); iter; iter = iter->next)
  {
    dt_metadata_t *metadata = iter->data;
    if(!metadata->internal && metadata->visible)
      luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_METADATA_OFFSET + metadata->key);
  }
  dt_pthread_mutex_unlock(&darktable.metadata_threadsafe);

  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_LENS);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_FOCAL_LENGTH);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_ISO);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_APERTURE);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_ASPECT_RATIO);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_EXPOSURE);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_EXPOSURE_BIAS);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_FILENAME);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_GEOTAGGING);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_LOCAL_COPY);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_MODULE);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_ORDER);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_DUPLICATES);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_MONTH);
  luaA_enum_value(L, dt_collection_properties_t, DT_COLLECTION_PROP_DIMENSIONS);
}
#endif
#undef MAX_RULES

// clang-format off
// modelines: These editor modelines have been set for all relevant files by tools/update_modelines.py
// vim: shiftwidth=2 expandtab tabstop=2 cindent
// kate: tab-indents: off; indent-width 2; replace-tabs on; indent-mode cstyle; remove-trailing-spaces modified;
// clang-format on