Convert plugin to Event-based view and improve filter dialog

- Convert plugin from Family-based to Event-based view
  * Change category from Families to Events
  * Update navigation_type to 'Event'
  * Replace FamilyBookmarks with EventBookmarks
  * Rewrite collect_events() to show all events in database
  * Update goto_handle() to work with event handles

- Update filter dialog to show families with members
  * Restructure person filter page with expandable families
  * Each family shows father, mother, and children
  * Add helper method to generate family display names

- Restore person connection lines
  * Re-enable visual connections between events of selected person
  * Clicking an event selects the person and shows connections

- Add uninstall script
  * Remove plugin files and backup directories
  * Clean up any plugin files in subdirectories
This commit is contained in:
Daniel Viegas 2025-11-29 21:42:10 +01:00
parent b32be12aea
commit ce75cd55bb
3 changed files with 466 additions and 113 deletions

View File

@ -34,14 +34,14 @@ register(
VIEW,
id="mytimelineview",
name=_("MyTimeline"),
description=_("A vertical timeline view showing family events including birth, death, and marriage"),
description=_("A vertical timeline view showing all events in the database"),
version="1.0",
gramps_target_version=MODULE_VERSION,
status=STABLE,
fname="MyTimeline.py",
authors=["Daniel Viegas"],
authors_email=["dlviegas@gmail.com"],
category=("Families", _("Families")),
category=("Events", _("Events")),
viewclass="MyTimelineView",
)

View File

@ -19,7 +19,7 @@
#
"""
MyTimeline View - A vertical timeline showing family events
MyTimeline View - A vertical timeline showing all events in the database
"""
# -------------------------------------------------------------------------
@ -29,6 +29,8 @@ MyTimeline View - A vertical timeline showing family events
# -------------------------------------------------------------------------
import cairo
import logging
logger = logging.getLogger("plugin.mytimeline")
import math
from dataclasses import dataclass
from typing import Optional, List, Tuple, Any, Set, Dict
@ -54,7 +56,7 @@ from gramps.gen.utils.db import (
from gramps.gen.datehandler import get_date
from gramps.gen.display.name import displayer as name_displayer
from gramps.gui.views.navigationview import NavigationView
from gramps.gui.views.bookmarks import FamilyBookmarks
from gramps.gui.views.bookmarks import EventBookmarks
_ = glocale.translation.sgettext
@ -340,8 +342,9 @@ class TimelineEvent:
# -------------------------------------------------------------------------
class MyTimelineView(NavigationView):
"""
View for displaying a vertical timeline of family events.
Shows all events for family members with modern design and interactivity.
View for displaying a vertical timeline of all events in the database.
Shows all events with modern design and interactivity, allowing selection
and filtering of events by type, category, person, and date range.
"""
def __init__(self, pdata, dbstate, uistate, nav_group=0):
@ -355,14 +358,14 @@ class MyTimelineView(NavigationView):
nav_group: Navigation group identifier.
"""
NavigationView.__init__(
self, _("MyTimeline"), pdata, dbstate, uistate, FamilyBookmarks, nav_group
self, _("MyTimeline"), pdata, dbstate, uistate, EventBookmarks, nav_group
)
self.dbstate = dbstate
self.uistate = uistate
# Current family handle
self.active_family_handle = None
# Current event handle (for selection/highlighting)
self.active_event_handle = None
self.events: List[TimelineEvent] = [] # List of TimelineEvent objects
# UI components
@ -419,20 +422,26 @@ class MyTimelineView(NavigationView):
Return the navigation type for this view.
Returns:
str: The navigation type, always "Family" for this view.
str: The navigation type, always "Event" for this view.
"""
return "Family"
return "Event"
def change_page(self) -> None:
"""
Called when the page changes.
Updates the view to show the active family's timeline.
Updates the view to show all events and highlight the active event if selected.
"""
NavigationView.change_page(self)
active_handle = self.get_active()
if active_handle:
self.goto_handle(active_handle)
else:
# If no event is selected, ensure timeline is displayed
if not self.events and self.dbstate.is_open():
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
def family_updated(self, handle_list: List[str]) -> None:
"""
@ -441,10 +450,10 @@ class MyTimelineView(NavigationView):
Args:
handle_list: List of family handles that were updated.
"""
if self.active_family_handle in handle_list:
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
# Refresh timeline since family updates may affect event display
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
def person_updated(self, handle_list: List[str]) -> None:
"""
@ -453,25 +462,10 @@ class MyTimelineView(NavigationView):
Args:
handle_list: List of person handles that were updated.
"""
# Check if any updated person is related to current family
if self.active_family_handle:
try:
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
if family:
father_handle = family.get_father_handle()
mother_handle = family.get_mother_handle()
child_handles = [ref.ref for ref in family.get_child_ref_list()]
if (
(father_handle and father_handle in handle_list)
or (mother_handle and mother_handle in handle_list)
or any(h in handle_list for h in child_handles)
):
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
except (AttributeError, KeyError) as e:
# Skip if family handle is invalid
logger.warning(f"Error accessing family in person_updated: {e}", exc_info=True)
# Refresh timeline since person updates may affect event display
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
def event_updated(self, handle_list: List[str]) -> None:
"""
@ -480,11 +474,14 @@ class MyTimelineView(NavigationView):
Args:
handle_list: List of event handles that were updated.
"""
# Re-collect events if we have an active family
if self.active_family_handle:
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
# Re-collect events when events are updated
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
# If the active event was updated, ensure it's still highlighted
if self.active_event_handle in handle_list:
self._scroll_to_event(self.active_event_handle)
def change_db(self, db) -> None:
"""
@ -493,7 +490,7 @@ class MyTimelineView(NavigationView):
Args:
db: The new database object.
"""
self.active_family_handle = None
self.active_event_handle = None
self.all_events = []
self.events = []
@ -745,7 +742,7 @@ class MyTimelineView(NavigationView):
def _build_person_filter_page(self) -> Gtk.Widget:
"""
Build the person filter page with checkboxes for each family member.
Build the person filter page with expandable families showing their members.
Returns:
Gtk.Widget: The person filter page.
@ -763,8 +760,10 @@ class MyTimelineView(NavigationView):
person_checkboxes = {}
self._filter_widgets['person_checkboxes'] = person_checkboxes
self._filter_widgets['person_container'] = box
# Store expanders by family handle
self._filter_widgets['family_expanders'] = {}
info_label = Gtk.Label(label=_("Select family members to include in the timeline."))
info_label = Gtk.Label(label=_("Select families and their members to include in the timeline."))
info_label.set_line_wrap(True)
box.pack_start(info_label, False, False, 0)
@ -929,50 +928,97 @@ class MyTimelineView(NavigationView):
else:
checkbox.set_active(category in self.category_filter)
# Update person checkboxes
# Update person checkboxes with families
if 'person_checkboxes' in self._filter_widgets and 'person_container' in self._filter_widgets:
# Clear existing person checkboxes
# Clear existing person checkboxes and family expanders
container = self._filter_widgets['person_container']
# Remove all existing expanders
if 'family_expanders' in self._filter_widgets:
for expander in list(self._filter_widgets['family_expanders'].values()):
container.remove(expander)
expander.destroy()
self._filter_widgets['family_expanders'].clear()
# Remove all existing checkboxes
for checkbox in list(self._filter_widgets['person_checkboxes'].values()):
container.remove(checkbox)
checkbox.destroy()
self._filter_widgets['person_checkboxes'].clear()
# Add current family members
if self.active_family_handle:
# Collect all families and create expanders
if self.dbstate.is_open():
try:
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
if family:
# Father
# Initialize family_expanders if not exists
if 'family_expanders' not in self._filter_widgets:
self._filter_widgets['family_expanders'] = {}
# Iterate through all families
for family in self.dbstate.db.iter_families():
family_handle = family.get_handle()
# Get family display name
family_name = self._get_family_display_name(family)
# Create expander for this family
expander = Gtk.Expander(label=family_name)
expander.set_expanded(False)
# Create container for family members
members_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
members_box.set_margin_start(20)
members_box.set_margin_top(5)
members_box.set_margin_bottom(5)
# Add father checkbox
father_handle = family.get_father_handle()
if father_handle:
father = self.dbstate.db.get_person_from_handle(father_handle)
if father:
checkbox = Gtk.CheckButton(label=name_displayer.display(father))
checkbox.set_active(True if not self.person_filter else father_handle in self.person_filter)
self._filter_widgets['person_checkboxes'][father_handle] = checkbox
container.pack_start(checkbox, False, False, 0)
try:
father = self.dbstate.db.get_person_from_handle(father_handle)
if father:
father_label = f" {_('Father')}: {name_displayer.display(father)}"
checkbox = Gtk.CheckButton(label=father_label)
checkbox.set_active(True if not self.person_filter else father_handle in self.person_filter)
self._filter_widgets['person_checkboxes'][father_handle] = checkbox
members_box.pack_start(checkbox, False, False, 0)
except (AttributeError, KeyError):
pass
# Mother
# Add mother checkbox
mother_handle = family.get_mother_handle()
if mother_handle:
mother = self.dbstate.db.get_person_from_handle(mother_handle)
if mother:
checkbox = Gtk.CheckButton(label=name_displayer.display(mother))
checkbox.set_active(True if not self.person_filter else mother_handle in self.person_filter)
self._filter_widgets['person_checkboxes'][mother_handle] = checkbox
container.pack_start(checkbox, False, False, 0)
try:
mother = self.dbstate.db.get_person_from_handle(mother_handle)
if mother:
mother_label = f" {_('Mother')}: {name_displayer.display(mother)}"
checkbox = Gtk.CheckButton(label=mother_label)
checkbox.set_active(True if not self.person_filter else mother_handle in self.person_filter)
self._filter_widgets['person_checkboxes'][mother_handle] = checkbox
members_box.pack_start(checkbox, False, False, 0)
except (AttributeError, KeyError):
pass
# Children
# Add children checkboxes
for child_ref in family.get_child_ref_list():
child = self.dbstate.db.get_person_from_handle(child_ref.ref)
if child:
checkbox = Gtk.CheckButton(label=name_displayer.display(child))
checkbox.set_active(True if not self.person_filter else child_ref.ref in self.person_filter)
self._filter_widgets['person_checkboxes'][child_ref.ref] = checkbox
container.pack_start(checkbox, False, False, 0)
child_handle = child_ref.ref
try:
child = self.dbstate.db.get_person_from_handle(child_handle)
if child:
child_label = f" {_('Child')}: {name_displayer.display(child)}"
checkbox = Gtk.CheckButton(label=child_label)
checkbox.set_active(True if not self.person_filter else child_handle in self.person_filter)
self._filter_widgets['person_checkboxes'][child_handle] = checkbox
members_box.pack_start(checkbox, False, False, 0)
except (AttributeError, KeyError):
pass
container.show_all()
# Only add expander if there are members to show
if len(members_box.get_children()) > 0:
expander.add(members_box)
self._filter_widgets['family_expanders'][family_handle] = expander
container.pack_start(expander, False, False, 0)
container.show_all()
except (AttributeError, KeyError) as e:
logger.warning(f"Error updating person filter: {e}", exc_info=True)
@ -1357,24 +1403,71 @@ class MyTimelineView(NavigationView):
"""
Rebuilds the current display. Called when the view becomes visible.
"""
# Collect all events if not already done
if not self.all_events and self.dbstate.is_open():
self.collect_events()
# Highlight active event if one is selected
active_handle = self.get_active()
if active_handle:
self.goto_handle(active_handle)
elif self.drawing_area:
self.drawing_area.queue_draw()
def goto_handle(self, handle: str) -> None:
"""
Called when the active family changes.
Called when the active event changes.
Args:
handle: The handle of the family to display.
handle: The handle of the event to highlight/select.
"""
if handle == self.active_family_handle:
if handle == self.active_event_handle:
return
self.active_family_handle = handle
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
self.active_event_handle = handle
# Ensure events are collected
if not self.all_events:
self.collect_events()
else:
# Just refresh display to highlight the selected event
if self.drawing_area:
self.drawing_area.queue_draw()
# Scroll to the selected event if possible
self._scroll_to_event(handle)
def _scroll_to_event(self, event_handle: str) -> None:
"""
Scroll the timeline to show the selected event.
Args:
event_handle: The handle of the event to scroll to.
"""
if not self.scrolledwindow or not event_handle:
return
# Find the event in our event list
event_index = None
for i, timeline_event in enumerate(self.events):
if timeline_event.event.get_handle() == event_handle:
event_index = i
break
if event_index is None:
return
# Calculate the y position of the event
if event_index < len(self.events):
event_y = TIMELINE_MARGIN_TOP + (event_index * EVENT_SPACING)
# Get the adjustment and scroll to the event
vadj = self.scrolledwindow.get_vadjustment()
if vadj:
# Center the event in the viewport
viewport_height = self.scrolledwindow.get_allocation().height
scroll_to = max(0, event_y - (viewport_height / 2))
vadj.set_value(scroll_to)
def _collect_person_event_refs(self, person) -> List:
"""
@ -1492,36 +1585,138 @@ class MyTimelineView(NavigationView):
if self.drawing_area:
self.drawing_area.set_size_request(800, self.timeline_height)
def _process_event(self, event, person_obj: Optional[Any] = None) -> Optional[TimelineEvent]:
"""
Process a single event and create a TimelineEvent.
Args:
event: The event object to process.
person_obj: Optional person object associated with this event.
Returns:
Optional[TimelineEvent]: The created TimelineEvent, or None if invalid.
"""
try:
if event and event.get_date_object():
date_obj = event.get_date_object()
event_type = event.get_type()
return TimelineEvent(
date_sort=date_obj.get_sort_value(),
date_obj=date_obj,
event=event,
person=person_obj,
event_type=event_type,
y_pos=0.0
)
except (AttributeError, KeyError, ValueError) as e:
logger.debug(f"Skipping invalid event: {e}")
return None
def _find_person_for_event(self, event) -> Optional[Any]:
"""
Find a primary person associated with an event by searching through people.
Args:
event: The event object to find a person for.
Returns:
Optional person object, or None if not found or if event is family-only.
"""
if not event:
return None
event_handle = event.get_handle()
# Search through people to find who references this event
# For performance, we'll search through a limited set or use event references
try:
# Try to find a person who has this event as a primary event
for person_handle in self.dbstate.db.get_person_handles():
try:
person = self.dbstate.db.get_person_from_handle(person_handle)
if person:
# Check primary events first
for event_ref in person.get_primary_event_ref_list():
if event_ref.ref == event_handle:
return person
# Check all events
for event_ref in person.get_event_ref_list():
if event_ref.ref == event_handle:
return person
except (AttributeError, KeyError):
continue
except (AttributeError, KeyError):
pass
return None
def _get_family_display_name(self, family) -> str:
"""
Get a display name for a family showing parent names.
Args:
family: The family object.
Returns:
str: Display name for the family.
"""
if not family:
return _("Unknown Family")
father_handle = family.get_father_handle()
mother_handle = family.get_mother_handle()
father_name = None
mother_name = None
if father_handle:
try:
father = self.dbstate.db.get_person_from_handle(father_handle)
if father:
father_name = name_displayer.display(father)
except (AttributeError, KeyError):
pass
if mother_handle:
try:
mother = self.dbstate.db.get_person_from_handle(mother_handle)
if mother:
mother_name = name_displayer.display(mother)
except (AttributeError, KeyError):
pass
if father_name and mother_name:
return f"{father_name} & {mother_name}"
elif father_name:
return f"{father_name} & {_('Unknown')}"
elif mother_name:
return f"{_('Unknown')} & {mother_name}"
else:
return _("Unknown Family")
def collect_events(self) -> None:
"""Collect all events for the active family."""
"""Collect all events from the database."""
self.all_events = []
self.events = []
if not self.active_family_handle:
if not self.dbstate.is_open():
return
try:
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
# Iterate through all events in the database
for event in self.dbstate.db.iter_events():
# Try to find an associated person for this event
person_obj = self._find_person_for_event(event)
# Process the event
timeline_event = self._process_event(event, person_obj)
if timeline_event:
self.all_events.append(timeline_event)
except (AttributeError, KeyError) as e:
logger.warning(f"Error accessing family: {e}", exc_info=True)
logger.warning(f"Error collecting events: {e}", exc_info=True)
return
if not family:
return
# Get family events (marriage, divorce, etc.)
self._collect_family_events(family)
# Get father's events
self._collect_family_member_events(family.get_father_handle(), "father")
# Get mother's events
self._collect_family_member_events(family.get_mother_handle(), "mother")
# Get children's events
for child_ref in family.get_child_ref_list():
self._collect_family_member_events(child_ref.ref, "child")
# Sort events by date
self.all_events.sort(key=lambda x: x.date_sort)
@ -1997,19 +2192,23 @@ class MyTimelineView(NavigationView):
clicked_index = self.find_event_at_position(event.x, event.y)
if clicked_index is not None:
clicked_event_data = self.events[clicked_index]
event_handle = clicked_event_data.event.get_handle()
# Clicking anywhere on the event line selects the person (like selecting the event)
if clicked_event_data.person:
person_handle = clicked_event_data.person.get_handle()
if self.selected_person_handle == person_handle:
# Deselect if clicking same person
self.selected_person_handle = None
else:
# Select this person
self.selected_person_handle = person_handle
else:
# No person for this event, deselect
# Select/highlight this event
if self.active_event_handle == event_handle:
# Deselect if clicking same event
self.active_event_handle = None
self.selected_person_handle = None
else:
# Select this event
self.active_event_handle = event_handle
# Set selected person based on this event's person
if clicked_event_data.person:
self.selected_person_handle = clicked_event_data.person.get_handle()
else:
self.selected_person_handle = None
# Navigate to this event in the view
self.uistate.set_active(event_handle, "Event")
self.drawing_area.queue_draw()
return False
@ -2416,9 +2615,9 @@ class MyTimelineView(NavigationView):
# Check if this event is hovered
is_hovered = (i == self.hovered_event_index)
# Check if this event belongs to selected person
is_selected = (self.selected_person_handle is not None and
self._person_matches_handle(event_data.person, self.selected_person_handle))
# Check if this event is the active/selected event
is_selected = (self.active_event_handle is not None and
event_data.event.get_handle() == self.active_event_handle)
# Draw event marker with modern styling
self.draw_event_marker(context, timeline_x, event_data.y_pos,
@ -2475,7 +2674,7 @@ class MyTimelineView(NavigationView):
# Draw events
self._draw_events(context, events_with_y_pos, timeline_x)
# Draw visual connections for selected person
# Draw visual connections for selected person (from selected event)
if self.selected_person_handle is not None:
self.draw_person_connections(context, events_with_y_pos, timeline_x,
timeline_y_start, timeline_y_end)

154
uninstall_from_snap.sh Executable file
View File

@ -0,0 +1,154 @@
#!/bin/bash
#
# Script to uninstall MyTimeline plugin from snap-installed Gramps
#
# This script removes the MyTimeline plugin files from the Gramps snap plugin directory
#
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Plugin files to remove
PLUGIN_FILES=("MyTimeline.gpr.py" "MyTimeline.py")
# Target directory for snap-installed Gramps
PLUGIN_DEST_DIR="$HOME/snap/gramps/current/.local/share/gramps/gramps60/plugins"
# Alternative locations to try
ALTERNATIVE_DIRS=(
"$HOME/snap/gramps/current/.local/share/gramps/gramps60/plugins"
"$HOME/snap/gramps/current/.gramps/gramps60/plugins"
"$HOME/snap/gramps/current/.gramps/plugins"
"$HOME/snap/gramps/common/.local/share/gramps/gramps60/plugins"
"$HOME/.local/share/gramps/gramps60/plugins"
"$HOME/.gramps/gramps60/plugins"
)
echo "=========================================="
echo "MyTimeline Plugin Uninstaller for Snap Gramps"
echo "=========================================="
echo ""
# Function to find the correct plugin directory
find_plugin_dir() {
# Try the primary location first
if [ -d "$PLUGIN_DEST_DIR" ]; then
echo "$PLUGIN_DEST_DIR"
return 0
fi
# Try alternative locations
for dir in "${ALTERNATIVE_DIRS[@]}"; do
if [ -d "$dir" ]; then
echo "$dir"
return 0
fi
done
return 1
}
# Find the target directory
echo "Locating Gramps plugin directory..."
TARGET_DIR=$(find_plugin_dir)
if [ -z "$TARGET_DIR" ]; then
echo -e "${YELLOW}Warning: Plugin directory not found.${NC}"
echo "The plugin may already be uninstalled or Gramps may not be installed via snap."
exit 0
fi
echo -e "${GREEN}${NC} Target directory: $TARGET_DIR"
echo ""
# Check if plugin files exist
FILES_FOUND=0
FILES_REMOVED=0
for file in "${PLUGIN_FILES[@]}"; do
if [ -f "$TARGET_DIR/$file" ]; then
FILES_FOUND=$((FILES_FOUND + 1))
fi
done
if [ $FILES_FOUND -eq 0 ]; then
echo -e "${YELLOW}No plugin files found. The plugin may already be uninstalled.${NC}"
exit 0
fi
echo "Found $FILES_FOUND plugin file(s)."
echo ""
# Remove plugin files
echo "Removing plugin files..."
for file in "${PLUGIN_FILES[@]}"; do
if [ -f "$TARGET_DIR/$file" ]; then
rm "$TARGET_DIR/$file"
echo -e "${GREEN}${NC} Removed: $file"
FILES_REMOVED=$((FILES_REMOVED + 1))
fi
done
# Remove backup directories created by the installer
BACKUP_DIRS_REMOVED=0
echo ""
echo "Searching for backup directories..."
for backup_dir in "$TARGET_DIR"/mytimeline_backup_*; do
if [ -d "$backup_dir" ]; then
echo "Found backup directory: $(basename "$backup_dir")"
rm -rf "$backup_dir"
echo -e "${GREEN}${NC} Removed backup directory: $(basename "$backup_dir")"
BACKUP_DIRS_REMOVED=$((BACKUP_DIRS_REMOVED + 1))
fi
done
# Also check for any plugin files in subdirectories that might be loaded
echo ""
echo "Checking for plugin files in subdirectories..."
SUBDIR_FILES_REMOVED=0
for subdir in "$TARGET_DIR"/*; do
if [ -d "$subdir" ]; then
for file in "${PLUGIN_FILES[@]}"; do
if [ -f "$subdir/$file" ]; then
echo -e "${YELLOW}Warning: Found $file in subdirectory: $(basename "$subdir")${NC}"
rm -f "$subdir/$file"
echo -e "${GREEN}${NC} Removed: $(basename "$subdir")/$file"
SUBDIR_FILES_REMOVED=$((SUBDIR_FILES_REMOVED + 1))
fi
done
fi
done
TOTAL_REMOVED=$((FILES_REMOVED + BACKUP_DIRS_REMOVED + SUBDIR_FILES_REMOVED))
echo ""
echo "=========================================="
if [ $TOTAL_REMOVED -gt 0 ]; then
echo -e "${GREEN}Uninstallation completed successfully!${NC}"
echo "=========================================="
echo ""
echo "Removed:"
echo " - $FILES_REMOVED plugin file(s) from main directory"
if [ $BACKUP_DIRS_REMOVED -gt 0 ]; then
echo " - $BACKUP_DIRS_REMOVED backup directory/directories"
fi
if [ $SUBDIR_FILES_REMOVED -gt 0 ]; then
echo " - $SUBDIR_FILES_REMOVED plugin file(s) from subdirectories"
fi
echo ""
echo "Location: $TARGET_DIR"
echo ""
echo "Next steps:"
echo " 1. Restart Gramps if it's currently running"
echo " 2. The plugin should no longer appear in the Plugins menu"
else
echo -e "${YELLOW}No files or directories were removed.${NC}"
echo "=========================================="
fi
echo ""