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.
@@ -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