Importing an export twice left second copies of everything. The import has kept the file's own UID since it was written — inventing one only where an event arrives without — so what was needed to recognise an event that is already here was there all along, and nothing looked at it.
Decided by the reporter on #173 after his colleague hit the duplication in testing:
Both of us would prefer duplicate checks on UIDs if UID present in event.
The rule
An event whose UID the calendar already holds is skipped.
An event that arrives without a UID is imported as before. There is nothing to match it on, and a softer match — title and time, say — guesses in both directions.
Matching is per calendar. A UID is what makes an event the same event across calendars, so the same event living in two of them is not a duplicate and the second calendar still gets its copy.
How the UIDs are read
Once per import, not once per event. CalendarEvent/query does take a uid filter — it is what findByUid uses — but a file of two thousand events would be two thousand queries.
The scan runs without expandRecurrences, so a weekly series is one event with one UID rather than one UID per occurrence, and narrows to the target calendar from each event's calendarIds rather than through an inCalendar filter this client has not confirmed the server supports. Paged at maxObjectsInGet.
Counts
importIcs now answers { created, skipped } and the toast keeps them apart. "Imported 40 events" over a file of 240 reads as a failure when 200 were simply already there, and a re-import of an unchanged file would otherwise report importing nothing at all instead of saying everything was already here.
Strings
The three import toasts are now translated in all nine catalogues. Worth flagging as slightly beyond the issue: the plural for the existing "Imported {n} events" had never been added and was falling back to English, so translating only the two new ones would have left the family half-done. Mine, not native-reviewed, like the rest.
Tests
Four new: skipping a UID already in the calendar, importing one whose UID is in a different calendar, still importing an event with no UID, and sending no set at all when the whole file is already present. The existing cases move to the { created, skipped } shape, and the harness now answers the UID scan.
npm run typecheck, npm test (908 web + 122 server), npm run build, i18n:check (no new stale or missing keys) pass.
Not in this PR
Whether a re-imported event whose UID matches should update the existing one rather than be skipped. Re-importing an updated export is a plausible reason to do this at all, but the reporter asked for skip and update is the larger behaviour. Noted on #222.
Closes #222.
Importing an export twice left second copies of everything. The import has kept the file's own UID since it was written — inventing one only where an event arrives without — so what was needed to recognise an event that is already here was there all along, and nothing looked at it.
Decided by the reporter on [#173](https://github.com/Coffey-Labs/ihasmail/issues/173#issuecomment-5510955196) after his colleague hit the duplication in testing:
> Both of us would prefer duplicate checks on UIDs if UID present in event.
## The rule
- An event whose UID the calendar already holds is **skipped**.
- An event that arrives **without** a UID is imported as before. There is nothing to match it on, and a softer match — title and time, say — guesses in both directions.
- Matching is **per calendar**. A UID is what makes an event the same event *across* calendars, so the same event living in two of them is not a duplicate and the second calendar still gets its copy.
## How the UIDs are read
Once per import, not once per event. `CalendarEvent/query` does take a `uid` filter — it is what `findByUid` uses — but a file of two thousand events would be two thousand queries.
The scan runs **without `expandRecurrences`**, so a weekly series is one event with one UID rather than one UID per occurrence, and narrows to the target calendar from each event's `calendarIds` rather than through an `inCalendar` filter this client has not confirmed the server supports. Paged at `maxObjectsInGet`.
## Counts
`importIcs` now answers `{ created, skipped }` and the toast keeps them apart. "Imported 40 events" over a file of 240 reads as a failure when 200 were simply already there, and a re-import of an unchanged file would otherwise report importing nothing at all instead of saying everything was already here.
## Strings
The three import toasts are now translated in all nine catalogues. Worth flagging as slightly beyond the issue: the plural for the *existing* "Imported {n} events" had never been added and was falling back to English, so translating only the two new ones would have left the family half-done. Mine, not native-reviewed, like the rest.
## Tests
Four new: skipping a UID already in the calendar, importing one whose UID is in a *different* calendar, still importing an event with no UID, and sending no `set` at all when the whole file is already present. The existing cases move to the `{ created, skipped }` shape, and the harness now answers the UID scan.
`npm run typecheck`, `npm test` (908 web + 122 server), `npm run build`, `i18n:check` (no new stale or missing keys) pass.
## Not in this PR
Whether a re-imported event whose UID matches should *update* the existing one rather than be skipped. Re-importing an updated export is a plausible reason to do this at all, but the reporter asked for skip and update is the larger behaviour. Noted on #222.
**Merged** 2026-09-02 as coffey-labs/ihasmail@a61fe28523a3
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #222.
Importing an export twice left second copies of everything. The import has kept the file's own UID since it was written — inventing one only where an event arrives without — so what was needed to recognise an event that is already here was there all along, and nothing looked at it.
Decided by the reporter on #173 after his colleague hit the duplication in testing:
The rule
How the UIDs are read
Once per import, not once per event.
CalendarEvent/querydoes take auidfilter — it is whatfindByUiduses — but a file of two thousand events would be two thousand queries.The scan runs without
expandRecurrences, so a weekly series is one event with one UID rather than one UID per occurrence, and narrows to the target calendar from each event'scalendarIdsrather than through aninCalendarfilter this client has not confirmed the server supports. Paged atmaxObjectsInGet.Counts
importIcsnow answers{ created, skipped }and the toast keeps them apart. "Imported 40 events" over a file of 240 reads as a failure when 200 were simply already there, and a re-import of an unchanged file would otherwise report importing nothing at all instead of saying everything was already here.Strings
The three import toasts are now translated in all nine catalogues. Worth flagging as slightly beyond the issue: the plural for the existing "Imported {n} events" had never been added and was falling back to English, so translating only the two new ones would have left the family half-done. Mine, not native-reviewed, like the rest.
Tests
Four new: skipping a UID already in the calendar, importing one whose UID is in a different calendar, still importing an event with no UID, and sending no
setat all when the whole file is already present. The existing cases move to the{ created, skipped }shape, and the harness now answers the UID scan.npm run typecheck,npm test(908 web + 122 server),npm run build,i18n:check(no new stale or missing keys) pass.Not in this PR
Whether a re-imported event whose UID matches should update the existing one rather than be skipped. Re-importing an updated export is a plausible reason to do this at all, but the reporter asked for skip and update is the larger behaviour. Noted on #222.
Merged 2026-09-02 as coffey-labs/ihasmail@a61fe28523
Rebuilt from: git history, session transcript.