Class: Pikuri::Thunderbird::Calendar

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/thunderbird/calendar.rb

Overview

Calendar search + read over Thunderbird's calendar-data stores (+cache.sqlite+ for cached network/CalDAV calendars, local.sqlite for local "storage" calendars). Event fields are normalized columns, not opaque ICS, so basic reads are a plain SELECT — no ICS parser for the common case. The one exception is the guest list (#attendees), which Thunderbird keeps as raw +ATTENDEE+/+ORGANIZER+ content lines.

cal = Calendar.new(calendar_dbs: profile.calendar_dbs, cache_dir: dir)
cal.available?      # any calendar store on disk?
cal.event_count     # 0 → nothing cached (prompt the user to enable Offline Support)
cal.search(query: 'standup', limit: 10)
cal.read(event_id: hit[:event_id])

Like Gloda, every query runs against DatabaseSnapshot copies (opened ?immutable=1, so a WAL store reads as-of-last-checkpoint), refreshed when the source generation moves.

Time & all-day encoding

+event_start+/+event_end+ are PRTime microseconds of the UTC instant; the event's own zone is in +event_start_tz+/+event_end_tz+ (an Olson name, floating, UTC, or a fixed GMT±HHMM). All-day events are flags & 8 (airtight — correlates 1:1 with a floating zone and a midnight-aligned start) with an iCal exclusive DTEND (a 1-day event spans exactly one day). #search/#read return the raw UTC Times + the tz string + an all_day flag; presentation (local display, all-day date rendering) is the tool's job.

#close closes the snapshot connections.

Sharing

P_shared_locked, on Gloda's terms and for its reasons — see that class's == Sharing for the contracts (lock spans the query, no re-entry, #close needs no refcount, cache_dir stays unshared). The only difference is shape: this class has no with_fresh_db seam, so the guard sits on each public query instead of one chokepoint.

Constant Summary collapse

LOGGER =
Pikuri.logger_for('Thunderbird::Calendar')
ALL_DAY_FLAG =

Returns all-day bit in cal_events.flags.

Returns:

  • (Integer)

    all-day bit in cal_events.flags.

8
RECURRENCE_FLAG =

Returns HAS_RECURRENCE bit in cal_events.flags — set on the master row of any event with an RRULE/RDATE (Thunderbird's CAL_ITEM_FLAG). Airtight for the master, same as ALL_DAY_FLAG.

Returns:

  • (Integer)

    HAS_RECURRENCE bit in cal_events.flags — set on the master row of any event with an RRULE/RDATE (Thunderbird's CAL_ITEM_FLAG). Airtight for the master, same as ALL_DAY_FLAG.

16

Instance Method Summary collapse

Constructor Details

#initialize(calendar_dbs:, cache_dir:) ⇒ Calendar

Parameters:

  • calendar_dbs (Array<String>)

    existing calendar store paths (see Profile#calendar_dbs); may be empty.

  • cache_dir (String)

    private 0700 dir for the snapshot copies.



59
60
61
62
63
64
65
66
67
# File 'lib/pikuri/thunderbird/calendar.rb', line 59

def initialize(calendar_dbs:, cache_dir:)
  @sources = calendar_dbs
  @snapshots = @sources.to_h do |src|
    [src, DatabaseSnapshot.new(source: src, dir: File.join(cache_dir, File.basename(src, '.sqlite')))]
  end
  @conns = {}
  @generations = {}
  @lock = Mutex.new
end

Instance Method Details

#available?Boolean

Returns whether any calendar store exists on disk (the calendar tools register regardless — see the empty-state guidance in Tools — but this gates whether a snapshot is even attempted).

Returns:

  • (Boolean)

    whether any calendar store exists on disk (the calendar tools register regardless — see the empty-state guidance in Tools — but this gates whether a snapshot is even attempted).



72
# File 'lib/pikuri/thunderbird/calendar.rb', line 72

def available? = !@sources.empty?

#closevoid

This method returns an undefined value.

Idempotent, and safe while another agent shares this instance — the next query reopens.



167
168
169
170
171
172
# File 'lib/pikuri/thunderbird/calendar.rb', line 167

def close
  @lock.synchronize do
    @conns.each_value(&:close)
    @conns.clear
  end
end

#event_countInteger

Returns total events cached to disk across all stores. Zero means every calendar is memory-only (Offline Support off); the tool turns that into a self-fixing observation.

Returns:

  • (Integer)

    total events cached to disk across all stores. Zero means every calendar is memory-only (Offline Support off); the tool turns that into a self-fixing observation.



77
78
79
80
81
82
# File 'lib/pikuri/thunderbird/calendar.rb', line 77

def event_count
  @lock.synchronize do
    ensure_fresh
    @conns.values.sum { |db| db.get_first_value('SELECT count(*) FROM cal_events').to_i }
  end
end

#read(event_id:) ⇒ Hash?

Full event detail for one event UID.

Parameters:

  • event_id (String)

    the #search handle (the event UID).

Returns:

  • (Hash, nil)

    the search shape plus :description, :status, and :attendees (see #attendees), or nil if not found.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/pikuri/thunderbird/calendar.rb', line 144

def read(event_id:)
  @lock.synchronize do
    ensure_fresh
    @conns.each_value do |db|
      # A recurring UID has several rows (master + occurrence exceptions);
      # prefer the master (recurrence_id NULL sorts first) for a stable read.
      row = db.execute(<<~SQL, [event_id]).first
        SELECT id, title, event_start, event_end, event_start_tz, event_end_tz, flags, ical_status
        FROM cal_events WHERE id = ?
        ORDER BY (recurrence_id IS NOT NULL) LIMIT 1
      SQL
      next unless row

      return build_event(row).merge(properties(db, event_id), attendees: attendees(db, event_id))
    end
  end
  nil
end

#search(query:, limit:, after: nil, before: nil, recurring: nil) ⇒ Array<Hash>

Search events by title / description / location.

cal.search(query: 'hang around', recurring: false, limit: 15)
# the one-off events titled "…hang around" — not the 50 whose
# description merely contains the common word "around"

Phrase-first, OR-fallback ranking: when the query's words appear adjacently in some event (#search_db's :phrase), only those hits are kept; the loose OR-any-word recall runs only as a fallback when no event matches the phrase (so a scattered-word query like "vaadin party" still hits). Within the kept set, most-words-matched ranks first, newest breaks ties. A single-word query has no adjacency to enforce, so it stays pure substring recall.

A recurring series is stored as a master row plus one row per modified occurrence; Google additionally splits a "this-and-following" edit into separate masters UID_R<ical-datetime>@google.com. #base_uid strips that suffix and #fold_series collapses the whole lot to one hit, so a weekly meeting surfaces once — not once per occurrence or per split.

Parameters:

  • query (String)

    substring over title + DESCRIPTION + LOCATION.

  • limit (Integer)

    max events.

  • after (Time, nil) (defaults to: nil)

    only events starting on/after this instant.

  • before (Time, nil) (defaults to: nil)

    only events starting on/before this instant.

  • recurring (Boolean, nil) (defaults to: nil)

    true → only recurring events, false → only single (non-recurring) events, nil → both.

Returns:

  • (Array<Hash>)

    [{event_id:, title:, start: Time, end: Time, all_day: Boolean, recurring: Boolean, start_tz:, end_tz:, location:}, …]



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/pikuri/thunderbird/calendar.rb', line 112

def search(query:, limit:, after: nil, before: nil, recurring: nil)
  words = query.to_s.scan(/[[:alnum:]]+/).map { |w| fold(w) }
  # Only the fetch is guarded — the folding and ranking below run on
  # materialized rows and touch no connection.
  rows = @lock.synchronize do
    ensure_fresh
    @conns.values.flat_map { |db| search_db(db, words:, after:, before:) }
  end
  # Group on the *base* UID (see #base_uid), not the raw id, so Google's
  # this-and-following splits fold into one series instead of 20+ hits.
  series = rows.group_by { |h| base_uid(h[:event_id]) }.map { |_uid, group| fold_series(group) }
  # Phrase-first: once any event matches the words adjacently, drop the
  # OR-any-word matches — else a two-word query floods with events sharing
  # only a common word (e.g. "around") in a description. The OR set is the
  # fallback when no event matches the phrase.
  series.select! { |h| h[:phrase] } if series.any? { |h| h[:phrase] }
  # Recurrence filter runs *after* folding: an occurrence row lacks the
  # master's HAS_RECURRENCE flag, so filtering per-row in SQL mislabeled
  # every occurrence "single" and leaked the whole series under
  # recurrence:single. Folding classifies the series as a whole first.
  series.select! { |h| h[:recurring] == recurring } unless recurring.nil?
  # Rank across stores by matched-word count, then recency. :score/:phrase
  # are internal ranking keys (see #search_db), dropped from the shape.
  series.sort_by { |h| [-h[:score], -(h[:start]&.to_i || 0)] }
        .first(limit).map { |h| h.except(:score, :phrase) }
end