pikuri-thunderbird 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/DESIGN.md +460 -0
- data/README.md +66 -0
- data/lib/pikuri/thunderbird/calendar.rb +372 -0
- data/lib/pikuri/thunderbird/calendar_create.rb +126 -0
- data/lib/pikuri/thunderbird/calendar_read.rb +130 -0
- data/lib/pikuri/thunderbird/calendar_search.rb +145 -0
- data/lib/pikuri/thunderbird/compose_guard.rb +281 -0
- data/lib/pikuri/thunderbird/contact_search.rb +90 -0
- data/lib/pikuri/thunderbird/database_snapshot.rb +155 -0
- data/lib/pikuri/thunderbird/date_helpers.rb +93 -0
- data/lib/pikuri/thunderbird/extension.rb +196 -0
- data/lib/pikuri/thunderbird/gloda/contacts.rb +229 -0
- data/lib/pikuri/thunderbird/gloda/mail.rb +216 -0
- data/lib/pikuri/thunderbird/gloda.rb +153 -0
- data/lib/pikuri/thunderbird/ical_line.rb +130 -0
- data/lib/pikuri/thunderbird/ics_event.rb +131 -0
- data/lib/pikuri/thunderbird/launcher.rb +76 -0
- data/lib/pikuri/thunderbird/mail_compose.rb +92 -0
- data/lib/pikuri/thunderbird/mail_read.rb +73 -0
- data/lib/pikuri/thunderbird/mail_search.rb +131 -0
- data/lib/pikuri/thunderbird/mailto_uri.rb +58 -0
- data/lib/pikuri/thunderbird/profile.rb +201 -0
- data/lib/pikuri-thunderbird.rb +76 -0
- data/prompts/thunderbird.txt +5 -0
- metadata +114 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Thunderbird
|
|
5
|
+
class Gloda
|
|
6
|
+
# Mail search + read over the {Gloda} index service — the mail half of the
|
|
7
|
+
# corpus (its people counterpart is {Gloda::Contacts}).
|
|
8
|
+
#
|
|
9
|
+
# mail = Gloda::Mail.new(gloda: gloda)
|
|
10
|
+
# hit = mail.search(query: 'invoice', limit: 10).first # ranked, deduped
|
|
11
|
+
# mail.read(message_id: hit[:message_id]) # full decoded body
|
|
12
|
+
#
|
|
13
|
+
# It owns no resources: it queries {Gloda}'s live snapshot through
|
|
14
|
+
# {Gloda#with_fresh_db} (so a rebuild under it is transparent), and there
|
|
15
|
+
# is nothing to close — the {Gloda} it holds is closed by its own owner.
|
|
16
|
+
#
|
|
17
|
+
# == Gmail dedup
|
|
18
|
+
#
|
|
19
|
+
# Gmail exposes each label as an IMAP folder, so a message with N labels
|
|
20
|
+
# yields N Gloda rows (+[Gmail]/All Mail+ alone duplicates nearly
|
|
21
|
+
# everything). {#search} over-fetches and dedups by +headerMessageID+
|
|
22
|
+
# (falling back to a +gloda:<id>+ handle when absent) *before* the top-N
|
|
23
|
+
# cap, so duplicates can't eat result slots.
|
|
24
|
+
class Mail
|
|
25
|
+
# @return [Integer] over-fetch multiple before dedup+cap (so Gmail
|
|
26
|
+
# label duplicates don't shrink recall).
|
|
27
|
+
OVERFETCH = 4
|
|
28
|
+
|
|
29
|
+
# @return [Integer] absolute floor on the pre-dedup fetch.
|
|
30
|
+
MIN_FETCH = 40
|
|
31
|
+
|
|
32
|
+
# @param gloda [Gloda] the index service whose snapshot this queries.
|
|
33
|
+
# @return [Mail]
|
|
34
|
+
def initialize(gloda:)
|
|
35
|
+
@gloda = gloda
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Ranked, deduped mail search. +query+ is matched as an OR of terms
|
|
39
|
+
# over all decoded columns (recall-first); +bm25+ ranks, recency
|
|
40
|
+
# breaks ties. Structured filters are exact predicates applied
|
|
41
|
+
# alongside. An empty query degrades to a filter-only, date-ordered
|
|
42
|
+
# listing.
|
|
43
|
+
#
|
|
44
|
+
# @param query [String] free text over body/subject/author/recipients/
|
|
45
|
+
# attachment-names.
|
|
46
|
+
# @param limit [Integer] max hits after dedup.
|
|
47
|
+
# @param from [String, nil] substring match on the author.
|
|
48
|
+
# @param to [String, nil] substring match on the recipients.
|
|
49
|
+
# @param subject [String, nil] substring match on the subject.
|
|
50
|
+
# @param after [Time, nil] only messages on/after this instant.
|
|
51
|
+
# @param before [Time, nil] only messages on/before this instant.
|
|
52
|
+
# @param folder [String, nil] exact Gloda folder name.
|
|
53
|
+
# @return [Array<Hash>] +[{message_id:, date: Time, from:, to:,
|
|
54
|
+
# subject:, folder:, snippet:}, …]+, best first, deduped.
|
|
55
|
+
def search(query:, limit:, from: nil, to: nil, subject: nil,
|
|
56
|
+
after: nil, before: nil, folder: nil)
|
|
57
|
+
fetch = [limit * OVERFETCH, MIN_FETCH].max
|
|
58
|
+
rows = @gloda.with_fresh_db do |db|
|
|
59
|
+
if query.to_s.strip.empty?
|
|
60
|
+
filter_only(db, from:, to:, subject:, after:, before:, folder:, fetch:)
|
|
61
|
+
else
|
|
62
|
+
fts_search(db, query:, from:, to:, subject:, after:, before:, folder:, fetch:)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
dedup(rows).first(limit)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Full decoded body + headers + attachment names for one message.
|
|
69
|
+
#
|
|
70
|
+
# @param message_id [String] the {#search} handle (an RFC +Message-ID+
|
|
71
|
+
# or a +gloda:<id>+ fallback).
|
|
72
|
+
# @return [Hash, nil] +{message_id:, date:, from:, to:, subject:,
|
|
73
|
+
# folder:, attachment_names:, body:}+, or +nil+ if not found.
|
|
74
|
+
def read(message_id:)
|
|
75
|
+
gloda_id = message_id.to_s[/\Agloda:(\d+)\z/, 1]
|
|
76
|
+
where, bind = gloda_id ? ['m.id = ?', gloda_id.to_i] : ['m.headerMessageID = ?', message_id]
|
|
77
|
+
row = @gloda.with_fresh_db do |db|
|
|
78
|
+
db.execute(<<~SQL, [bind]).first
|
|
79
|
+
SELECT m.id, m.headerMessageID, m.date,
|
|
80
|
+
c.c0body, c.c1subject, c.c2attachmentNames, c.c3author, c.c4recipients,
|
|
81
|
+
fl.name AS folder
|
|
82
|
+
FROM messages m
|
|
83
|
+
JOIN messagesText_content c ON c.docid = m.id
|
|
84
|
+
LEFT JOIN folderLocations fl ON fl.id = m.folderID
|
|
85
|
+
WHERE #{where} AND m.deleted = 0
|
|
86
|
+
ORDER BY m.date DESC
|
|
87
|
+
LIMIT 1
|
|
88
|
+
SQL
|
|
89
|
+
end
|
|
90
|
+
return nil unless row
|
|
91
|
+
|
|
92
|
+
id, header_msgid, date_us, body, subj, attach, author, recipients, folder = row
|
|
93
|
+
{
|
|
94
|
+
message_id: header_msgid && !header_msgid.empty? ? header_msgid : "gloda:#{id}",
|
|
95
|
+
date: to_time(date_us), from: author, to: recipients, subject: subj,
|
|
96
|
+
folder: folder, attachment_names: attach.to_s, body: body.to_s
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# @return [Array<Array>] raw result rows (see {#row_to_hit} for shape).
|
|
103
|
+
def fts_search(db, query:, from:, to:, subject:, after:, before:, folder:, fetch:)
|
|
104
|
+
clauses = ['fts MATCH ?', 'm.deleted = 0']
|
|
105
|
+
binds = [match_expr(query)]
|
|
106
|
+
append_filters(clauses, binds, from:, to:, subject:, after:, before:, folder:)
|
|
107
|
+
db.execute(<<~SQL, binds + [fetch])
|
|
108
|
+
SELECT m.headerMessageID, m.id, m.date, c.c1subject, c.c3author, c.c4recipients,
|
|
109
|
+
fl.name, snippet(fts, 0, '', '', ' … ', 12)
|
|
110
|
+
FROM fts
|
|
111
|
+
JOIN messages m ON m.id = fts.rowid
|
|
112
|
+
JOIN messagesText_content c ON c.docid = fts.rowid
|
|
113
|
+
LEFT JOIN folderLocations fl ON fl.id = m.folderID
|
|
114
|
+
WHERE #{clauses.join(' AND ')}
|
|
115
|
+
ORDER BY bm25(fts), m.date DESC
|
|
116
|
+
LIMIT ?
|
|
117
|
+
SQL
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Array<Array>] raw result rows, date-ordered.
|
|
121
|
+
def filter_only(db, from:, to:, subject:, after:, before:, folder:, fetch:)
|
|
122
|
+
clauses = ['m.deleted = 0']
|
|
123
|
+
binds = []
|
|
124
|
+
append_filters(clauses, binds, from:, to:, subject:, after:, before:, folder:)
|
|
125
|
+
db.execute(<<~SQL, binds + [fetch])
|
|
126
|
+
SELECT m.headerMessageID, m.id, m.date, c.c1subject, c.c3author, c.c4recipients,
|
|
127
|
+
fl.name, substr(c.c0body, 1, 180)
|
|
128
|
+
FROM messages m
|
|
129
|
+
JOIN messagesText_content c ON c.docid = m.id
|
|
130
|
+
LEFT JOIN folderLocations fl ON fl.id = m.folderID
|
|
131
|
+
WHERE #{clauses.join(' AND ')}
|
|
132
|
+
ORDER BY m.date DESC
|
|
133
|
+
LIMIT ?
|
|
134
|
+
SQL
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Append the shared structured predicates (mutates +clauses+/+binds+).
|
|
138
|
+
#
|
|
139
|
+
# @return [void]
|
|
140
|
+
def append_filters(clauses, binds, from:, to:, subject:, after:, before:, folder:)
|
|
141
|
+
add_like(clauses, binds, 'c.c3author', from)
|
|
142
|
+
add_like(clauses, binds, 'c.c4recipients', to)
|
|
143
|
+
add_like(clauses, binds, 'c.c1subject', subject)
|
|
144
|
+
if after
|
|
145
|
+
clauses << 'm.date >= ?'
|
|
146
|
+
binds << to_prtime(after)
|
|
147
|
+
end
|
|
148
|
+
if before
|
|
149
|
+
clauses << 'm.date <= ?'
|
|
150
|
+
binds << to_prtime(before)
|
|
151
|
+
end
|
|
152
|
+
if folder && !folder.empty?
|
|
153
|
+
clauses << 'fl.name = ?'
|
|
154
|
+
binds << folder
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [void]
|
|
159
|
+
def add_like(clauses, binds, column, needle)
|
|
160
|
+
return if needle.nil? || needle.to_s.empty?
|
|
161
|
+
|
|
162
|
+
clauses << "#{column} LIKE '%' || ? || '%'"
|
|
163
|
+
binds << needle.to_s
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Build the FTS5 MATCH expression: OR of the query's terms, each
|
|
167
|
+
# phrase-quoted (internal +"+ doubled) so FTS5 syntax in user input is
|
|
168
|
+
# inert. OR maximizes recall; bm25 does the ranking.
|
|
169
|
+
#
|
|
170
|
+
# @param query [String]
|
|
171
|
+
# @return [String]
|
|
172
|
+
def match_expr(query)
|
|
173
|
+
terms = query.to_s.scan(/[[:alnum:]]+/)
|
|
174
|
+
return '""' if terms.empty?
|
|
175
|
+
|
|
176
|
+
terms.map { |t| %("#{t.gsub('"', '""')}") }.join(' OR ')
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @param rows [Array<Array>] raw rows.
|
|
180
|
+
# @return [Array<Hash>] hit hashes, deduped by handle, order preserved.
|
|
181
|
+
def dedup(rows)
|
|
182
|
+
seen = {}
|
|
183
|
+
rows.each do |row|
|
|
184
|
+
hit = row_to_hit(row)
|
|
185
|
+
seen[hit[:message_id]] ||= hit
|
|
186
|
+
end
|
|
187
|
+
seen.values
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @param row [Array] +[headerMessageID, id, date_us, subject, author,
|
|
191
|
+
# recipients, folder, snippet]+
|
|
192
|
+
# @return [Hash]
|
|
193
|
+
def row_to_hit(row)
|
|
194
|
+
header_msgid, id, date_us, subject, author, recipients, folder, snippet = row
|
|
195
|
+
{
|
|
196
|
+
message_id: header_msgid && !header_msgid.empty? ? header_msgid : "gloda:#{id}",
|
|
197
|
+
date: to_time(date_us), from: author, to: recipients,
|
|
198
|
+
subject: subject, folder: folder, snippet: snippet.to_s.strip
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @param prtime_us [Integer, nil] PRTime microseconds since epoch.
|
|
203
|
+
# @return [Time, nil]
|
|
204
|
+
def to_time(prtime_us)
|
|
205
|
+
return nil if prtime_us.nil? || prtime_us.zero?
|
|
206
|
+
|
|
207
|
+
Time.at(prtime_us / 1_000_000)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @param time [Time]
|
|
211
|
+
# @return [Integer] PRTime microseconds.
|
|
212
|
+
def to_prtime(time) = (time.to_i * 1_000_000)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sqlite3'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Thunderbird
|
|
7
|
+
# Provides queryable access to Thunderbird's Gloda index
|
|
8
|
+
# (+global-messages-db.sqlite+) — a *pre-decoded corpus*: Gloda has already
|
|
9
|
+
# MIME-decoded, HTML-stripped, and split every message into body / subject /
|
|
10
|
+
# attachment-names / author / recipients, and keeps it fresh for free. This
|
|
11
|
+
# is the *service*: it snapshots the locked live file, builds an ephemeral
|
|
12
|
+
# FTS5 index into the copy, tracks freshness, and hands out a current
|
|
13
|
+
# connection — but runs no queries itself. Two views do the querying:
|
|
14
|
+
#
|
|
15
|
+
# gloda = Gloda.new(gloda_path: profile.gloda_path, cache_dir: dir)
|
|
16
|
+
# gloda.available? # is the Gloda file present?
|
|
17
|
+
# Gloda::Mail.new(gloda: gloda) # mail search + read
|
|
18
|
+
# Gloda::Contacts.new(gloda: gloda) # name → address + novelty
|
|
19
|
+
# gloda.close # closes the snapshot connection
|
|
20
|
+
#
|
|
21
|
+
# A view queries through {#with_fresh_db}; it never caches the connection
|
|
22
|
+
# (a rebuild reopens it). pikuri never touches the locked live file — it
|
|
23
|
+
# works against a {DatabaseSnapshot} copy and builds its own FTS5 index into that
|
|
24
|
+
# copy (Gloda's own +messagesText+ is an fts3 vtable behind the custom
|
|
25
|
+
# +mozporter+ tokenizer, which the sqlite3 gem can't open).
|
|
26
|
+
#
|
|
27
|
+
# This service is *specific to Gloda's schema* — the FTS5 index it builds is
|
|
28
|
+
# over Gloda's decoded columns (+c0body+ … +c4recipients+), so it is not a
|
|
29
|
+
# generic SQLite handle and does not serve the calendar DBs ({Calendar} has
|
|
30
|
+
# its own {DatabaseSnapshot} + backend).
|
|
31
|
+
#
|
|
32
|
+
# == Freshness & consistency (one signal)
|
|
33
|
+
#
|
|
34
|
+
# Each {#with_fresh_db} first compares the live Gloda change counter against
|
|
35
|
+
# the generation the current copy+index were built from; if it moved, a
|
|
36
|
+
# fresh {DatabaseSnapshot} is taken and the FTS5 index rebuilt (~1 s). Between
|
|
37
|
+
# changes it's a 4-byte read and a no-op. The counter bumps on any Gloda
|
|
38
|
+
# write — including read/star/tag flips during triage — so a rebuild can
|
|
39
|
+
# fire mid-conversation (desirable: the views then see new mail). See
|
|
40
|
+
# {DatabaseSnapshot}.
|
|
41
|
+
#
|
|
42
|
+
# Holds no external state beyond its own snapshot + connection, but {#close}
|
|
43
|
+
# is required for orderly teardown.
|
|
44
|
+
#
|
|
45
|
+
# == Sharing
|
|
46
|
+
#
|
|
47
|
+
# +P_shared_locked+, and sharing beats one-per-agent: the snapshot copy and
|
|
48
|
+
# the ~1 s FTS5 rebuild are then paid once for the machine, not once each.
|
|
49
|
+
# Three contracts come with that:
|
|
50
|
+
#
|
|
51
|
+
# * The lock is held **across the caller's block**. Releasing it at the
|
|
52
|
+
# +yield+ would hand out exactly the connection a rebuild then closes, so
|
|
53
|
+
# a rebuild parks a second agent for ~1 s — the cost it would have paid
|
|
54
|
+
# alone anyway; every other query is local SQLite.
|
|
55
|
+
# * A view may therefore never nest {#with_fresh_db} inside another (the
|
|
56
|
+
# guard is a +Mutex+, not a +Monitor+).
|
|
57
|
+
# * {#close} needs no refcount: it drops the fd, and the next
|
|
58
|
+
# {#with_fresh_db} reopens the same copy. A co-owner pays a rebuild,
|
|
59
|
+
# never a failure.
|
|
60
|
+
#
|
|
61
|
+
# The +cache_dir+ is what must stay unshared — two instances over one
|
|
62
|
+
# snapshot path +cp -f+ under each other's open fd.
|
|
63
|
+
class Gloda
|
|
64
|
+
LOGGER = Pikuri.logger_for('Thunderbird::Gloda')
|
|
65
|
+
|
|
66
|
+
# @param gloda_path [String] path to +global-messages-db.sqlite+.
|
|
67
|
+
# @param cache_dir [String] private 0700 dir for the snapshot copy. Must
|
|
68
|
+
# be this instance's alone — see the +== Sharing+ section.
|
|
69
|
+
# @return [Gloda]
|
|
70
|
+
def initialize(gloda_path:, cache_dir:)
|
|
71
|
+
@source = gloda_path
|
|
72
|
+
@snapshot = DatabaseSnapshot.new(source: gloda_path, dir: cache_dir)
|
|
73
|
+
@db = nil
|
|
74
|
+
@indexed_generation = nil
|
|
75
|
+
@lock = Mutex.new
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Boolean] whether the Gloda index file exists (the mail /
|
|
79
|
+
# contact tools' registration gate).
|
|
80
|
+
def available? = File.file?(@source)
|
|
81
|
+
|
|
82
|
+
# Yield the snapshot connection, guaranteed current — a stale index is
|
|
83
|
+
# rebuilt first. The sole seam the {Gloda::Mail} / {Gloda::Contacts} views
|
|
84
|
+
# query through; they never cache the returned connection, because a
|
|
85
|
+
# rebuild reopens it.
|
|
86
|
+
#
|
|
87
|
+
# gloda.with_fresh_db { |db| db.execute('SELECT count(*) FROM messages') }
|
|
88
|
+
#
|
|
89
|
+
# The lock spans the block, so the yielded connection cannot be swapped
|
|
90
|
+
# mid-query and the block must not re-enter (see +== Sharing+).
|
|
91
|
+
#
|
|
92
|
+
# @yield [SQLite3::Database] the fresh snapshot connection.
|
|
93
|
+
# @return [Object] the block's value.
|
|
94
|
+
def with_fresh_db
|
|
95
|
+
@lock.synchronize do
|
|
96
|
+
ensure_fresh
|
|
97
|
+
yield @db
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Close the snapshot connection (the snapshot dir itself is
|
|
102
|
+
# finalizer-reaped). Idempotent, and safe while another agent shares this
|
|
103
|
+
# instance — the next {#with_fresh_db} reopens.
|
|
104
|
+
#
|
|
105
|
+
# @return [void]
|
|
106
|
+
def close
|
|
107
|
+
@lock.synchronize { close! }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# {#close} without the lock — the form the already-holding callers use;
|
|
113
|
+
# +Mutex+ is not reentrant.
|
|
114
|
+
#
|
|
115
|
+
# @return [void]
|
|
116
|
+
def close!
|
|
117
|
+
@db&.close
|
|
118
|
+
@db = nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# (Re)take the snapshot + rebuild the FTS5 index iff the live Gloda
|
|
122
|
+
# generation differs from what the current index was built from. Callers
|
|
123
|
+
# hold the lock.
|
|
124
|
+
#
|
|
125
|
+
# @return [void]
|
|
126
|
+
def ensure_fresh
|
|
127
|
+
live = DatabaseSnapshot.change_counter(@source)
|
|
128
|
+
return if @db && @indexed_generation && live == @indexed_generation
|
|
129
|
+
|
|
130
|
+
close! # release the fd before cp overwrites the copy in place
|
|
131
|
+
@snapshot.refresh
|
|
132
|
+
open_and_index
|
|
133
|
+
@indexed_generation = @snapshot.generation
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Open the fresh snapshot copy read-write and build an external-content
|
|
137
|
+
# FTS5 index over Gloda's decoded columns (stores only the index, no
|
|
138
|
+
# second text copy; hits map back by rowid = +messages.id+).
|
|
139
|
+
#
|
|
140
|
+
# @return [void]
|
|
141
|
+
def open_and_index
|
|
142
|
+
@db = SQLite3::Database.new(@snapshot.path)
|
|
143
|
+
@db.execute(<<~SQL)
|
|
144
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(
|
|
145
|
+
c0body, c1subject, c2attachmentNames, c3author, c4recipients,
|
|
146
|
+
content='messagesText_content', content_rowid='docid'
|
|
147
|
+
)
|
|
148
|
+
SQL
|
|
149
|
+
@db.execute("INSERT INTO fts(fts) VALUES('rebuild')")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Thunderbird
|
|
5
|
+
# Parses the little iCalendar that Thunderbird leaves opaque — pikuri needs
|
|
6
|
+
# it only for +cal_attendees+, whose rows are raw +ATTENDEE+/+ORGANIZER+
|
|
7
|
+
# content lines (everything else is already in normalized columns). The
|
|
8
|
+
# inbound counterpart to {IcsEvent}'s outbound line *building*. Two entry
|
|
9
|
+
# points:
|
|
10
|
+
#
|
|
11
|
+
# # domain: an attendee line → a typed {Attendee}
|
|
12
|
+
# who = IcalLine.attendee('ATTENDEE;CN="Last, First";PARTSTAT=ACCEPTED:mailto:x@y.com')
|
|
13
|
+
# who.name # => "Last, First"
|
|
14
|
+
# who.email # => "x@y.com"
|
|
15
|
+
# who.optional? # => false
|
|
16
|
+
#
|
|
17
|
+
# # generic: any content line → its name/params/value
|
|
18
|
+
# line = IcalLine.parse('ATTENDEE;PARTSTAT=ACCEPTED:mailto:x@y.com')
|
|
19
|
+
# line.params["PARTSTAT"] # => "ACCEPTED"
|
|
20
|
+
#
|
|
21
|
+
# Both are quote-aware — a +;+/+:+ inside a double-quoted param value
|
|
22
|
+
# doesn't split — and upcase param names (RFC 5545 §3.2: case-insensitive).
|
|
23
|
+
# +TEXT+-value backslash *unescaping* is deliberately not done: the names
|
|
24
|
+
# and addresses +cal_attendees+ carries arrive quoted, not +\,+/+\;+-escaped,
|
|
25
|
+
# so it would be dead code.
|
|
26
|
+
#
|
|
27
|
+
# Immutable.
|
|
28
|
+
module IcalLine
|
|
29
|
+
# One attendee or organizer, parsed from an +ATTENDEE+/+ORGANIZER+ line.
|
|
30
|
+
# +email+ falls back from the +EMAIL+ param to the +mailto:+ value,
|
|
31
|
+
# +name+ from +CN+ to the email — so both are always present.
|
|
32
|
+
#
|
|
33
|
+
# @!attribute [r] name
|
|
34
|
+
# @return [String] display name (+CN+), or the email when there's no CN.
|
|
35
|
+
# @!attribute [r] email
|
|
36
|
+
# @return [String] the address, sans +mailto:+.
|
|
37
|
+
# @!attribute [r] status
|
|
38
|
+
# @return [String, nil] raw +PARTSTAT+ (+"ACCEPTED"+, +"NEEDS-ACTION"+…).
|
|
39
|
+
# @!attribute [r] role
|
|
40
|
+
# @return [String, nil] raw +ROLE+ (+"REQ-PARTICIPANT"+, +"OPT-PARTICIPANT"+).
|
|
41
|
+
# @!attribute [r] cutype
|
|
42
|
+
# @return [String, nil] raw +CUTYPE+ (+"INDIVIDUAL"+, +"RESOURCE"+…).
|
|
43
|
+
# @!attribute [r] organizer
|
|
44
|
+
# @return [Boolean] whether the line was an +ORGANIZER+, not an +ATTENDEE+.
|
|
45
|
+
Attendee = Data.define(:name, :email, :status, :role, :cutype, :organizer) do
|
|
46
|
+
# @return [Boolean] the ORGANIZER (vs a plain attendee).
|
|
47
|
+
def organizer? = organizer
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] an optional invitee (+ROLE=OPT-PARTICIPANT+).
|
|
50
|
+
def optional? = role == 'OPT-PARTICIPANT'
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] a room/equipment, not a person (+CUTYPE=RESOURCE+).
|
|
53
|
+
def resource? = cutype == 'RESOURCE'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# One generic parsed content line.
|
|
57
|
+
#
|
|
58
|
+
# @!attribute [r] name
|
|
59
|
+
# @return [String] the property name, upcased (e.g. +"ATTENDEE"+).
|
|
60
|
+
# @!attribute [r] params
|
|
61
|
+
# @return [Hash{String => String}] param name (upcased) → unquoted value.
|
|
62
|
+
# @!attribute [r] value
|
|
63
|
+
# @return [String] the text after the first unquoted +:+ (+""+ if none).
|
|
64
|
+
Parsed = Data.define(:name, :params, :value)
|
|
65
|
+
|
|
66
|
+
# Parse one +ATTENDEE+/+ORGANIZER+ content line into an {Attendee}.
|
|
67
|
+
#
|
|
68
|
+
# @param line [String] one raw +cal_attendees.icalString+.
|
|
69
|
+
# @return [Attendee]
|
|
70
|
+
def self.attendee(line)
|
|
71
|
+
parsed = parse(line)
|
|
72
|
+
email = (parsed.params['EMAIL'].to_s.empty? ? parsed.value.sub(/\Amailto:/i, '') : parsed.params['EMAIL']).strip
|
|
73
|
+
name = parsed.params['CN'].to_s.strip
|
|
74
|
+
Attendee.new(
|
|
75
|
+
name: name.empty? ? email : name, email: email,
|
|
76
|
+
status: parsed.params['PARTSTAT'], role: parsed.params['ROLE'],
|
|
77
|
+
cutype: parsed.params['CUTYPE'], organizer: parsed.name == 'ORGANIZER'
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parse one content line into its {Parsed} parts. Never raises — a line
|
|
82
|
+
# with no +:+ yields an empty value, a token with no +=+ is skipped.
|
|
83
|
+
#
|
|
84
|
+
# @param line [String] a raw iCal content line.
|
|
85
|
+
# @return [Parsed]
|
|
86
|
+
def self.parse(line)
|
|
87
|
+
head, value = split_unquoted(line, ':', max: 2)
|
|
88
|
+
name, *param_tokens = split_unquoted(head, ';')
|
|
89
|
+
params = param_tokens.each_with_object({}) do |token, h|
|
|
90
|
+
key, val = token.split('=', 2)
|
|
91
|
+
h[key.to_s.upcase] = unquote(val) if val
|
|
92
|
+
end
|
|
93
|
+
Parsed.new(name: name.to_s.upcase, params: params, value: value.to_s)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Split +str+ on the single char +delim+, ignoring delimiters inside a
|
|
97
|
+
# double-quoted iCal param value; +max+ caps the part count (like
|
|
98
|
+
# +String#split+'s limit) so a value's own +mailto:+ colon survives.
|
|
99
|
+
#
|
|
100
|
+
# @param str [String]
|
|
101
|
+
# @param delim [String] one-character delimiter.
|
|
102
|
+
# @param max [Integer, nil] max parts, or +nil+ for unlimited.
|
|
103
|
+
# @return [Array<String>]
|
|
104
|
+
def self.split_unquoted(str, delim, max: nil)
|
|
105
|
+
parts = []
|
|
106
|
+
buf = +''
|
|
107
|
+
in_quote = false
|
|
108
|
+
str.to_s.each_char do |c|
|
|
109
|
+
if c == '"'
|
|
110
|
+
in_quote = !in_quote
|
|
111
|
+
buf << c
|
|
112
|
+
elsif c == delim && !in_quote && (max.nil? || parts.size < max - 1)
|
|
113
|
+
parts << buf
|
|
114
|
+
buf = +''
|
|
115
|
+
else
|
|
116
|
+
buf << c
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
parts << buf
|
|
120
|
+
parts
|
|
121
|
+
end
|
|
122
|
+
private_class_method :split_unquoted
|
|
123
|
+
|
|
124
|
+
# @param str [String, nil]
|
|
125
|
+
# @return [String] +str+ with one layer of surrounding double quotes off.
|
|
126
|
+
def self.unquote(str) = str.to_s.gsub(/\A"|"\z/, '')
|
|
127
|
+
private_class_method :unquote
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Pikuri
|
|
7
|
+
module Thunderbird
|
|
8
|
+
# Builds the iCalendar (+.ics+) text that {CalendarCreate} stages for
|
|
9
|
+
# Thunderbird's import wizard. The text *is* the injection surface — the same
|
|
10
|
+
# role {MailtoUri} plays for compose — because a raw newline in a text field
|
|
11
|
+
# would let an attacker-authored description close +DESCRIPTION+ and inject
|
|
12
|
+
# its own +ATTENDEE+ line or a second +VEVENT+. So every text value is
|
|
13
|
+
# RFC-5545-escaped (+\+, +;+, +,+, newline) and control bytes are stripped
|
|
14
|
+
# before it joins a property line:
|
|
15
|
+
#
|
|
16
|
+
# IcsEvent.build(title: 'Q2 review', start: Time.parse('2026-07-20 14:00'))
|
|
17
|
+
# # BEGIN:VCALENDAR … BEGIN:VEVENT
|
|
18
|
+
# # SUMMARY:Q2 review
|
|
19
|
+
# # DTSTART:20260720T120000Z ← host-local 14:00 emitted as UTC
|
|
20
|
+
# # DTEND:20260720T130000Z ← default +1h
|
|
21
|
+
# # END:VEVENT END:VCALENDAR
|
|
22
|
+
#
|
|
23
|
+
# == Time & all-day encoding
|
|
24
|
+
#
|
|
25
|
+
# Timed events emit +DTSTART+/+DTEND+ as UTC (+…Z+): the caller's {Time} is
|
|
26
|
+
# taken at face value and converted with +#utc+, so a bare "2pm" the user
|
|
27
|
+
# typed is 2pm in the *host* zone — the same "host zone == user zone"
|
|
28
|
+
# heuristic {Calendar}/{DateHelpers} already read events back with. An
|
|
29
|
+
# explicit offset in the input (e.g. "15:00 +0900") is honored too —
|
|
30
|
+
# {DateHelpers.parse_time} keeps it and +#utc+ maps it to the right instant,
|
|
31
|
+
# so +start+ and +finish+ may legitimately sit in different zones. All-day events
|
|
32
|
+
# emit +VALUE=DATE+ with an iCal *exclusive* +DTEND+ (a single-day event's
|
|
33
|
+
# DTEND is the next day), so +finish+ is the user-intuitive *last* day.
|
|
34
|
+
#
|
|
35
|
+
# Immutable.
|
|
36
|
+
module IcsEvent
|
|
37
|
+
# @return [String] product id line value; identifies pikuri as the writer.
|
|
38
|
+
PRODID = '-//pikuri//thunderbird//EN'
|
|
39
|
+
|
|
40
|
+
# C0/C1 control bytes (except the ones {.escape} turns into +\n+) — invalid
|
|
41
|
+
# in an ICS value and a structure-injection vector; stripped outright.
|
|
42
|
+
#
|
|
43
|
+
# A +String#delete+ byte set, not a regexp: {.escape} strips these from the
|
|
44
|
+
# *binary* copy of the value, keeping the strip byte-wise (every byte here
|
|
45
|
+
# is ASCII, so it can never bite into a multibyte sequence) without a
|
|
46
|
+
# +/n+-flagged regexp meeting a UTF-8 string. Ruby warns on that pairing
|
|
47
|
+
# ("historical binary regexp match"), and an interpreter warning goes to the
|
|
48
|
+
# raw +$stderr+, bypassing +Pikuri.log_io+ and painting over a TUI's screen
|
|
49
|
+
# — one accented character in a title was enough. Dropping the +/n+ instead
|
|
50
|
+
# would have made the strip character-wise and raise +ArgumentError+ on
|
|
51
|
+
# invalid UTF-8, which {CalendarCreate} then reports as a bad date.
|
|
52
|
+
#
|
|
53
|
+
# @return [String] a +String#delete+ character set.
|
|
54
|
+
CONTROL = "\x00-\x08\x0b\x0c\x0e-\x1f\x7f"
|
|
55
|
+
|
|
56
|
+
# Build the +.ics+ document for one event.
|
|
57
|
+
#
|
|
58
|
+
# @param title [String] event summary (SUMMARY).
|
|
59
|
+
# @param start [Time] start instant (timed) or day (all-day, date part used).
|
|
60
|
+
# @param finish [Time, nil] end instant / last all-day date; +nil+ defaults
|
|
61
|
+
# to +start + 1h+ (timed) or the same single day (all-day).
|
|
62
|
+
# @param all_day [Boolean] emit +VALUE=DATE+ instead of a UTC datetime.
|
|
63
|
+
# @param location [String, nil] LOCATION, or +nil+ to omit.
|
|
64
|
+
# @param description [String, nil] DESCRIPTION, or +nil+ to omit.
|
|
65
|
+
# @param uid [String] event UID; defaults to a fresh random one.
|
|
66
|
+
# @param dtstamp [Time] the DTSTAMP instant; defaults to now.
|
|
67
|
+
# @return [String] a complete +VCALENDAR+ with one +VEVENT+, CRLF-joined.
|
|
68
|
+
def self.build(title:, start:, finish: nil, all_day: false, location: nil, description: nil,
|
|
69
|
+
uid: "#{SecureRandom.uuid}@pikuri", dtstamp: Time.now)
|
|
70
|
+
lines = [
|
|
71
|
+
'BEGIN:VCALENDAR', 'VERSION:2.0', "PRODID:#{PRODID}", 'METHOD:PUBLISH',
|
|
72
|
+
'BEGIN:VEVENT', "UID:#{escape(uid)}", "DTSTAMP:#{utc_stamp(dtstamp)}",
|
|
73
|
+
*date_lines(start, finish, all_day),
|
|
74
|
+
"SUMMARY:#{escape(title)}"
|
|
75
|
+
]
|
|
76
|
+
lines << "LOCATION:#{escape(location)}" if present?(location)
|
|
77
|
+
lines << "DESCRIPTION:#{escape(description)}" if present?(description)
|
|
78
|
+
lines += %w[END:VEVENT END:VCALENDAR]
|
|
79
|
+
# CRLF line endings + a trailing CRLF, per RFC 5545 §3.1.
|
|
80
|
+
"#{lines.join("\r\n")}\r\n"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Array<String>] the DTSTART/DTEND property lines.
|
|
84
|
+
def self.date_lines(start, finish, all_day)
|
|
85
|
+
return timed_lines(start, finish) unless all_day
|
|
86
|
+
|
|
87
|
+
last = finish || start
|
|
88
|
+
["DTSTART;VALUE=DATE:#{date_stamp(start)}",
|
|
89
|
+
"DTEND;VALUE=DATE:#{date_stamp(last + 86_400)}"] # exclusive: last day + 1
|
|
90
|
+
end
|
|
91
|
+
private_class_method :date_lines
|
|
92
|
+
|
|
93
|
+
# @return [Array<String>]
|
|
94
|
+
def self.timed_lines(start, finish)
|
|
95
|
+
["DTSTART:#{utc_stamp(start)}", "DTEND:#{utc_stamp(finish || start + 3600)}"]
|
|
96
|
+
end
|
|
97
|
+
private_class_method :timed_lines
|
|
98
|
+
|
|
99
|
+
# @param time [Time]
|
|
100
|
+
# @return [String] +"YYYYMMDDTHHMMSSZ"+ (UTC).
|
|
101
|
+
def self.utc_stamp(time) = time.getutc.strftime('%Y%m%dT%H%M%SZ')
|
|
102
|
+
private_class_method :utc_stamp
|
|
103
|
+
|
|
104
|
+
# @param time [Time]
|
|
105
|
+
# @return [String] +"YYYYMMDD"+ (local date, for all-day VALUE=DATE).
|
|
106
|
+
def self.date_stamp(time) = time.getlocal.strftime('%Y%m%d')
|
|
107
|
+
private_class_method :date_stamp
|
|
108
|
+
|
|
109
|
+
# RFC 5545 §3.3.11 TEXT escaping + control-byte strip (the injection
|
|
110
|
+
# defense). Long lines are left unfolded — Thunderbird reads them fine.
|
|
111
|
+
#
|
|
112
|
+
# @param value [String]
|
|
113
|
+
# @return [String]
|
|
114
|
+
def self.escape(value)
|
|
115
|
+
# Block form: the returned strings are used literally, sidestepping
|
|
116
|
+
# gsub's backreference interpretation of a replacement String. Backslash
|
|
117
|
+
# first, so the escapes introduced below aren't themselves re-escaped.
|
|
118
|
+
value.to_s.b.delete(CONTROL).force_encoding(Encoding::UTF_8)
|
|
119
|
+
.gsub('\\') { '\\\\' }
|
|
120
|
+
.gsub(';') { '\\;' }
|
|
121
|
+
.gsub(',') { '\\,' }
|
|
122
|
+
.gsub(/\r\n|\r|\n/) { '\\n' }
|
|
123
|
+
end
|
|
124
|
+
private_class_method :escape
|
|
125
|
+
|
|
126
|
+
# @return [Boolean]
|
|
127
|
+
def self.present?(value) = !value.nil? && !value.to_s.strip.empty?
|
|
128
|
+
private_class_method :present?
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|