1#
2# Gramps - a GTK+/GNOME based genealogy program
3#
4# Copyright (C) 2000-2007  Donald N. Allingham
5# Copyright (C) 2008       Brian G. Matherly
6# Copyright (C) 2008       Stephane Charette
7# Copyright (C) 2010       Jakim Friant
8#
9# This program is free software; you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation; either version 2 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program; if not, write to the Free Software
21# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
22#
23
24"Find unused objects and remove with the user's permission."
25
26#-------------------------------------------------------------------------
27#
28# gtk modules
29#
30#-------------------------------------------------------------------------
31from gi.repository import Gdk
32from gi.repository import Gtk
33from gi.repository import GObject
34
35#-------------------------------------------------------------------------
36#
37# Gramps modules
38#
39#-------------------------------------------------------------------------
40from gramps.gen.db import DbTxn
41from gramps.gen.errors import WindowActiveError
42from gramps.gui.managedwindow import ManagedWindow
43from gramps.gen.datehandler import displayer as _dd
44from gramps.gen.display.place import displayer as _pd
45from gramps.gen.updatecallback import UpdateCallback
46from gramps.gui.plug import tool
47from gramps.gui.glade import Glade
48from gramps.gen.filters import GenericFilterFactory, rules
49from gramps.gen.const import GRAMPS_LOCALE as glocale
50_ = glocale.translation.gettext
51
52#-------------------------------------------------------------------------
53#
54# runTool
55#
56#-------------------------------------------------------------------------
57class RemoveUnused(tool.Tool, ManagedWindow, UpdateCallback):
58    MARK_COL = 0
59    OBJ_ID_COL = 1
60    OBJ_NAME_COL = 2
61    OBJ_TYPE_COL = 3
62    OBJ_HANDLE_COL = 4
63
64    BUSY_CURSOR = Gdk.Cursor.new_for_display(Gdk.Display.get_default(),
65                                             Gdk.CursorType.WATCH)
66
67    def __init__(self, dbstate, user, options_class, name, callback=None):
68        uistate = user.uistate
69        self.title = _('Unused Objects')
70
71        tool.Tool.__init__(self, dbstate, options_class, name)
72
73        if self.db.readonly:
74            return
75
76        ManagedWindow.__init__(self, uistate, [], self.__class__)
77        UpdateCallback.__init__(self, self.uistate.pulse_progressbar)
78
79        self.dbstate = dbstate
80        self.uistate = uistate
81
82        self.tables = {
83            'events': {'get_func': self.db.get_event_from_handle,
84                       'remove': self.db.remove_event,
85                       'get_text': self.get_event_text,
86                       'editor': 'EditEvent',
87                       'icon': 'gramps-event',
88                       'name_ix': 4},
89            'sources': {'get_func': self.db.get_source_from_handle,
90                        'remove': self.db.remove_source,
91                        'get_text': None,
92                        'editor': 'EditSource',
93                        'icon': 'gramps-source',
94                        'name_ix': 2},
95            'citations': {'get_func': self.db.get_citation_from_handle,
96                          'remove': self.db.remove_citation,
97                          'get_text': None,
98                          'editor': 'EditCitation',
99                          'icon': 'gramps-citation',
100                          'name_ix': 3},
101            'places': {'get_func': self.db.get_place_from_handle,
102                       'remove': self.db.remove_place,
103                       'get_text': self.get_place_text,
104                       'editor': 'EditPlace',
105                       'icon': 'gramps-place',
106                       'name_ix': 2},
107            'media': {'get_func': self.db.get_media_from_handle,
108                      'remove': self.db.remove_media,
109                      'get_text': None,
110                      'editor': 'EditMedia',
111                      'icon': 'gramps-media',
112                      'name_ix': 4},
113            'repos': {'get_func': self.db.get_repository_from_handle,
114                      'remove': self.db.remove_repository,
115                      'get_text': None,
116                      'editor': 'EditRepository',
117                      'icon': 'gramps-repository',
118                      'name_ix': 3},
119            'notes': {'get_func': self.db.get_note_from_handle,
120                      'remove': self.db.remove_note,
121                      'get_text': self.get_note_text,
122                      'editor': 'EditNote',
123                      'icon': 'gramps-notes',
124                      'name_ix': 2},
125            }
126
127        self.init_gui()
128
129    def init_gui(self):
130        self.top = Glade()
131        window = self.top.toplevel
132        self.set_window(window, self.top.get_object('title'), self.title)
133        self.setup_configs('interface.removeunused', 400, 520)
134
135        self.events_box = self.top.get_object('events_box')
136        self.sources_box = self.top.get_object('sources_box')
137        self.citations_box = self.top.get_object('citations_box')
138        self.places_box = self.top.get_object('places_box')
139        self.media_box = self.top.get_object('media_box')
140        self.repos_box = self.top.get_object('repos_box')
141        self.notes_box = self.top.get_object('notes_box')
142        self.find_button = self.top.get_object('find_button')
143        self.remove_button = self.top.get_object('remove_button')
144
145        self.events_box.set_active(self.options.handler.options_dict['events'])
146        self.sources_box.set_active(
147            self.options.handler.options_dict['sources'])
148        self.citations_box.set_active(
149            self.options.handler.options_dict['citations'])
150        self.places_box.set_active(
151            self.options.handler.options_dict['places'])
152        self.media_box.set_active(self.options.handler.options_dict['media'])
153        self.repos_box.set_active(self.options.handler.options_dict['repos'])
154        self.notes_box.set_active(self.options.handler.options_dict['notes'])
155
156        self.warn_tree = self.top.get_object('warn_tree')
157        self.warn_tree.connect('button_press_event', self.double_click)
158
159        self.selection = self.warn_tree.get_selection()
160
161        self.mark_button = self.top.get_object('mark_button')
162        self.mark_button.connect('clicked', self.mark_clicked)
163
164        self.unmark_button = self.top.get_object('unmark_button')
165        self.unmark_button.connect('clicked', self.unmark_clicked)
166
167        self.invert_button = self.top.get_object('invert_button')
168        self.invert_button.connect('clicked', self.invert_clicked)
169
170        self.real_model = Gtk.ListStore(GObject.TYPE_BOOLEAN,
171                                        GObject.TYPE_STRING,
172                                        GObject.TYPE_STRING,
173                                        GObject.TYPE_STRING,
174                                        GObject.TYPE_STRING)
175        # a short term Gtk introspection means we need to try both ways:
176        if hasattr(self.real_model, "sort_new_with_model"):
177            self.sort_model = self.real_model.sort_new_with_model()
178        else:
179            self.sort_model = Gtk.TreeModelSort.new_with_model(self.real_model)
180        self.warn_tree.set_model(self.sort_model)
181
182        self.renderer = Gtk.CellRendererText()
183        self.img_renderer = Gtk.CellRendererPixbuf()
184        self.bool_renderer = Gtk.CellRendererToggle()
185        self.bool_renderer.connect('toggled', self.selection_toggled)
186
187        # Add mark column
188        mark_column = Gtk.TreeViewColumn(_('Mark'), self.bool_renderer,
189                                         active=RemoveUnused.MARK_COL)
190        mark_column.set_sort_column_id(RemoveUnused.MARK_COL)
191        self.warn_tree.append_column(mark_column)
192
193        # Add image column
194        img_column = Gtk.TreeViewColumn(None, self.img_renderer)
195        img_column.set_cell_data_func(self.img_renderer, self.get_image)
196        self.warn_tree.append_column(img_column)
197
198        # Add column with object gramps_id
199        id_column = Gtk.TreeViewColumn(_('ID'), self.renderer,
200                                       text=RemoveUnused.OBJ_ID_COL)
201        id_column.set_sort_column_id(RemoveUnused.OBJ_ID_COL)
202        self.warn_tree.append_column(id_column)
203
204        # Add column with object name
205        name_column = Gtk.TreeViewColumn(_('Name'), self.renderer,
206                                         text=RemoveUnused.OBJ_NAME_COL)
207        name_column.set_sort_column_id(RemoveUnused.OBJ_NAME_COL)
208        self.warn_tree.append_column(name_column)
209
210        self.top.connect_signals({
211            "destroy_passed_object"   : self.close,
212            "on_remove_button_clicked": self.do_remove,
213            "on_find_button_clicked"  : self.find,
214            "on_delete_event"         : self.close,
215            })
216
217        self.dc_label = self.top.get_object('dc_label')
218
219        self.sensitive_list = [self.warn_tree, self.mark_button,
220                               self.unmark_button, self.invert_button,
221                               self.dc_label, self.remove_button]
222
223        for item in self.sensitive_list:
224            item.set_sensitive(False)
225
226        self.show()
227
228    def build_menu_names(self, obj):
229        return (self.title, None)
230
231    def find(self, obj):
232        self.options.handler.options_dict.update(
233            events=self.events_box.get_active(),
234            sources=self.sources_box.get_active(),
235            citations=self.citations_box.get_active(),
236            places=self.places_box.get_active(),
237            media=self.media_box.get_active(),
238            repos=self.repos_box.get_active(),
239            notes=self.notes_box.get_active(),
240            )
241
242        for item in self.sensitive_list:
243            item.set_sensitive(True)
244
245        self.uistate.set_busy_cursor(True)
246        self.uistate.progress.show()
247        self.window.get_window().set_cursor(self.BUSY_CURSOR)
248
249        self.real_model.clear()
250        self.collect_unused()
251
252        self.uistate.progress.hide()
253        self.uistate.set_busy_cursor(False)
254        self.window.get_window().set_cursor(None)
255        self.reset()
256
257        # Save options
258        self.options.handler.save_options()
259
260    def collect_unused(self):
261        # Run through all requested tables and check all objects
262        # for being referenced some place. If not, add_results on them.
263
264        db = self.db
265        tables = (
266            ('events', db.get_event_cursor, db.get_number_of_events),
267            ('sources', db.get_source_cursor, db.get_number_of_sources),
268            ('citations', db.get_citation_cursor, db.get_number_of_citations),
269            ('places', db.get_place_cursor, db.get_number_of_places),
270            ('media', db.get_media_cursor, db.get_number_of_media),
271            ('repos', db.get_repository_cursor, db.get_number_of_repositories),
272            ('notes', db.get_note_cursor, db.get_number_of_notes),
273            )
274
275        # bug 7619 : don't select notes from to do list.
276        # notes associated to the todo list doesn't have references.
277        # get the todo list (from get_note_list method of the todo gramplet )
278        all_notes = self.dbstate.db.get_note_handles()
279        FilterClass = GenericFilterFactory('Note')
280        filter1 = FilterClass()
281        filter1.add_rule(rules.note.HasType(["To Do"]))
282        todo_list = filter1.apply(self.dbstate.db, all_notes)
283        filter2 = FilterClass()
284        filter2.add_rule(rules.note.HasType(["Link"]))
285        link_list = filter2.apply(self.dbstate.db, all_notes)
286
287        for (the_type, cursor_func, total_func) in tables:
288            if not self.options.handler.options_dict[the_type]:
289                # This table was not requested. Skip it.
290                continue
291
292            with cursor_func() as cursor:
293                self.set_total(total_func())
294                fbh = db.find_backlink_handles
295                for handle, data in cursor:
296                    if not any(h for h in fbh(handle)):
297                        if handle not in todo_list and handle not in link_list:
298                            self.add_results((the_type, handle, data))
299                    self.update()
300            self.reset()
301
302    def do_remove(self, obj):
303        with DbTxn(_("Remove unused objects"), self.db, batch=False) as trans:
304            self.db.disable_signals()
305
306            for row_num in range(len(self.real_model)-1, -1, -1):
307                path = (row_num,)
308                row = self.real_model[path]
309                if not row[RemoveUnused.MARK_COL]:
310                    continue
311
312                the_type = row[RemoveUnused.OBJ_TYPE_COL]
313                handle = row[RemoveUnused.OBJ_HANDLE_COL]
314                remove_func = self.tables[the_type]['remove']
315                remove_func(handle, trans)
316
317                self.real_model.remove(row.iter)
318
319        self.db.enable_signals()
320        self.db.request_rebuild()
321
322    def selection_toggled(self, cell, path_string):
323        sort_path = tuple(map(int, path_string.split(':')))
324        real_path = self.sort_model.convert_path_to_child_path(Gtk.TreePath(sort_path))
325        row = self.real_model[real_path]
326        row[RemoveUnused.MARK_COL] = not row[RemoveUnused.MARK_COL]
327        self.real_model.row_changed(real_path, row.iter)
328
329    def mark_clicked(self, mark_button):
330        for row_num in range(len(self.real_model)):
331            path = (row_num,)
332            row = self.real_model[path]
333            row[RemoveUnused.MARK_COL] = True
334
335    def unmark_clicked(self, unmark_button):
336        for row_num in range(len(self.real_model)):
337            path = (row_num,)
338            row = self.real_model[path]
339            row[RemoveUnused.MARK_COL] = False
340
341    def invert_clicked(self, invert_button):
342        for row_num in range(len(self.real_model)):
343            path = (row_num,)
344            row = self.real_model[path]
345            row[RemoveUnused.MARK_COL] = not row[RemoveUnused.MARK_COL]
346
347    def double_click(self, obj, event):
348        if (event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS
349                and event.button == 1):
350            (model, node) = self.selection.get_selected()
351            if not node:
352                return
353            sort_path = self.sort_model.get_path(node)
354            real_path = self.sort_model.convert_path_to_child_path(sort_path)
355            row = self.real_model[real_path]
356            the_type = row[RemoveUnused.OBJ_TYPE_COL]
357            handle = row[RemoveUnused.OBJ_HANDLE_COL]
358            self.call_editor(the_type, handle)
359
360    def call_editor(self, the_type, handle):
361        try:
362            obj = self.tables[the_type]['get_func'](handle)
363            editor_str = 'from gramps.gui.editors import %s as editor' % (
364                self.tables[the_type]['editor'])
365            exec(editor_str, globals())
366            editor(self.dbstate, self.uistate, [], obj)
367        except WindowActiveError:
368            pass
369
370    def get_image(self, column, cell, model, iter, user_data=None):
371        the_type = model.get_value(iter, RemoveUnused.OBJ_TYPE_COL)
372        the_icon = self.tables[the_type]['icon']
373        cell.set_property('icon-name', the_icon)
374
375    def add_results(self, results):
376        (the_type, handle, data) = results
377        gramps_id = data[1]
378
379        # if we have a function that will return to us some type
380        # of text summary, then we should use it; otherwise we'll
381        # use the generic field index provided in the tables above
382        if self.tables[the_type]['get_text']:
383            text = self.tables[the_type]['get_text'](the_type, handle, data)
384        else:
385            # grab the text field index we know about, and hope
386            # it represents something useful to the user
387            name_ix = self.tables[the_type]['name_ix']
388            text = data[name_ix]
389
390        # insert a new row into the table
391        self.real_model.append(row=[False, gramps_id, text, the_type, handle])
392
393    def get_event_text(self, the_type, handle, data):
394        """
395        Come up with a short line of text that we can use as
396        a summary to represent this event.
397        """
398
399        # get the event:
400        event = self.tables[the_type]['get_func'](handle)
401
402        # first check to see if the event has a descriptive name
403        text = event.get_description()  # (this is rarely set for events)
404
405        # if we don't have a description...
406        if text == '':
407            # ... then we merge together several fields
408
409            # get the event type (marriage, birth, death, etc.)
410            text = str(event.get_type())
411
412            # see if there is a date
413            date = _dd.display(event.get_date_object())
414            if date != '':
415                text += '; %s' % date
416
417            # see if there is a place
418            if event.get_place_handle():
419                text += '; %s' % _pd.display_event(self.db, event)
420
421        return text
422
423    def get_note_text(self, the_type, handle, data):
424        """
425        We need just the first few words of a note as a summary.
426        """
427        # get the note object
428        note = self.tables[the_type]['get_func'](handle)
429
430        # get the note text; this ignores (discards) formatting
431        text = note.get()
432
433        # convert whitespace to a single space
434        text = " ".join(text.split())
435
436        # if the note is too long, truncate it
437        if len(text) > 80:
438            text = text[:80] + "..."
439
440        return text
441
442    def get_place_text(self, the_type, handle, data):
443        """
444        We need just the place name.
445        """
446        # get the place object
447        place = self.tables[the_type]['get_func'](handle)
448
449        # get the name
450        text = place.get_name().get_value()
451
452        return text
453#------------------------------------------------------------------------
454#
455#
456#
457#------------------------------------------------------------------------
458class CheckOptions(tool.ToolOptions):
459    """
460    Defines options and provides handling interface.
461    """
462
463    def __init__(self, name, person_id=None):
464        tool.ToolOptions.__init__(self, name, person_id)
465
466        # Options specific for this report
467        self.options_dict = {
468            'events': 1,
469            'sources': 1,
470            'citations': 1,
471            'places': 1,
472            'media': 1,
473            'repos': 1,
474            'notes': 1,
475        }
476        self.options_help = {
477            'events': ("=0/1", "Whether to use check for unused events",
478                       ["Do not check events", "Check events"],
479                       True),
480            'sources': ("=0/1", "Whether to use check for unused sources",
481                        ["Do not check sources", "Check sources"],
482                        True),
483            'citations': ("=0/1", "Whether to use check for unused citations",
484                          ["Do not check citations", "Check citations"],
485                          True),
486            'places': ("=0/1", "Whether to use check for unused places",
487                       ["Do not check places", "Check places"],
488                       True),
489            'media': ("=0/1", "Whether to use check for unused media",
490                      ["Do not check media", "Check media"],
491                      True),
492            'repos': ("=0/1", "Whether to use check for unused repositories",
493                      ["Do not check repositories", "Check repositories"],
494                      True),
495            'notes': ("=0/1", "Whether to use check for unused notes",
496                      ["Do not check notes", "Check notes"],
497                      True),
498            }
499