Class: Pikuri::Thunderbird::Calendar
- Inherits:
-
Object
- Object
- Pikuri::Thunderbird::Calendar
- 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. 8- RECURRENCE_FLAG =
Returns
HAS_RECURRENCEbit incal_events.flags— set on the master row of any event with an RRULE/RDATE (Thunderbird'sCAL_ITEM_FLAG). Airtight for the master, same as ALL_DAY_FLAG. 16
Instance Method Summary collapse
-
#available? ⇒ 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).
-
#close ⇒ void
Idempotent, and safe while another agent shares this instance — the next query reopens.
-
#event_count ⇒ Integer
Total events cached to disk across all stores.
- #initialize(calendar_dbs:, cache_dir:) ⇒ Calendar constructor
-
#read(event_id:) ⇒ Hash?
Full event detail for one event UID.
-
#search(query:, limit:, after: nil, before: nil, recurring: nil) ⇒ Array<Hash>
Search events by title / description / location.
Constructor Details
#initialize(calendar_dbs:, cache_dir:) ⇒ Calendar
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).
72 |
# File 'lib/pikuri/thunderbird/calendar.rb', line 72 def available? = !@sources.empty? |
#close ⇒ void
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_count ⇒ Integer
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.
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.
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.
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 |