1/* Copyright 2016 Software Freedom Conservancy Inc.
2 *
3 * This software is licensed under the GNU LGPL (version 2.1 or later).
4 * See the COPYING file in this distribution.
5 */
6
7// For specifying whether a search should be ORed (any) or ANDed (all).
8public enum SearchOperator {
9    ANY = 0,
10    ALL,
11    NONE;
12
13    public string to_string() {
14        switch (this) {
15            case SearchOperator.ANY:
16                return "ANY";
17
18            case SearchOperator.ALL:
19                return "ALL";
20
21            case SearchOperator.NONE:
22                return "NONE";
23
24            default:
25                error("unrecognized search operator enumeration value");
26        }
27    }
28
29    public static SearchOperator from_string(string str) {
30        if (str == "ANY")
31            return SearchOperator.ANY;
32
33        else if (str == "ALL")
34            return SearchOperator.ALL;
35
36        else if (str == "NONE")
37            return SearchOperator.NONE;
38
39        else
40            error("unrecognized search operator name: %s", str);
41    }
42}
43
44// Important note: if you are adding, removing, or otherwise changing
45// this table, you're going to have to modify SavedSearchDBTable.vala
46// as well.
47public abstract class SearchCondition {
48    // Type of search condition.
49    public enum SearchType {
50        ANY_TEXT = 0,
51        TITLE,
52        TAG,
53        EVENT_NAME,
54        FILE_NAME,
55#if ENABLE_FACES
56        FACE,
57#endif
58        MEDIA_TYPE,
59        FLAG_STATE,
60        MODIFIED_STATE,
61        RATING,
62        COMMENT,
63        DATE;
64        // Note: when adding new types, be sure to update all functions below.
65
66        public static SearchType[] as_array() {
67            return { ANY_TEXT, TITLE, TAG, COMMENT, EVENT_NAME, FILE_NAME,
68#if ENABLE_FACES
69            FACE,
70#endif
71            MEDIA_TYPE, FLAG_STATE, MODIFIED_STATE, RATING, DATE };
72        }
73
74        // Sorts an array alphabetically by display name.
75        public static void sort_array(ref SearchType[] array) {
76            Posix.qsort(array, array.length, sizeof(SearchType), (a, b) => {
77                return utf8_cs_compare(((*(SearchType*) a)).display_text(),
78                    ((*(SearchType*) b)).display_text());
79            });
80        }
81
82        public string to_string() {
83            switch (this) {
84                case SearchType.ANY_TEXT:
85                    return "ANY_TEXT";
86
87                case SearchType.TITLE:
88                    return "TITLE";
89
90                case SearchType.TAG:
91                    return "TAG";
92
93                case SearchType.COMMENT:
94                    return "COMMENT";
95
96                case SearchType.EVENT_NAME:
97                    return "EVENT_NAME";
98
99                case SearchType.FILE_NAME:
100                    return "FILE_NAME";
101#if ENABLE_FACES
102                case SearchType.FACE:
103                    return "FACE";
104#endif
105                case SearchType.MEDIA_TYPE:
106                    return "MEDIA_TYPE";
107
108                case SearchType.FLAG_STATE:
109                    return "FLAG_STATE";
110
111                case SearchType.MODIFIED_STATE:
112                    return "MODIFIED_STATE";
113
114                case SearchType.RATING:
115                    return "RATING";
116
117                case SearchType.DATE:
118                    return "DATE";
119
120                default:
121                    error("unrecognized search type enumeration value");
122            }
123        }
124
125        public static SearchType from_string(string str) {
126            if (str == "ANY_TEXT")
127                return SearchType.ANY_TEXT;
128
129            else if (str == "TITLE")
130                return SearchType.TITLE;
131
132            else if (str == "TAG")
133                return SearchType.TAG;
134
135            else if (str == "COMMENT")
136                return SearchType.COMMENT;
137
138            else if (str == "EVENT_NAME")
139                return SearchType.EVENT_NAME;
140
141            else if (str == "FILE_NAME")
142                return SearchType.FILE_NAME;
143#if ENABLE_FACES
144            else if (str == "FACE")
145                return SearchType.FACE;
146#endif
147            else if (str == "MEDIA_TYPE")
148                return SearchType.MEDIA_TYPE;
149
150            else if (str == "FLAG_STATE")
151                return SearchType.FLAG_STATE;
152
153            else if (str == "MODIFIED_STATE")
154                return SearchType.MODIFIED_STATE;
155
156            else if (str == "RATING")
157                return SearchType.RATING;
158
159            else if (str == "DATE")
160                return SearchType.DATE;
161
162            else
163                error("unrecognized search type name: %s", str);
164        }
165
166        public string display_text() {
167            switch (this) {
168                case SearchType.ANY_TEXT:
169                    return _("Any text");
170
171                case SearchType.TITLE:
172                    return _("Title");
173
174                case SearchType.TAG:
175                    return _("Tag");
176
177                case SearchType.COMMENT:
178                    return _("Comment");
179
180                case SearchType.EVENT_NAME:
181                    return _("Event name");
182
183                case SearchType.FILE_NAME:
184                    return _("File name");
185#if ENABLE_FACES
186                case SearchType.FACE:
187                    return _("Face");
188#endif
189                case SearchType.MEDIA_TYPE:
190                    return _("Media type");
191
192                case SearchType.FLAG_STATE:
193                    return _("Flag state");
194
195                case SearchType.MODIFIED_STATE:
196                    return _("Photo state");
197
198                case SearchType.RATING:
199                    return _("Rating");
200
201                case SearchType.DATE:
202                    return _("Date");
203
204                default:
205                    error("unrecognized search type enumeration value");
206            }
207        }
208    }
209
210    public SearchType search_type { get; protected set; }
211
212    // Determines whether the source is included.
213    public abstract bool predicate(MediaSource source);
214}
215
216// Condition for text matching.
217public class SearchConditionText : SearchCondition {
218    public enum Context {
219        CONTAINS = 0,
220        IS_EXACTLY,
221        STARTS_WITH,
222        ENDS_WITH,
223        DOES_NOT_CONTAIN,
224        IS_NOT_SET,
225        IS_SET;
226
227        public string to_string() {
228            switch (this) {
229                case Context.CONTAINS:
230                    return "CONTAINS";
231
232                case Context.IS_EXACTLY:
233                    return "IS_EXACTLY";
234
235                case Context.STARTS_WITH:
236                    return "STARTS_WITH";
237
238                case Context.ENDS_WITH:
239                    return "ENDS_WITH";
240
241                case Context.DOES_NOT_CONTAIN:
242                    return "DOES_NOT_CONTAIN";
243
244                case Context.IS_NOT_SET:
245                    return "IS_NOT_SET";
246
247                case Context.IS_SET:
248                    return "IS_SET";
249
250                default:
251                    error("unrecognized text search context enumeration value");
252            }
253        }
254
255        public static Context from_string(string str) {
256            if (str == "CONTAINS")
257                return Context.CONTAINS;
258
259            else if (str == "IS_EXACTLY")
260                return Context.IS_EXACTLY;
261
262            else if (str == "STARTS_WITH")
263                return Context.STARTS_WITH;
264
265            else if (str == "ENDS_WITH")
266                return Context.ENDS_WITH;
267
268            else if (str == "DOES_NOT_CONTAIN")
269                return Context.DOES_NOT_CONTAIN;
270
271            else if (str == "IS_NOT_SET")
272                return Context.IS_NOT_SET;
273
274            else if (str == "IS_SET")
275                return Context.IS_SET;
276
277            else
278                error("unrecognized text search context name: %s", str);
279        }
280    }
281
282    // What to search for.
283    public string text { get; private set; }
284
285    // How to match.
286    public Context context { get; private set; }
287
288    public SearchConditionText(SearchCondition.SearchType search_type, string? text, Context context) {
289        this.search_type = search_type;
290        this.text = (text != null) ? String.remove_diacritics(text.down()) : "";
291        this.context = context;
292    }
293
294    // Match string by context.
295    private bool string_match(string needle, string? haystack) {
296        switch (context) {
297            case Context.CONTAINS:
298            case Context.DOES_NOT_CONTAIN:
299                return !is_string_empty(haystack) && haystack.contains(needle);
300
301            case Context.IS_EXACTLY:
302                return !is_string_empty(haystack) && haystack == needle;
303
304            case Context.STARTS_WITH:
305                return !is_string_empty(haystack) && haystack.has_prefix(needle);
306
307            case Context.ENDS_WITH:
308                return !is_string_empty(haystack) && haystack.has_suffix(needle);
309
310            case Context.IS_NOT_SET:
311                return (is_string_empty(haystack));
312
313            case Context.IS_SET:
314                return (!is_string_empty(haystack));
315        }
316
317        return false;
318    }
319
320    // Determines whether the source is included.
321    public override bool predicate(MediaSource source) {
322        bool ret = false;
323
324        // title
325        if (SearchType.ANY_TEXT == search_type || SearchType.TITLE == search_type) {
326            string? title = (null != source.get_title()) ?
327                String.remove_diacritics(source.get_title().down()) : null;
328            ret |= string_match(text, title);
329        }
330
331        // tags
332        if (SearchType.ANY_TEXT == search_type || SearchType.TAG == search_type) {
333            Gee.List<Tag>? tag_list = Tag.global.fetch_for_source(source);
334            if (null != tag_list) {
335                string itag;
336                foreach (Tag tag in tag_list) {
337                    itag = tag.get_searchable_name().down(); // get_searchable already remove diacritics
338                    ret |= string_match(text, itag);
339                }
340            } else {
341                ret |= string_match(text, null); // for IS_NOT_SET
342            }
343        }
344
345        // event name
346        if (SearchType.ANY_TEXT == search_type || SearchType.EVENT_NAME == search_type) {
347            string? event_name = (null != source.get_event()) ?
348                String.remove_diacritics(source.get_event().get_name().down()) : null;
349            ret |= string_match(text, event_name);
350        }
351
352        // comment
353        if (SearchType.ANY_TEXT == search_type || SearchType.COMMENT == search_type) {
354            string? comment = source.get_comment();
355            if(null != comment)
356                ret |= string_match(text, String.remove_diacritics(comment.down()));
357        }
358
359        // file name
360        if (SearchType.ANY_TEXT == search_type || SearchType.FILE_NAME == search_type) {
361            ret |= string_match(text, String.remove_diacritics(source.get_basename().down()));
362        }
363
364#if ENABLE_FACES
365        if (SearchType.ANY_TEXT == search_type || SearchType.FACE == search_type) {
366            Gee.List<Face>? face_list = Face.global.fetch_for_source(source);
367            if (null != face_list) {
368                foreach (Face face in face_list) {
369                    ret |= string_match(text, face.get_name().down());
370                }
371            } else {
372                ret |= string_match(text, null); // for IS_NOT_SET
373            }
374        }
375#endif
376
377        return (context == Context.DOES_NOT_CONTAIN) ? !ret : ret;
378    }
379}
380
381// Condition for media type matching.
382public class SearchConditionMediaType : SearchCondition {
383    public enum Context {
384        IS = 0,
385        IS_NOT;
386
387        public string to_string() {
388            switch (this) {
389                case Context.IS:
390                    return "IS";
391
392                case Context.IS_NOT:
393                    return "IS_NOT";
394
395                default:
396                    error("unrecognized media search context enumeration value");
397            }
398        }
399
400        public static Context from_string(string str) {
401            if (str == "IS")
402                return Context.IS;
403
404            else if (str == "IS_NOT")
405                return Context.IS_NOT;
406
407            else
408                error("unrecognized media search context name: %s", str);
409        }
410    }
411
412    public enum MediaType {
413        PHOTO_ALL = 0,
414        PHOTO_RAW,
415        VIDEO;
416
417        public string to_string() {
418            switch (this) {
419                case MediaType.PHOTO_ALL:
420                    return "PHOTO_ALL";
421
422                case MediaType.PHOTO_RAW:
423                    return "PHOTO_RAW";
424
425                case MediaType.VIDEO:
426                    return "VIDEO";
427
428                default:
429                    error("unrecognized media search type enumeration value");
430            }
431        }
432
433        public static MediaType from_string(string str) {
434            if (str == "PHOTO_ALL")
435                return MediaType.PHOTO_ALL;
436
437            else if (str == "PHOTO_RAW")
438                return MediaType.PHOTO_RAW;
439
440            else if (str == "VIDEO")
441                return MediaType.VIDEO;
442
443            else
444                error("unrecognized media search type name: %s", str);
445        }
446    }
447
448    // What to search for.
449    public MediaType media_type { get; private set; }
450
451    // How to match.
452    public Context context { get; private set; }
453
454    public SearchConditionMediaType(SearchCondition.SearchType search_type, Context context, MediaType media_type) {
455        this.search_type = search_type;
456        this.context = context;
457        this.media_type = media_type;
458    }
459
460    // Determines whether the source is included.
461    public override bool predicate(MediaSource source) {
462        // For the given type, check it against the MediaSource type
463        // and the given search context.
464        switch (media_type) {
465            case MediaType.PHOTO_ALL:
466                if (source is Photo)
467                    return context == Context.IS;
468                else
469                    return context == Context.IS_NOT;
470
471            case MediaType.PHOTO_RAW:
472                if (source is Photo && ((Photo) source).get_master_file_format() == PhotoFileFormat.RAW)
473                    return context == Context.IS;
474                else
475                    return context == Context.IS_NOT;
476
477            case MediaType.VIDEO:
478                if (source is VideoSource)
479                    return context == Context.IS;
480                else
481                    return context == Context.IS_NOT;
482
483            default:
484                    error("unrecognized media search type enumeration value");
485        }
486    }
487}
488
489// Condition for flag state matching.
490public class SearchConditionFlagged : SearchCondition {
491    public enum State {
492        FLAGGED = 0,
493        UNFLAGGED;
494
495        public string to_string() {
496            switch (this) {
497                case State.FLAGGED:
498                    return "FLAGGED";
499
500                case State.UNFLAGGED:
501                    return "UNFLAGGED";
502
503                default:
504                    error("unrecognized flagged search state enumeration value");
505            }
506        }
507
508        public static State from_string(string str) {
509            if (str == "FLAGGED")
510                return State.FLAGGED;
511
512            else if (str == "UNFLAGGED")
513                return State.UNFLAGGED;
514
515            else
516                error("unrecognized flagged search state name: %s", str);
517        }
518    }
519
520    // What to match.
521    public State state { get; private set; }
522
523    public SearchConditionFlagged(SearchCondition.SearchType search_type, State state) {
524        this.search_type = search_type;
525        this.state = state;
526    }
527
528    // Determines whether the source is included.
529    public override bool predicate(MediaSource source) {
530        if (state == State.FLAGGED) {
531            return ((Flaggable) source).is_flagged();
532        } else if (state == State.UNFLAGGED) {
533            return !((Flaggable) source).is_flagged();
534        } else {
535            error("unrecognized flagged search state");
536        }
537    }
538}
539
540// Condition for modified state matching.
541public class SearchConditionModified : SearchCondition {
542
543    public enum Context {
544        HAS = 0,
545        HAS_NO;
546
547        public string to_string() {
548            switch (this) {
549                case Context.HAS:
550                    return "HAS";
551
552                case Context.HAS_NO:
553                    return "HAS_NO";
554
555                default:
556                    error("unrecognized modified search context enumeration value");
557            }
558        }
559
560        public static Context from_string(string str) {
561            if (str == "HAS")
562                return Context.HAS;
563
564            else if (str == "HAS_NO")
565                return Context.HAS_NO;
566
567            else
568                error("unrecognized modified search context name: %s", str);
569        }
570    }
571
572    public enum State {
573        MODIFIED = 0,
574        INTERNAL_CHANGES,
575        EXTERNAL_CHANGES;
576
577        public string to_string() {
578            switch (this) {
579                case State.MODIFIED:
580                    return "MODIFIED";
581
582                case State.INTERNAL_CHANGES:
583                    return "INTERNAL_CHANGES";
584
585                 case State.EXTERNAL_CHANGES:
586                    return "EXTERNAL_CHANGES";
587
588                default:
589                    error("unrecognized modified search state enumeration value");
590            }
591        }
592
593        public static State from_string(string str) {
594            if (str == "MODIFIED")
595                return State.MODIFIED;
596
597            else if (str == "INTERNAL_CHANGES")
598                return State.INTERNAL_CHANGES;
599
600            else if (str == "EXTERNAL_CHANGES")
601                return State.EXTERNAL_CHANGES;
602
603            else
604                error("unrecognized modified search state name: %s", str);
605        }
606    }
607
608    // What to match.
609    public State state { get; private set; }
610
611    // How to match.
612    public Context context { get; private set; }
613
614    public SearchConditionModified(SearchCondition.SearchType search_type, Context context, State state) {
615        this.search_type = search_type;
616        this.context = context;
617        this.state = state;
618    }
619
620    // Determines whether the source is included.
621    public override bool predicate(MediaSource source) {
622        // check against state and the given search context.
623        Photo? photo = source as Photo;
624        if (photo == null)
625            return false;
626
627        bool match;
628        if (state == State.MODIFIED)
629            match = photo.has_transformations() || photo.has_editable();
630        else if (state == State.INTERNAL_CHANGES)
631            match = photo.has_transformations();
632        else if (state == State.EXTERNAL_CHANGES)
633            match = photo.has_editable();
634        else
635            error("unrecognized modified search state");
636
637        if (match)
638            return context == Context.HAS;
639        else
640            return context == Context.HAS_NO;
641    }
642}
643
644
645// Condition for rating matching.
646public class SearchConditionRating : SearchCondition {
647    public enum Context {
648        AND_HIGHER = 0,
649        ONLY,
650        AND_LOWER;
651
652        public string to_string() {
653            switch (this) {
654                case Context.AND_HIGHER:
655                    return "AND_HIGHER";
656
657                case Context.ONLY:
658                    return "ONLY";
659
660                case Context.AND_LOWER:
661                    return "AND_LOWER";
662
663                default:
664                    error("unrecognized rating search context enumeration value");
665            }
666        }
667
668        public static Context from_string(string str) {
669            if (str == "AND_HIGHER")
670                return Context.AND_HIGHER;
671
672            else if (str == "ONLY")
673                return Context.ONLY;
674
675            else if (str == "AND_LOWER")
676                return Context.AND_LOWER;
677
678            else
679                error("unrecognized rating search context name: %s", str);
680        }
681    }
682
683    // Rating to check against.
684    public Rating rating { get; private set; }
685
686    // How to match.
687    public Context context { get; private set; }
688
689    public SearchConditionRating(SearchCondition.SearchType search_type, Rating rating, Context context) {
690        this.search_type = search_type;
691        this.rating = rating;
692        this.context = context;
693    }
694
695    // Determines whether the source is included.
696    public override bool predicate(MediaSource source) {
697        Rating source_rating = source.get_rating();
698        if (context == Context.AND_HIGHER)
699            return source_rating >= rating;
700        else if (context == Context.ONLY)
701            return source_rating == rating;
702        else if (context == Context.AND_LOWER)
703            return source_rating <= rating;
704        else
705            error("unknown rating search context");
706    }
707}
708
709
710// Condition for date range.
711public class SearchConditionDate : SearchCondition {
712    public enum Context {
713        EXACT = 0,
714        AFTER,
715        BEFORE,
716        BETWEEN,
717        IS_NOT_SET;
718
719        public string to_string() {
720            switch (this) {
721                case Context.EXACT:
722                    return "EXACT";
723
724                case Context.AFTER:
725                    return "AFTER";
726
727                case Context.BEFORE:
728                    return "BEFORE";
729
730                case Context.BETWEEN:
731                    return "BETWEEN";
732
733                case Context.IS_NOT_SET:
734                    return "IS_NOT_SET";
735
736                default:
737                    error("unrecognized date search context enumeration value");
738            }
739        }
740
741        public static Context from_string(string str) {
742            if (str == "EXACT")
743                return Context.EXACT;
744
745            if (str == "AFTER")
746                return Context.AFTER;
747
748            else if (str == "BEFORE")
749                return Context.BEFORE;
750
751            else if (str == "BETWEEN")
752                return Context.BETWEEN;
753
754            else if (str == "IS_NOT_SET")
755                return Context.IS_NOT_SET;
756
757            else
758                error("unrecognized date search context name: %s", str);
759        }
760    }
761
762    // Date to check against.  Second date only used for between searches.
763    public DateTime date_one { get; private set; }
764    public DateTime date_two { get; private set; }
765
766    // How to match.
767    public Context context { get; private set; }
768
769    public SearchConditionDate(SearchCondition.SearchType search_type, Context context,
770        DateTime date_one, DateTime date_two) {
771        this.search_type = search_type;
772        this.context = context;
773        if (context != Context.BETWEEN || date_two.compare(date_one) >= 1) {
774            this.date_one = date_one;
775            this.date_two = date_two;
776        } else {
777            this.date_one = date_two;
778            this.date_two = date_one;
779        }
780
781    }
782
783    // Determines whether the source is included.
784    public override bool predicate(MediaSource source) {
785        time_t exposure_time = source.get_exposure_time();
786        if (exposure_time == 0)
787            return context == Context.IS_NOT_SET;
788
789        DateTime dt = new DateTime.from_unix_local(exposure_time);
790        switch (context) {
791            case Context.EXACT:
792                DateTime second = date_one.add_days(1);
793                return (dt.compare(date_one) >= 0 && dt.compare(second) < 0);
794
795            case Context.AFTER:
796                return (dt.compare(date_one) >= 0);
797
798            case Context.BEFORE:
799                return (dt.compare(date_one) <= 0);
800
801            case Context.BETWEEN:
802                DateTime second = date_two.add_days(1);
803                return (dt.compare(date_one) >= 0 && dt.compare(second) < 0);
804
805            case Context.IS_NOT_SET:
806                return false; // Already checked above.
807
808            default:
809                error("unrecognized date search context enumeration value");
810        }
811    }
812}
813
814// Contains the logic of a search.
815// A saved search requires a name, an AND/OR (all/any) operator, as well as a list of one or more conditions.
816public class SavedSearch : DataSource {
817    public const string TYPENAME = "saved_search";
818
819    // Row from the database.
820    private SavedSearchRow row;
821
822    public SavedSearch(SavedSearchRow row, int64 object_id = INVALID_OBJECT_ID) {
823        base (object_id);
824
825        this.row = row;
826    }
827
828    public override string get_name() {
829        return row.name;
830    }
831
832    public override string to_string() {
833        return "SavedSearch " + get_name();
834    }
835
836    public override string get_typename() {
837        return TYPENAME;
838    }
839
840    public SavedSearchID get_saved_search_id() {
841        return row.search_id;
842    }
843
844    public override int64 get_instance_id() {
845        return get_saved_search_id().id;
846    }
847
848    public static int compare_names(void *a, void *b) {
849        SavedSearch *asearch = (SavedSearch *) a;
850        SavedSearch *bsearch = (SavedSearch *) b;
851
852        return String.collated_compare(asearch->get_name(), bsearch->get_name());
853    }
854
855    public bool predicate(MediaSource source) {
856        bool ret;
857        if (SearchOperator.ALL == row.operator || SearchOperator.NONE == row.operator)
858            ret = true;
859        else
860            ret = false; // assumes conditions.size() > 0
861
862        foreach (SearchCondition c in row.conditions) {
863            if (SearchOperator.ALL == row.operator)
864                ret &= c.predicate(source);
865            else if (SearchOperator.ANY == row.operator)
866                ret |= c.predicate(source);
867            else if (SearchOperator.NONE == row.operator)
868                ret &= !c.predicate(source);
869        }
870        return ret;
871    }
872
873    public void reconstitute() {
874        try {
875            row.search_id = SavedSearchDBTable.get_instance().create_from_row(row);
876        } catch (DatabaseError err) {
877            AppWindow.database_error(err);
878        }
879
880        SavedSearchTable.get_instance().add_to_map(this);
881        debug("Reconstituted %s", to_string());
882    }
883
884    // Returns false if the name already exists or a bad name.
885    public bool rename(string new_name) {
886        if (is_string_empty(new_name))
887            return false;
888
889        if (SavedSearchTable.get_instance().exists(new_name))
890            return false;
891
892        try {
893            SavedSearchDBTable.get_instance().rename(row.search_id, new_name);
894        } catch (DatabaseError err) {
895            AppWindow.database_error(err);
896            return false;
897        }
898
899        SavedSearchTable.get_instance().remove_from_map(this);
900        row.name = new_name;
901        SavedSearchTable.get_instance().add_to_map(this);
902
903        LibraryWindow.get_app().switch_to_saved_search(this);
904        return true;
905    }
906
907    public Gee.List<SearchCondition> get_conditions() {
908        return row.conditions.read_only_view;
909    }
910
911    public SearchOperator get_operator() {
912        return row.operator;
913    }
914}
915
916// This table contains every saved search.  It's the preferred way to add and destroy a saved
917// search as well, since this table's create/destroy methods are tied to the database.
918public class SavedSearchTable {
919    private static SavedSearchTable? instance = null;
920    private Gee.HashMap<string, SavedSearch> search_map = new Gee.HashMap<string, SavedSearch>();
921
922    public signal void search_added(SavedSearch search);
923    public signal void search_removed(SavedSearch search);
924
925    private SavedSearchTable() {
926        // Load existing searches from DB.
927        try {
928            foreach(SavedSearchRow row in SavedSearchDBTable.get_instance().get_all_rows())
929                add_to_map(new SavedSearch(row));
930        } catch (DatabaseError err) {
931            AppWindow.database_error(err);
932        }
933
934    }
935
936    public static SavedSearchTable get_instance() {
937        if (instance == null)
938            instance = new SavedSearchTable();
939
940        return instance;
941    }
942
943    public Gee.Collection<SavedSearch> get_all() {
944        return search_map.values;
945    }
946
947    // Creates a saved search with the given name, operator, and conditions.  The saved search is
948    // added to the database and to this table.
949    public SavedSearch create(string name, SearchOperator operator,
950        Gee.ArrayList<SearchCondition> conditions) {
951        SavedSearch? search = null;
952        // Create a new SavedSearch in the database.
953        try {
954            search = new SavedSearch(SavedSearchDBTable.get_instance().add(name, operator, conditions));
955        } catch (DatabaseError err) {
956            AppWindow.database_error(err);
957        }
958
959        // Add search to table.
960        add_to_map(search);
961        LibraryWindow.get_app().switch_to_saved_search(search);
962        return search;
963    }
964
965    // Removes a saved search, both from here and from the table.
966    public void remove(SavedSearch search) {
967        try {
968            SavedSearchDBTable.get_instance().remove(search.get_saved_search_id());
969        } catch (DatabaseError err) {
970            AppWindow.database_error(err);
971        }
972
973        remove_from_map(search);
974    }
975
976    public void add_to_map(SavedSearch search) {
977        search_map.set(search.get_name(), search);
978        search_added(search);
979    }
980
981    public void remove_from_map(SavedSearch search) {
982        search_map.unset(search.get_name());
983        search_removed(search);
984    }
985
986    public Gee.Iterable<SavedSearch> get_saved_searches() {
987        return search_map.values;
988    }
989
990    public int get_count() {
991        return search_map.size;
992    }
993
994    public bool exists(string search_name) {
995        return search_map.has_key(search_name);
996    }
997
998    // Generate a unique search name (not thread safe)
999    public string generate_unique_name() {
1000        for (int ctr = 1; ctr < int.MAX; ctr++) {
1001            string name = "%s %d".printf(Resources.DEFAULT_SAVED_SEARCH_NAME, ctr);
1002
1003            if (!exists(name))
1004                return name;
1005        }
1006        return ""; // If all names are used (unlikely!)
1007    }
1008}
1009