LOCAL CHANGES TO THE VENDORED CALENDAR EXPERIMENT
=================================================

This folder is vendored from thunderbird/webext-experiments, path
calendar/experiments/calendar, at commit

    b7f7cb3e76807903a785a03784d6e7df7b213f21

as recorded in ../../VENDOR.md. It is NOT a pristine copy. Seven files
carry local changes; every other file is byte-identical to that commit.

The same folder is vendored into TbSync, and the two copies are kept
byte-identical by hand - this file included. Verify with

    diff -r TbSync/src/experiments/calendar EAS-4-TbSync/src/experiments/calendar

Where to look
-------------

The history is deliberately split so the deviation is reviewable on its own:

    6967645  Vendor the calendar experiment 1:1 from upstream
    1479556  Apply our local changes to the vendored calendar experiment

6967645 is the pristine baseline, so

    git diff 6967645 -- src/experiments/calendar

is the complete and current set of deviations, however many commits have
touched the folder since. If that diff is empty, this file is stale and
should be deleted.

The timezone changes were made here first and copied to TbSync, where they
still are: TbSync calls calendar.calendars.* and calendar.items.* and never
touches calendar.timezones.*, so it has no use for them, but carrying them
keeps the two copies diffable, which is worth more than trimming files no
caller reaches. They landed in c4283c4 ("be more verbose on
errors", 2026-04-29), the commit
that also added modules/eas/timezone-mapping.mjs and the Windows timezone
tables - so they are the experiment-side half of getting Windows/IANA
timezone mapping working against a real server. 1761b86 ("init
timezoneService in the child impl", 2026-05-07) is the follow-up.

This add-on is the only consumer of calendar.timezones:
modules/eas/timezone-mapping.mjs reads currentZone, timezoneIds and
getDefinition; modules/eas/calendar-sync.mjs reads currentZone.

What changed, and why
---------------------

Items 0-4 are in child/ext-calendar-timezones.js, item 5 in
parent/ext-calendar-timezones.js, item 6 in schema/calendar-calendars.json,
ext-calendar-utils.sys.mjs and parent/ext-calendar-calendars.js, items 7 and
8 in parent/ext-calendar-provider.js, item 9 in ext-calendar-utils.sys.mjs,
parent/ext-calendar-provider.js and parent/ext-calendar-items.js.

0. Priming the timezone service. This runs in the child process, where the
   service is a fresh instance Thunderbird's own startup never touched -
   unprimed, the zone list is empty and there is no default zone. Up to and
   including Thunderbird 153 that means calling startup(). Thunderbird 154
   moved the work into the constructor and removed startup() entirely
   (Bug 2022873, "restructure lazy cal. services", commit 1a8cfe0a7058 on
   the beta branch of thunderbird/thunderbird-desktop), so the call is
   probed with startup?.(null) rather than made unconditionally. Probed
   rather than compared against Services.appinfo.version, because the
   version boundary only holds until someone backports, and a probe
   degrades to "already primed, do nothing" instead of to an error.

1. Cu.cloneInto for timezoneIds and the parsed jCal definition.
   Both cross the privileged/extension boundary. Without cloning into
   context.cloneScope the extension side receives an object it cannot use.
   This is also why getAPI(_context) became getAPI(context): upstream does
   not use the parameter, we need it for the clone scope.

2. currentZone returns "" instead of undefined when there is no default
   timezone, keeping the return type stable across the boundary. Callers
   still guard with `|| "UTC"`.

3. The private call cal.timezoneService.wrappedJSObject._updateDefaultTimezone()
   is removed. On 153 it is redundant once the service is primed, because
   startup() registers the pref and system-timezone observers that keep the
   default fresh. On 154+ it would throw outright, since that method is now
   a genuine #private field. Upstream still carries the call, so upstream's
   own draft is broken on 154+.

4. An unknown returnFormat throws instead of silently returning the raw
   ical definition, and ExtensionError is imported so that throw raises the
   intended error rather than a ReferenceError - upstream imports it in
   every other file that throws it, but omits it here. Our own
   getDefinition(tzid) call in timezone-mapping.mjs passes no returnFormat
   and is unaffected: the schema declares "default": "ical", which the API
   layer fills in, and that same default is why upstream's fallthrough
   happened to work.

5. The same _updateDefaultTimezone() call is guarded rather than removed in
   the parent, where it appears twice inside the onUpdated EventManager. It
   still does useful work on 153 - it forces a refresh before the handler
   reads defaultTimezone - so it is written as
   wrappedJSObject?._updateDefaultTimezone?.(). On 154+ the timezone service
   is a plain ESM singleton with no XPCOM wrapper, so wrappedJSObject is
   undefined and the expression short-circuits; nothing is lost, because the
   service observes the same two prefs and the same system-timezone topic
   this handler observes.

6. calendar.calendars exposes refreshInterval, the minutes between
   Thunderbird's own automatic refreshes of a calendar, 0 meaning "do not
   refresh". Upstream reports and accepts neither, so a provider can see
   neither how often the host will refresh its calendars nor stop it doing
   so - which matters here, because the add-on runs its own sync schedule
   and the host's timer duplicates it.

   It needs no platform support. refreshInterval is an ordinary entry in
   the calICalendar property bag, the same bag the experiment already uses
   for color, disabled and suppressAlarms, and the same one Thunderbird's
   calendar properties dialog writes (calendar-properties-dialog.js).
   CalCalendarManager observes it: onPropertyChanged calls
   setupRefreshTimer, so a write takes effect at once, and the property is
   in propsToCopy, so it persists. Unset means Thunderbird's 30-minute
   default (setupRefreshTimer), which is why convertCalendar omits the
   property rather than reporting 30 - a stored 30 and no value at all are
   different states, and only the former survives the user changing the
   default. The bag can hand the value back as a string, so it is parsed.

   Three files: the property on Calendar, CalendarChangeProps and both
   create()'s createProperties and update()'s updateProperties in the
   schema; the read in convertCalendar; the writes in create() and
   update() plus the onUpdated case in parent/ext-calendar-calendars.js.

   create() takes it because that is where it matters most. A provider
   that runs its own sync schedule wants 0 from the outset: the host arms
   the timer in registerCalendar, so setting the interval afterwards means
   a timer exists, however briefly, and the first refresh can land before
   the correction does. The create() write happens before that call.

   Both entry points refuse the property on a calendar whose canRefresh is
   false, throwing rather than ignoring it: an interval on a calendar that
   cannot refresh is meaningless, and silently dropping it would let a
   later get() disagree with what the caller believed it had set. 0 is
   refused too - the point is that no such calendar carries an interval at
   all, not that it carries a harmless one.

   The check runs before either command touches the calendar: in create()
   directly after the provider object is built and before its name is set,
   in update() alongside the existing foreign-calendar preconditions and
   before the first setProperty. A refused call therefore applies none of
   its other properties either, instead of leaving the calendar half
   updated.

   The host is more permissive - setupRefreshTimer arms a timer for any
   positive interval and timerCallback tests canRefresh only when it
   fires - so this is stricter than Thunderbird itself. That is deliberate:
   the API should not accept state the calendar cannot act on. Note that
   canRefresh is true for everything this API is likely to touch, because
   CalCachedCalendar hardcodes it to true to keep the reload button
   working, and the provider in ext-calendar-provider.js sets it too. The
   base implementation in calProviderUtils returns false, so storage and
   memory calendars - which have nothing to refresh from - are what the
   guard actually rejects.

7. canNotify answers for the calendar rather than for the base class.

   `calICalendar.canNotify(method, item)` is how Thunderbird asks whether
   the calendar itself will notify attendees. The inherited implementation
   in cal.provider.BaseClass answers false unconditionally, so a provider
   declaring `scheduling: "server"` - the server sends the invitation for a
   meeting pushed to it, and the reply for one answered through its API -
   was invisible to everything that asks. The event dialog kept drawing the
   client-side "notify attendees" checkbox for a calendar that never sends
   from here.

   Display only: outgoing mail was already suppressed one layer lower,
   where the transport for "server" reports success without sending. So
   what was wrong was what the user was told, not what was sent.

   Recorded late. This landed with the scheduling work (EAS 19221a3,
   TbSync a1e69e9, kept byte-identical by hand) and was never written down
   here, which is why the header said five files when it was already six.

8. adoptItem gives an item a calendar when it arrives without one. Upstream
   reads `item.calendar.superCalendar.id` in convertItem, to tell the
   extension which calendar the item belongs to, and nothing checks that
   `item.calendar` is set.

   Usually it is. The event dialog, calendar.items.create and offline
   playback all assign one before adding. Pasting does not: calendar-
   clipboard.js parses the item straight out of the clipboard's ICS with
   calIIcsParser, clones it, gives it a fresh id and hands it to addItem,
   and no step on that path touches calendar. The parsed item never came
   from a calendar, so there is nothing to inherit.

   The result is that pasting an event into a provider calendar does
   nothing at all, silently. convertItem is evaluated while building the
   arguments for fire.async, so it throws before any listener of ours runs -
   which is why the add-on's own event log stays empty and the reporter saw
   no errors. And pasteFromClipboard calls
   doTransaction without awaiting it, so the rejection is an unhandled
   promise that reaches only the Error Console. Nothing appears in the
   calendar and nothing explains why.

   Fixed in adoptItem rather than in convertItem because that is where the
   fact is known: an item being adopted belongs to the calendar adopting
   it, and every consumer of the emitted item benefits, not just the one
   that happened to dereference it. The item is cloned first when it is
   immutable.

   This is an upstream bug, not a deviation we want: addItem is documented
   to take an item, and other providers - storage, CalDAV - never read
   item.calendar in it. Worth offering upstream ahead of the others.

   Measured on Thunderbird 153.0.3: reported on macOS, reproduced on Linux,
   so it is not platform-specific.

9. parseJcalData fetches the parent it could not find, so editing a single
   occurrence works.

   Upstream leaves this unimplemented:

       if (!parent) {
         throw new ExtensionError("TODO need to retrieve a parent item from storage");
       }

   That branch is not exotic - it is what one occurrence looks like. Asked
   for an item, convertItem serializes only the item it was given
   (`serializer.addItems([item])`), so an edited occurrence arrives as a
   lone vevent carrying a recurrence-id and nothing to attach it to.
   parseJcalData collects it into `exceptions`, never finds a parent, and
   throws.

   What the user sees is nothing at all. The throw happens while
   ext-calendar-provider.js is building the arguments for its listener, so
   modifyItem's catch turns it into a bare NS_ERROR_FAILURE, the event
   dialog closes as though the edit had been saved, and the occurrence is
   unchanged. "Edit all occurrences" works, which makes it look like a
   provider bug (EAS-4-TbSync#354).

   Done the way the TODO says and the way the rest of the file already
   works: the series is the item sharing the exception's uid, so it is read
   with `await calendar.getItem(uid)` - the same call ext-calendar-items.js
   makes in five places whenever it has an id and needs the item. The
   calendar is passed in rather than a parent, because a calendar is
   unambiguous while a parent asks each caller to know which item is the
   right one; a caller that got that wrong would corrupt the series rather
   than fail.

   Reading storage from inside modifyItem does not re-enter the provider:
   ext-calendar-provider.js's own getItem delegates straight to
   `this.offlineStorage`, so the lookup goes to the cache and not back
   through the provider that is asking.

   Only the two paths that modify pass a calendar - items.update and the
   provider's onItemUpdated. The create paths deliberately do not: a
   create carrying only an exception, for a series the calendar already
   holds, would otherwise resolve that series and quietly modify it, so
   `create` would do the job of `update`. Upstream throws there, and it
   still does.

   The cost is that parseJcalData and propsToItem become async. All four
   callers already are.

   The stored item is cloned before use, since merging an exception mutates
   the recurrence info, and the merged parent is returned rather than the
   exception. That is not new: the branch above already returns the parent
   for a vcalendar carrying exceptions, so it is the shape this function
   already promises.

   The throw stays for what nothing can rescue - no such item, or no item
   component at all - but says which of the two happened instead of naming
   a TODO.

   Measured on Microsoft (Exchange 16.1): before, an exception-only update
   is refused with the TODO text; after, it is applied, the stored item is
   the parent with the occurrence merged into it, and a full sync carries
   it to the server and back with no warnings.

   Both routes into the branch were exercised, which needs some care. An
   exception-only vcalendar handed to calendar.items.update reaches it in
   ext-calendar-items.js and never gets as far as the provider. To reach
   the copy in ext-calendar-provider.js - the one the event dialog uses -
   send a *bare* vevent carrying a recurrence-id instead: parseJcalData's
   first branch returns that as an occurrence, so modifyItem is handed one,
   convertItem then serialises it alone, and the provider's own listener
   parses the result. That second route only runs when the extension
   returns a typed item from the listener, so the series has to be synced
   first: with no stamps to restore, this add-on's hook returns the item
   unchanged and the API skips propsToItem entirely.

   Upstream's own gap rather than a deviation we want, like item 8, and
   worth offering there.

Verified against Thunderbird 153.0.2 (esr153), 154.0 (beta) and 155.0a1:
every other member these files touch - defaultTimezone, timezoneIds and
calITimezoneDatabase.getTimezoneDefinition - is present unchanged in all
three, so startup() and wrappedJSObject on the timezone service are the only
version-dependent surfaces.

Status
------

None of these are upstream and never have been. Checked across the whole
repository history: b7f7cb3e is the tip of main and the nearest revision to
this copy by a wide margin, `master` is a stale branch 54 commits behind,
there are no tags, and cloneInto/cloneScope appear nowhere in that
directory's history on any branch, nor in any open pull request. Item 6 was
written here rather than upstream for the same reason, and is meant to be
offered there once it has run against real accounts.

They are worth submitting upstream. Once accepted, re-vendor at the new
commit, drop the local changes, and delete this file.

Re-vendoring
------------

    git clone https://github.com/thunderbird/webext-experiments
    git -C webext-experiments show <commit>:calendar/experiments/calendar/<file>

Then re-apply the diff above. Note that ../../../.prettierignore excludes
this folder: it must stay byte-identical to upstream apart from the changes
listed here, and running a formatter over it would make that impossible to
verify.
