17d98748c466ee5fb1304519af03d685fa0be605
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1a842d8d14 |
Build the two rule descriptions as sentences, not fragments
Both describeRule functions assembled their output by concatenation, which no catalogue could fix. A translator handed " and " or " on " in isolation cannot move it: German puts the verb last, Japanese does not separate list items with a word at all, and the fragments arrive in an order the English sentence chose. Reported by a native speaker reviewing the German catalogue (#247), whose "the summaries" item is the Sieve one. Every branch is now one whole sentence with placeholders, so a translator rewrites the sentence including its word order. Joining is Intl.ListFormat, which gives "A, B und C" for an allof rule and the language's own disjunction for anyof, rather than a hardcoded " and " that would be wrong twice over. The recurrence tail no longer appends: ", 5 times" and ", until 2026-05-03" wrap the sentence they qualify, so a language that puts the limit first can. Ordinals become words. The old suffix table -- st, nd, rd, th, picked by arithmetic -- is English spelling rules in code, and no catalogue can reach a suffix chosen that way. German writes "1.", Japanese "第1". nthOfPeriod is 1-5 or -1 in practice, so five words and "last" cover it. WEEKDAYS is gone. Its long names could have been catalogue entries but its short ones never could: "T" is Tuesday and Thursday, "S" is Saturday and Sunday, and a catalogue cannot hold two translations under one key. That was bad data rather than missing translation, and Intl has every name in every locale in three widths. lib/datetime.ts gains weekdayName, weekdayNames and formatList; recurrence.ts keeps WEEKDAY_KEYS for the ordering, which is not a language question. Adds the first tests either function has had. Neither had any, and no test would have caught what was wrong with them, since the English output was correct -- so these pin the two properties that actually matter: fragments go through the catalogue, and the joining is Intl's. 32 strings and 9 plural forms are new and land with each language. Verified: typecheck clean, 1009 tests pass. |
||
|
|
e1402e472f |
Schedule against the whole guest list, and say who cannot be read
The availability bar showed the guests it had free/busy for and quietly left out the ones it did not. In one row that is survivable. As a grid it would be a lie: a row with nothing in it reads as a diary with nothing in it, and "we cannot see this person's calendar" is the one thing that must not look like "this person is free". So everyone the event concerns now gets a row, and the ones with no free/busy to read are drawn hatched rather than empty, with a line under the grid saying how many and why. You get a row too, first. Scheduling around the other people and not around yourself is how two things end up at the same time, and the organiser was the one calendar the panel never showed. Your own row is never unknown. Where the directory does not list you under the address your identity sends from -- an alias, a login that differs from the address -- the account is still yours to read, and Stalwart answers for it under the account's own id. The window steps backwards and forwards a screenful at a time without touching the event, which is the "movable forwards & backwards" the report asks for, and offers its way back when you have wandered off. And the bars are somewhere to put the event rather than only something to read: the pointer shows the half hour it is over, and a click moves the event there keeping its length. Clicking while stepped away moves the event to where you clicked and returns the view to it, so it lands where you were looking instead of jumping. Free/busy is answered per principal, and only the server's own accounts are principals. Somebody at another domain has none to read -- which is not a gap to be closed, it is what the protocol can see -- so the grid says so rather than drawing them blank. Closes #172 |
||
|
|
c4649e0084 |
Say what the availability bar is showing, and show all of it
The bar was a day wide whatever it was drawing. It began at midnight on the event's start day and stopped 24 hours later, so an event running over two days showed availability for the first of them and gave no sign there was more. And it carried no marks at all, which left "is this the whole day or only working hours" unanswerable without dragging the event about to see where its own outline moved. It now covers whole days from the day the event starts to the day it ends, and the free/busy lookup asks for the same range it draws. Above the bars is an axis: hours every three across a single day, every six across two, day names beyond that. The marks are drawn down the bars too, so a busy block can be read against the hour it starts at rather than guessed at. Whole days, always. A bar starting at the event's own start time would move under the reader every time they adjusted it, and "busy from about a third of the way along" is not a time anybody can read. A week is as far as it goes. Something running longer is not an event anybody is hunting a free slot in, and a month at eight pixels a day would say nothing; it says how many days it left out instead. The span is measured between two real midnights rather than counted in 24-hour days, because twice a year they differ, and every position on the bar is a fraction of it. The mock answered with a single busy block on the first day whatever range it was asked for -- all a day-wide bar could show -- which would have left a multi-day bar looking like everyone was free from the second day on. It now answers across the range. This is parts 1 and 2 of #172. The separate multi-day scheduling view it also asks for is still open, and needs an answer first on what to show for participants who have no free/busy to read. |
||
|
|
b862f61cde |
Invite the people the message was already between
Follow-up to #167: an event made from a mail now opens with the sender and everyone it was addressed to already in the guest list, so a thread becomes a meeting without retyping the room. Two things are deliberately left out. The reader's own addresses, since they are the organiser and an organiser among their own guests is an invitation to your own appointment. And a blind copy, on a message the reader sent: a guest list is visible to every guest, so promoting a Bcc to a guest would tell the room about a copy the sender chose to hide. That is not something a menu item may do quietly. *Send invitation emails to guests* now starts off when the guests were inherited rather than typed, and on everywhere else -- which is every event whose guests somebody chose one at a time. The reason is the case the issue opened with: a reminder made out of a bill carries the biller and everyone else on the mail. Left on, the primary button reads Send invites and the first press mails all of them an invitation to what was meant as a note to self. The switch sits right there under the list and says what it does, so inviting them is one deliberate click. Un-sending is not. |
||
|
|
7ba749148f |
Make an event out of a message
Asked for in #167: a right-click on a mail that turns it into a calendar entry, the way a bill or a task becomes a reminder. Nothing clever, and deliberately so -- the subject becomes the title, the body becomes the description, and the reader supplies the one thing the message cannot. A due date is exactly that thing. "Due on the 14th" in an invoice is not a date a parser could be trusted with, and a wrong guess quietly scheduled is worse than no guess at all, so the editor opens on the next half hour for an hour and the reader fixes it. Forward rather than now, because a start time that has already passed by the time they press Create is one more thing to correct. The body is capped at 5000 characters. A newsletter is a message too, and its whole body would be stored on the event, synced to every device, and shown in a three-row textarea; what is worth keeping -- the amount, the account, the address -- is near the top. The cut is marked, so a truncated bill is not read as the whole of it. One message only. The list menu acts on the selection everywhere else, but there is no sensible event to make out of five mails, and the mobile entry appears only when exactly one row is held. The editor lives inside CalendarView and the reader is in the mail view when they ask, so the draft waits in the calendar store until that view mounts and takes it -- once, or it would reopen on every later visit. It seeds a form rather than an event: the dialog still says New event and still has to be pressed. Called *Create event…* rather than "appointment", which is the word the issue used: it opens the New event dialog, and each catalogue already has its own settled noun for that -- Termin, événement, 日程. Reachable three ways, since a phone has no right-click: the row context menu, a message's ⋮, and the ⋮ of a held row on mobile. Hidden entirely where the account has no calendar. |
||
|
|
a6863e98cc |
Second extraction pass: the strings the codemod could not see
`i18n:coverage` reported 100% while a hundred-odd strings rendered
English in every language. It was not wrong about what it measured: it
reads JSX text, and none of these were JSX text. They were toast
arguments, `confirmDialog({ title, confirmLabel })` props, `title=` and
`aria-label=` attributes, and template literals — every one built from an
expression the codemod cannot read.
176 source strings and 15 plural sets now go through t() and plural(),
translated into all nine languages. Where English put a word in a slot,
the sentence is spelled out per branch instead: `Filter ${verb}` became
"Filter saved" and "Filter created", because which word agrees with what,
and where it sits, is not a property English gets to decide for everyone.
Counts that were `${n} message${n === 1 ? "" : "s"}` are plural() calls,
so Russian and Ukrainian get three forms and Japanese and Chinese get the
one they actually have.
Two of the catalogue's own conventions were worth learning the hard way.
Plural entries are keyed on the English *other* form, not `one` — `one`
is a form English happens to have and Japanese does not. And a constant
table holding English that is translated at the render site is fine: the
literal is a key, not a leak.
Which is what the new check encodes. `scripts/i18n-literals.mjs` accepts
a string that is wrapped where it is written or is a catalogue key
somewhere, and refuses one that is neither — a string no catalogue can
translate, however many languages ship. It found twenty more than my own
sweep had, including the stale-folder toast seen in production. It runs
as part of `npm run i18n:check`.
Also fixed: the catalogue is now awaited before the first paint. The
tree is rebuilt when a catalogue lands, so components recover on their
own, but a string computed in an effect does not — a toast fired in that
gap is emitted in English and stays English. The wait costs nothing
visible, since the session bootstrap already shows a spinner and English
resolves immediately.
And the Japanese agenda title loses a space Japanese does not use:
"{date} からの予定" was written with the English habit of spacing around
a placeholder.
|
||
|
|
3f4b33cb51 |
Finish extraction: 100%, and a coverage number worth believing
The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.
A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.
The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.
Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.
The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.
596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
|
||
|
|
8ea611f7f7 |
Extract 515 strings by codemod, and the two bugs only a screenshot caught
Wrapping ~1,000 strings by hand is a thousand chances to mistype the copy
itself, and a parser does not get bored. scripts/i18n-extract.mjs does the
mechanical part -- JSX text and the attributes a person actually reads -- and
refuses the rest rather than guessing. 78% now: 515 wrapped, 143 left.
What it refuses matters as much as what it does. Text split around an
interpolation arrives as separate fragments, and wrapping each on its own
produces "Move " and " messages", which no translator can do anything with;
those are listed for a person to rebuild as sentences. So is anything
containing a double quote, which would end the literal.
Three things it had to be taught, each found by running it:
- <code>, <kbd> and <pre> are not prose. The first run wrapped `label:name`
inside <code> -- a search operator, where translating it breaks the thing it
documents. Subtrees marked translate="no" are skipped for the same reason.
- `t` is a natural name for a callback parameter and several files already use
it, so an import called `t` is shadowed inside those callbacks -- silently,
wherever the local happens to be callable. The name is checked per file now
and aliased to `translate` where it is taken.
- JSX decodes HTML entities and a JS string literal does not, so
`Language & region` moved into t("...") and rendered the entity on screen.
That last one is the one worth remembering. Typecheck passed, 443 tests
passed, and the page said "Language & region" in plain sight. It took
looking at a screenshot, and then a sweep of ten views to find the second
occurrence in a sentence I had written by hand earlier the same day. Nothing
in the toolchain was ever going to catch it: it is valid TypeScript rendering
valid text that happens to be wrong.
The codemod decodes entities now, and checks for a quote after decoding rather
than before.
|
||
|
|
dd8998f178 |
Calendar: edit and delete a single occurrence
Closes #132. Stalwart 0.16.20 accepts a synthetic id on `CalendarEvent/set`, writing a `recurrenceOverrides` entry rather than touching the series, so editing one date of a recurring event is now something the server does and this does too. Editing asks the scope *before* the form opens, because it decides which event the form is even about: a form populated from the master shows the series' start date, so editing Wednesday's standup would have offered to move Monday's. Deleting asks in place of the old confirm. The patch is narrowed rather than posted hopefully. 0.16.20 sorts per-occurrence properties into three groups and only one is honest: ten are refused with `invalidProperties`, twelve more are dropped from the patch while the response still reports success, and the rest are applied. That silent middle group is how #26 reached a live server - a successful response is not evidence anything was written - so `occurrencePatch` throws on the first group, reports the second to the caller, and the editor leaves out the five it always sends. A patch that would be entirely dropped is not sent at all. The refusal for an occurrence of a this-and-future change offers the series instead of a bare error toast. Nothing here writes one of those, but an event synced from another client can carry one. Two things the scope prompt cost, both worth knowing. A dialog is queued in a store the moment it is asked for, so it outlives the effect that asked: without a ref guard a remount queues a second prompt the first answer cannot retract. And gating the *answer* on the effect's cleanup flag is worse - StrictMode runs mount, cleanup, mount, so the flag is already set by the time anyone clicks and the editor never opens. The mock expands recurrences for the first time, which is what makes any of this developable. It hands out synthetic ids for everything including one-offs, gives occurrences a `recurrenceId` and no rule, and reproduces the refusals - including the silent drops, since a mock that applied them would let a client that sends them look correct everywhere but a real server. |
||
|
|
373822c2ae |
Resolve the base event id in the calendar store, not at the call sites
Closes #133. `updateEvent`, `destroyEvent` and `rsvp` took an id and sent it. The `baseEventId ?? id` that made them hit the series lived at four call sites instead, and every one of them happened to be right. That was backstopped by the server until now. Through 0.16.19 a synthetic id reaching `destroy` came back as "Deleting synthetic ids is not yet supported" and the user saw a toast. 0.16.20 accepts it and removes one date instead, reporting success under a dialog that said "Delete all occurrences?" - so a forgotten `??` became silent data loss rather than an error. The three methods now take the event and a required `scope`, and there is exactly one place that turns an event into an id. A caller that wants the series cannot get an occurrence by forgetting anything; a caller that wants one occurrence has to say so. `rsvp` takes the event rather than an id for the same reason, and no longer looks it up: its patch is `participants/{key}/participationStatus`, which is one of the pointers 0.16.20 *allows* on an occurrence, so aimed at an instance it would quietly mean "only that day". `findByUid` says in a comment that its query omits `expandRecurrences` on purpose, since InviteCard hands the result straight to `destroyEvent`. |
||
|
|
c1ef19849e |
Say it in the words Stalwart 0.16 answers to
Guests added to an event vanished on save and no invitation was ever sent. Not a guard in the editor, and nothing the server complained about: ihasmail addresses a participant the way RFC 8984 does, with sendTo and email, and Stalwart 0.16 keeps that address under calendarAddress. Handed the RFC's spelling it stores the event, drops the entire participant map, and reports success. Six shapes were tried against a live 0.16.19, down to sendTo and roles alone; all six were dropped, and patching a participant onto an existing event fails outright with "Patch operation failed". The same disagreement runs through two more properties. The organizer is organizerCalendarAddress, not replyTo. A recurrence is a single recurrenceRule, not a recurrenceRules array — and that one Stalwart refuses honestly, with invalidProperties, so no recurring event could be created at all and existing ones showed no repeat. So writes now use Stalwart's names and reads accept either, since a mailbox may hold events written by other clients. The mock now refuses what the real server refuses and drops what it drops: advertising the RFC spelling is exactly how this reached a live server unnoticed, the same way the capability-placement bug did. Verified against 0.16.19: participants, organizer and rule all survive a create, an update and a re-read, with the roles kept as sent. Fixes #26 Fixes #30 |
||
|
|
8b22ea9aab |
Stop calling every event a series
A one-time event opened for editing said "this is a recurring event — changes apply to the whole series", and deleting one offered to delete all occurrences of an event that has exactly one. Three places asked whether an event had a baseEventId and took that for recurrence. It isn't: the calendar loads its range with expandRecurrences, and Stalwart puts a baseEventId on everything it returns that way, a one-off pointing at itself included. The mock never sets the field at all, which is why this only showed up against a real server. They now share isRecurring(), which asks about recurrence rules, and treats a base that is some other event as an occurrence of a series too — so an expanded instance that travels without its rules is still described honestly on the way to being deleted. Fixes #25 |
||
|
|
2518605126 |
Custom date and time pickers that follow the configured format
Browsers render <input type="date"> and datetime-local in their own locale and ignore the page's, so #1 left a German user on an English browser reading 22.11.2025 everywhere but still entering dates through an mm/dd/yyyy widget. #3 makes the case that people use the picker rather than typing, which is where the AM/PM mistakes happen. New DateField and DateTimeField (web/src/ui/datefield.tsx) replace all nine native controls — event editor (all-day and timed start/end, recurrence until), out-of-office, contact birthday, advanced search. They take and emit the same ISO strings the native inputs did, so call sites barely changed. Each is a text box in the configured order plus a popover: a month grid (week start from settings, locale weekday and month names, today and the selection marked) and, for date-times, a list of times in the configured clock. Keyboard: arrows move by day, PageUp/PageDown by month, Home/End across the week, Enter picks, Escape closes, ArrowDown opens; the focused day holds DOM focus so screen readers follow, and the dialog has an accessible name (Popover gained an ariaLabel prop). Text entry is lenient — the configured order with any separator, unseparated digits (221125), day and month alone, non-Latin digits, and bare ISO always; times take 18:23, 1823, 6:23pm, 930. What will not parse reverts on blur rather than clearing the field, and impossible dates like 31 February are rejected instead of rolling into March. Editable boxes stay Gregorian and Latin-digit even where display does not (fa-IR, th-TH, ar-EG): the locale's field order and separator are kept, but a Buddhist-era year in a text box cannot round-trip against a Gregorian grid. Noted in the README. The out-of-office format echo added in #2 is gone — the fields now show the right format themselves. Closes #3 |
||
|
|
d82ff15921 |
Configurable date and time formats, defaulting to the Stalwart locale
Every user-visible date now goes through web/src/lib/datetime.ts, driven by three settings (Settings > General > Locale): - Language & region: automatic, or any of the 618 locales CLDR has data for, each named in its own language and script (web/src/lib/locales.ts, generated by probing Intl over the subtag space). - Date format: automatic (locale order), 22.11.2025, 22/11/2025, 11/22/2025, or ISO 8601 2025-11-22. - Time format: automatic (locale), 24-hour, or 12-hour. Automatic takes the locale Stalwart has for the account, read best-effort at login via x:Account/get (urn:stalwart:jmap) and passed to the client in the session; servers without the capability, or that deny sysAccountGet to a regular user, fall back to the browser locale. POSIX forms are normalised (de_DE.UTF-8 -> de-DE) and script modifiers kept (sr_RS@latin -> sr-Latn-RS, uz_UZ@cyrillic -> uz-Cyrl-UZ), while dialect/variant/currency modifiers are dropped and a script the locale already implies is not appended. Numerals follow the locale (22.11.2025 renders as Arabic-Indic digits under ar-EG); ISO 8601 is the exception and pins date and clock to Latin digits so one line never mixes digit systems. Rewired: message list and headers, quoted reply headers, calendar (titles, weekday and hour gutters, mini calendar, agenda, popovers, invite cards, free/busy), contacts, files, sessions. No raw toLocale*String date calls are left in web/src. Native <input type="datetime-local"> pickers always follow the browser locale and cannot be restyled by a page, so the out-of-office fields echo the entered instant in the chosen format underneath. Also: month-grid day labels no longer wrap when they hold a date, and the mock server serves x:Account/get (MOCK_LOCALE, default en_US). Closes #1 |
||
|
|
645b8b510f |
ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI. |