Stalwart 1.0: load calendars, and keep settings and Files working #67

Merged
jcoffey-dev merged 1 commits from fix/stalwart-1-0-calendar-files into main 2026-10-30 20:21:08 +00:00
7 changed files with 425 additions and 7 deletions

No files matched your search

+6
View File
@@ -48,6 +48,12 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
0.15 was removed on 2026-08-26; the last release that runs on it is tagged
[`stalwart-0.15-support`](https://git.coffeylabs.org/coffey-labs/ihasmail/releases/tag/stalwart-0.15-support).
- **Stalwart 1.0 renamed FileNode's `nodeType` and `type`, and refuses `recurrenceOverrides` beside `utcStart`.** Both **confirmed against real 0.16.27 and 1.0.0 servers (2026-10-30)**, from containers of the images CI pins, and both reached a reader as [#66](https://git.coffeylabs.org/coffey-labs/ihasmail/issues/66): calendars would not load, and settings reset at every sign-in.
- **Calendar.** A `CalendarEvent/get` naming `recurrenceOverrides` together with `utcStart` or `utcEnd` fails whole, *"recurrenceOverrides cannot be requested together with utcStart or utcEnd"*, even with `ids: []`. 0.16.27 answers it. The everyday property list no longer asks for the overrides (an occurrence's are null anyway), and the export, which needs them, asks without the times.
- **Files.** 1.0 wants `"@type": "Directory"` or `"File"` where 0.16 had `nodeType`, and `mediaType` where it had `type`. Each server refuses the other's names on create and update and ignores them on get, so ihasmail could make no folder on 1.0: not even its own, which is where the settings file lives. 1.0 also accepts `filter: { nodeType }` and answers it with no ids. The client asks which shape a server wants once per session (a query sorted by `type`, which 1.0 refuses as `unsupportedSort`) and translates at one place, `jmap/fileNodeShape.ts`.
- **Not yet handled:** 1.0 returns `myRights` and `shareWith` for folders only. A file read from 1.0 carries no rights, so rename, delete and editing are offered as unavailable on files there.
- **Recurrence, for the record.** Both versions take the singular `recurrenceRule` the editor writes. 0.16.27 refuses the RFC 8984 list `recurrenceRules`; 1.0 accepts it and drops it, leaving one occurrence.
- **`ContactCard/changes` works, and a download honors one byte range but does not say so.** Both **confirmed live (0.16.22, 2026-09-16)**, with objects on a throwaway account that were removed afterwards. `ContactCard/changes` reports a create, an update and a destroy exactly, nets a card created and destroyed since the given state out to nothing, and answers a state it does not recognize with `invalidArguments` rather than `cannotCalculateChanges`; the contacts store syncs from it and falls back to a full reload on any error. The download endpoint answers a single range (`bytes=0-9`, `bytes=-5`, `bytes=995-`) with `206` and a correct `Content-Range`, and anything else (several ranges, or a range past the end) with the whole file and `200`, never `416`. It sends no `Accept-Ranges`, so ihasmail's proxy advertises it: Chrome's PDF viewer reads a file in pieces only when told it can. The mock answers the same way.
- **Push subscriptions are not replaced by a repeated `deviceClientId`, and an account holds fifteen.** ihasmail registered a new subscription on every renewal believing the old one would be replaced, as the mock did. **Confirmed live (0.16.22, 2026-09-16)**: a second create with the same `deviceClientId` leaves both in place, the sixteenth create is refused with `overQuota`, "There are too many subscriptions, please delete some before adding a new one.", and `update` of `expires` is accepted. `PushSubscription/get` does not return `url` (nor `keys`), so a subscription can only be matched by its `deviceClientId`. A `types` of `[]` or `null` is stored as *every* type, not none. Read from the 0.16.22 source: `EmailDelivery` changes only on delivery, a delivery reaches a subscription with an `emailPush` filter as an EmailPush alone, and the payload carries `id` and `threadId` only when they are named in `properties`. Browsers now subscribe to `EmailDelivery` only, extend rather than re-create, clear their own duplicates and make room on `overQuota`; the server removes what its previous process registered. The mock follows all of it ([#375](https://git.coffeylabs.org/coffey-labs/ihasmail-github-archive/issues/375)).
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { argsToServer, resultFromServer, shapeFromProbe } from "@/jmap/fileNodeShape";
import type { JmapSession } from "@/jmap/types";
/**
* Stalwart 1.0 renamed FileNode's `nodeType` and `type` and refuses the old
* names, so nothing the client wrote for 0.16 could make a folder or a file
* there and the settings file was never written (Gitea issue #66). The shapes
* below are what real 0.16.27 and 1.0.0 servers sent on 2026-10-30; the
* server compatibility test sends the same calls to both.
*/
describe("the probe", () => {
it("reads 1.0's refusal of a sort by `type`", () => {
expect(shapeFromProbe(["error", { type: "unsupportedSort", description: "type" }, "shape"])).toBe("1.0");
});
it("reads 0.16's answer", () => {
expect(shapeFromProbe(["FileNode/query", { ids: [] }, "shape"])).toBe("0.16");
});
it("decides nothing on any other answer", () => {
expect(shapeFromProbe(["error", { type: "forbidden" }, "shape"])).toBeNull();
expect(shapeFromProbe(["error", { type: "accountNotFound" }, "shape"])).toBeNull();
expect(shapeFromProbe(undefined)).toBeNull();
});
});
describe("calls to a 1.0 server", () => {
it("creates a folder and a file in 1.0's names", () => {
const args = argsToServer("1.0", "FileNode/set", {
accountId: "a",
create: {
d: { parentId: null, name: "ihasmail", nodeType: "directory" },
f: { parentId: "#d", name: "settings.json", blobId: "B", type: "application/json", nodeType: "file" },
},
});
expect(args.create).toEqual({
d: { parentId: null, name: "ihasmail", "@type": "Directory" },
f: { parentId: "#d", name: "settings.json", blobId: "B", mediaType: "application/json", "@type": "File" },
});
});
it("renames `type` in an update", () => {
const args = argsToServer("1.0", "FileNode/set", { accountId: "a", update: { n1: { blobId: "B", type: "application/json", size: 7 } } });
expect(args.update).toEqual({ n1: { blobId: "B", mediaType: "application/json", size: 7 } });
});
it("asks for the renamed properties", () => {
const args = argsToServer("1.0", "FileNode/get", { accountId: "a", ids: ["n1"], properties: ["id", "nodeType", "type", "name"] });
expect(args.properties).toEqual(["id", "@type", "mediaType", "name"]);
});
it("drops a filter by kind, which 1.0 answers with nothing", () => {
expect(argsToServer("1.0", "FileNode/query", { accountId: "a", filter: { nodeType: "directory" } }).filter).toEqual({});
expect(argsToServer("1.0", "FileNode/query", { accountId: "a", filter: { operator: "AND", conditions: [{ nodeType: "file" }, { parentId: "p" }] } }).filter)
.toEqual({ operator: "AND", conditions: [{}, { parentId: "p" }] });
});
it("sorts by `mediaType` where it was `type`", () => {
const args = argsToServer("1.0", "FileNode/query", { accountId: "a", sort: [{ property: "type", isAscending: true }, { property: "name" }] });
expect(args.sort).toEqual([{ property: "mediaType", isAscending: true }, { property: "name" }]);
});
it("leaves other methods alone", () => {
const args = { accountId: "a", properties: ["type"] };
expect(argsToServer("1.0", "Email/get", args)).toBe(args);
});
});
describe("answers from a 1.0 server", () => {
it("reads nodes back in the client's names", () => {
const res = resultFromServer("1.0", "FileNode/get", {
list: [
{ id: "b", parentId: null, "@type": "Directory", name: "ihasmail" },
{ id: "c", parentId: "b", "@type": "File", name: "settings.json", mediaType: "application/json", blobId: "B" },
],
});
expect(res.list).toEqual([
{ id: "b", parentId: null, nodeType: "directory", name: "ihasmail" },
{ id: "c", parentId: "b", nodeType: "file", name: "settings.json", type: "application/json", blobId: "B" },
]);
});
it("reads what a set created and updated", () => {
const res = resultFromServer("1.0", "FileNode/set", { created: { f: { id: "c", mediaType: "application/octet-stream" } }, updated: { c: null } });
expect(res.created).toEqual({ f: { id: "c", type: "application/octet-stream" } });
expect(res.updated).toEqual({ c: null });
});
});
describe("0.16", () => {
it("is the client's own shape, passed through as is", () => {
const args = { accountId: "a", create: { d: { nodeType: "directory" } }, filter: { nodeType: "directory" } };
expect(argsToServer("0.16", "FileNode/set", args)).toBe(args);
const res = { list: [{ nodeType: "file", type: "text/plain" }] };
expect(resultFromServer("0.16", "FileNode/get", res)).toBe(res);
});
});
describe("the client", () => {
afterEach(() => {
vi.unstubAllGlobals();
client.session = null;
});
it("asks once per session, then sends and reads in 1.0's names", async () => {
client.session = {
capabilities: { [CAP.core]: {}, [CAP.filenode]: {} },
accounts: {},
primaryAccounts: { [CAP.filenode]: "own" },
state: "s1",
} as unknown as JmapSession;
const sent: [string, Record<string, unknown>, string][][] = [];
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const calls = (JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] }).methodCalls;
sent.push(calls);
const methodResponses = calls.map(([m, a, id]) =>
id === "shape"
? ["error", { type: "unsupportedSort" }, id]
: [m, { list: [{ id: "b", "@type": "Directory", name: (a.properties as string[]).join(",") }] }, id],
);
return { ok: true, status: 200, json: async () => ({ methodResponses }) } as Response;
}));
const one = await client.request([["FileNode/get", { accountId: "shared", ids: ["b"], properties: ["id", "nodeType"] }, "g"]], [CAP.core, CAP.filenode]);
await client.request([["FileNode/get", { accountId: "a", ids: ["b"], properties: ["id"] }, "g"]], [CAP.core, CAP.filenode]);
expect(sent.map((calls) => calls.map(([m]) => m))).toEqual([["FileNode/query"], ["FileNode/get"], ["FileNode/get"]]);
// Of the reader's own account, not the shared one asked about.
expect(sent[0]![0]![1].accountId).toBe("own");
expect(sent[1]![0]![1].properties).toEqual(["id", "@type"]);
expect(one.methodResponses[0]![1].list).toEqual([{ id: "b", nodeType: "directory", name: "id,@type" }]);
});
});
+113 -1
View File
@@ -2,8 +2,12 @@
import { beforeAll, describe, expect, it } from "vitest";
import { CAP, STALWART_CAP, usingFor } from "@/jmap/client";
import { BODY_PROPS, FULL_PROPS, LIST_PROPS, MAILBOX_PROPS } from "@/store/mail/props";
import { CALENDAR_PROPS } from "@/store/calendar";
import { CALENDAR_PROPS, EVENT_PROPS, SERIES_PROPS } from "@/store/calendar";
import { ADDRESS_BOOK_PROPS } from "@/store/contacts";
import { argsToServer, resultFromServer, shapeFromProbe, shapeProbe } from "@/jmap/fileNodeShape";
import { APP_FOLDER, folderProps, isAppFolder } from "@/lib/appFolder";
import { directoryCreate, fileCreate, fileNodeProps } from "@/lib/filenode";
import type { FileNode } from "@/jmap/types";
/**
* Runs against a real, empty Stalwart, not a mock. Skipped unless
@@ -186,6 +190,114 @@ describe.skipIf(!BASE)("a real Stalwart server", () => {
await expectAnswered(alice, calls);
});
it("reads events with every property list the calendar asks for", async () => {
// Stalwart 1.0 refuses recurrenceOverrides together with utcStart/utcEnd
// for the whole call, even with no ids (Gitea issue #66), so the empty
// gets matter as much as the ones that find something.
await expectAnswered(alice, [
["CalendarEvent/get", { accountId: aid, ids: [], properties: EVENT_PROPS }, "e"],
["CalendarEvent/get", { accountId: aid, ids: [], properties: SERIES_PROPS }, "s"],
]);
const calendars = (await jmap(alice, [["Calendar/get", { accountId: aid, properties: CALENDAR_PROPS }, "c"]]))[0]![1].list as { id: string }[];
let calendarId = calendars[0]?.id;
if (!calendarId) {
const made = await jmap(alice, [["Calendar/set", { accountId: aid, create: { c: { name: "Compat" } } }, "c"]]);
calendarId = (made[0]![1].created as Record<string, { id: string }>).c!.id;
}
const event = {
"@type": "Event",
uid: crypto.randomUUID(),
calendarIds: { [calendarId]: true },
title: "Compat weekly",
start: "2026-11-02T09:00:00",
timeZone: "Etc/UTC",
duration: "PT1H",
// The singular the editor writes: 0.16 refuses the RFC's list, and 1.0
// takes the list but drops it, leaving one event.
recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", count: 3 },
};
const made = await jmap(alice, [["CalendarEvent/set", { accountId: aid, create: { e: event } }, "e"]]);
const masterId = (made[0]![1].created as Record<string, { id: string }>)?.e?.id;
expect(masterId, JSON.stringify(made[0]![1])).toBeTruthy();
// What the grid does: a window, expanded, read with EVENT_PROPS.
const grid = await jmap(alice, [
["CalendarEvent/query", { accountId: aid, filter: { after: "2026-11-01T00:00:00", before: "2026-11-30T00:00:00" }, timeZone: "Etc/UTC", sort: [{ property: "start", isAscending: true }], expandRecurrences: true, limit: 2000 }, "q"],
["CalendarEvent/get", { accountId: aid, "#ids": { resultOf: "q", name: "CalendarEvent/query", path: "/ids" }, properties: EVENT_PROPS, timeZone: "Etc/UTC" }, "g"],
]);
expect(grid[1]![0], JSON.stringify(grid[1]![1]).slice(0, 300)).toBe("CalendarEvent/get");
const occurrences = grid[1]![1].list as { utcStart?: string }[];
expect(occurrences, JSON.stringify(occurrences.map((o) => o.utcStart))).toHaveLength(3);
expect(occurrences.every((o) => typeof o.utcStart === "string")).toBe(true);
// What the export does: the master, read with SERIES_PROPS.
const series = await jmap(alice, [["CalendarEvent/get", { accountId: aid, ids: [masterId], properties: SERIES_PROPS }, "g"]]);
expect(series[0]![0], JSON.stringify(series[0]![1]).slice(0, 300)).toBe("CalendarEvent/get");
expect((series[0]![1].list as Record<string, unknown>[])[0]!.recurrenceRule).toBeTruthy();
});
it("keeps the settings file the way the app does", async () => {
// The client's own translation, against whichever shape this server wants
// (fileNodeShape.ts). Stalwart 1.0 renamed nodeType and type, and refused
// the old names on every create, so the settings were never written
// (Gitea issue #66).
const shape = shapeFromProbe((await jmap(alice, [shapeProbe(aid) as Call]))[0]);
expect(shape, "the probe decided nothing").not.toBeNull();
const files = async (calls: Call[]): Promise<Response[]> =>
(await jmap(alice, calls.map(([m, a, id]) => [m, argsToServer(shape!, m, a), id] as Call))).map(([m, a, id]) => [m, resultFromServer(shape!, m, a), id] as Response);
const topLevel = async () =>
((await files([
["FileNode/query", { accountId: aid, filter: { isTopLevel: true }, limit: 1000 }, "q"],
["FileNode/get", { accountId: aid, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"],
]))[1]![1].list as FileNode[]);
// ensureFolder: found, or made and then found by the same test.
let folderId = (await topLevel()).find(isAppFolder)?.id;
if (!folderId) {
const made = await files([["FileNode/set", { accountId: aid, create: { d: directoryCreate(null, APP_FOLDER) } }, "s"]]);
expect(made[0]![1].notCreated, JSON.stringify(made[0]![1])).toBeFalsy();
folderId = (await topLevel()).find(isAppFolder)?.id;
}
expect(folderId).toBeTruthy();
// The Files tree asks for folders by kind and sorts them out itself.
const tree = await files([
["FileNode/query", { accountId: aid, filter: { nodeType: "directory" }, sort: [{ property: "name", isAscending: true }], limit: 1000 }, "q"],
["FileNode/get", { accountId: aid, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
]);
expect((tree[1]![1].list as FileNode[]).filter((n) => n.nodeType === "directory").map((n) => n.id)).toContain(folderId);
// writeSettings: upload, then create the file or update the one there.
const upload = async (text: string) => {
const res = await fetch(`${BASE}/jmap/upload/${aid}/`, { method: "POST", headers: { "content-type": "application/json", authorization: auth(alice) }, body: text });
expect(res.ok, `upload: HTTP ${res.status}`).toBe(true);
return ((await res.json()) as { blobId: string }).blobId;
};
const inFolder = async () =>
((await files([
["FileNode/query", { accountId: aid, filter: { parentId: folderId }, limit: 1000 }, "q"],
["FileNode/get", { accountId: aid, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: ["id", "name", "parentId", "blobId", "size", "type", "nodeType"] }, "g"],
]))[1]![1].list as FileNode[]).find((n) => n.name === "settings.json");
const first = '{"theme":"dark"}';
let existing = await inFolder();
if (!existing) {
const made = await files([["FileNode/set", { accountId: aid, create: { s: fileCreate(folderId!, "settings.json", await upload(first), "application/json") } }, "s"]]);
expect(made[0]![1].notCreated, JSON.stringify(made[0]![1])).toBeFalsy();
existing = await inFolder();
}
expect(existing).toMatchObject({ nodeType: "file", type: "application/json" });
const second = '{"theme":"light"}';
const updated = await files([["FileNode/set", { accountId: aid, update: { [existing!.id]: { blobId: await upload(second), type: "application/json", size: second.length } } }, "s"]]);
expect(updated[0]![1].notUpdated, JSON.stringify(updated[0]![1])).toBeFalsy();
// A stored blob has an id of its own, so the content is what is compared.
const stored = (await inFolder())!.blobId!;
const read = await fetch(`${BASE}/jmap/download/${aid}/${stored}/settings.json?accept=application/json`, { headers: { authorization: auth(alice) } });
expect(await read.text()).toBe(second);
});
it("answers every read the administration pages make", async () => {
const calls: Call[] = [
"x:Account/query",
+38 -1
View File
@@ -1,4 +1,5 @@
import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types";
import { argsToServer, resultFromServer, shapeFromProbe, shapeProbe, type FileNodeShape } from "./fileNodeShape";
import { withBase } from "@/lib/basePath";
import { reauthAtFrontDoor } from "@/lib/frontDoor";
@@ -118,6 +119,8 @@ export class JmapClient {
private stateHandlers = new Set<(sessionState: string) => void>();
/** The last session state announced, so a burst of replies announces it once. */
private announcedState: string | null = null;
/** The FileNode shape this session's server wants, asked once (fileNodeShape.ts). */
private fileShape: { session: JmapSession | null; shape: Promise<FileNodeShape> } | null = null;
get maxCallsInRequest(): number {
const core = this.session?.capabilities[CAP.core] as { maxCallsInRequest?: number } | undefined;
@@ -264,8 +267,42 @@ export class JmapClient {
return using.filter((u) => u === CAP.core || this.hasCapabilityAnywhere(u));
}
/** Low-level request: send invocations verbatim, return raw response. */
/**
* Low-level request: send invocations, return the raw response.
*
* Verbatim, except FileNode calls, which go out and come back through the
* translation in fileNodeShape.ts when the server is 1.0. Done here so that
* every caller keeps the one shape it was written for.
*/
async request(methodCalls: Invocation[], using: string[] = [CAP.core, CAP.mail], createdIds?: Record<string, Id>): Promise<JmapResponse> {
const fileCall = methodCalls.find(([m]) => m.startsWith("FileNode/"));
const accountId = fileCall?.[1].accountId;
if (typeof accountId !== "string") return this.send(methodCalls, using, createdIds);
const shape = await this.fileNodeShape(accountId);
const res = await this.send(methodCalls.map(([m, a, id]) => [m, argsToServer(shape, m, a), id]), using, createdIds);
return { ...res, methodResponses: res.methodResponses.map(([m, a, id]) => [m, resultFromServer(shape, m, a), id]) };
}
/**
* Ask once per session which FileNode shape the server wants, of the
* reader's own Files account where there is one. Until an answer decides
* it, the shape this client was written for, and the question stays open.
*/
private fileNodeShape(accountId: Id): Promise<FileNodeShape> {
if (this.fileShape && this.fileShape.session === this.session) return this.fileShape.shape;
const undecided = (): FileNodeShape => {
this.fileShape = null;
return "0.16";
};
const shape = this.send([shapeProbe(this.primaryAccount(CAP.filenode) ?? accountId)], [CAP.core, CAP.filenode]).then(
(res) => shapeFromProbe(res.methodResponses[0]) ?? undecided(),
undecided,
);
this.fileShape = { session: this.session, shape };
return shape;
}
private async send(methodCalls: Invocation[], using: string[], createdIds?: Record<string, Id>): Promise<JmapResponse> {
const body: Record<string, unknown> = { using: this.supportedUsing(using), methodCalls };
if (createdIds) body.createdIds = createdIds;
const res = await apiFetch<JmapResponse>("/api/jmap", { method: "POST", body: JSON.stringify(body) });
+106
View File
@@ -0,0 +1,106 @@
/**
* FileNode as Stalwart 1.0 spells it, translated to and from the 0.16 shape the
* rest of the client is written against.
*
* 1.0 renamed two properties and refuses the old names outright, so nothing
* written for 0.16 could make a folder or a file there (Gitea issue #66: the
* settings file never got written, so settings reset at every sign-in):
*
* | 0.16 | 1.0 |
* | ---------------------------------- | ----------------------------------- |
* | `nodeType: "directory" \| "file"` | `"@type": "Directory" \| "File"` |
* | `type` (a file's media type) | `mediaType` |
*
* Each server refuses the other's names on a create or an update, and ignores
* them on a get. 1.0 also accepts `filter: { nodeType }` and answers it with no
* ids at all, so that filter is dropped rather than translated: 1.0 offers no
* filter by kind, and the callers that use it sort folders from files
* themselves.
*
* Checked against real 0.16.27 and 1.0.0 servers on 2026-10-30; the server
* compatibility test sends these calls to both.
*
* Nothing in the session says which shape a server wants, so `PROBE` asks: a
* query sorted by `type`, which 0.16 answers and 1.0 refuses as
* `unsupportedSort` because the property is gone. It writes nothing and is
* sent once per session, before the first FileNode call, to the reader's own
* account: a shared one may refuse the reader outright, which says nothing
* about the server. An answer that is neither leaves the question open.
*/
import type { Invocation } from "./types";
export type FileNodeShape = "0.16" | "1.0";
/** The probe, for an account the caller already has. */
export function shapeProbe(accountId: string): Invocation {
return ["FileNode/query", { accountId, sort: [{ property: "type", isAscending: true }], limit: 1 }, "shape"];
}
/** Read the probe's answer: 0.16 answers it, 1.0 refuses the sort, and anything else decides nothing. */
export function shapeFromProbe(response: Invocation | undefined): FileNodeShape | null {
const [name, args] = response ?? [];
if (name === "FileNode/query") return "0.16";
if (name === "error" && (args as { type?: string } | undefined)?.type === "unsupportedSort") return "1.0";
return null;
}
const TO_TYPE: Record<string, string> = { directory: "Directory", file: "File", symlink: "Symlink" };
type Obj = Record<string, unknown>;
/** A node, or a patch to one, in 1.0's names. */
function nodeToServer(node: Obj): Obj {
const { nodeType, type, ...rest } = node;
const out: Obj = { ...rest };
if (typeof nodeType === "string") out["@type"] = TO_TYPE[nodeType] ?? nodeType;
if (type !== undefined) out.mediaType = type;
return out;
}
/** A node from 1.0, in the names the client reads. */
function nodeFromServer(node: unknown): unknown {
if (!node || typeof node !== "object") return node;
const { "@type": kind, mediaType, ...rest } = node as Obj;
const out: Obj = { ...rest };
if (typeof kind === "string") out.nodeType = kind.toLowerCase();
if (mediaType !== undefined) out.type = mediaType;
return out;
}
function mapValues(o: unknown, fn: (v: Obj) => unknown): unknown {
if (!o || typeof o !== "object") return o;
return Object.fromEntries(Object.entries(o as Obj).map(([k, v]) => [k, v && typeof v === "object" ? fn(v as Obj) : v]));
}
/** A filter without `nodeType`, through any operators. */
function filterToServer(filter: unknown): unknown {
if (!filter || typeof filter !== "object") return filter;
const f = filter as Obj;
if (Array.isArray(f.conditions)) return { ...f, conditions: f.conditions.map(filterToServer) };
const { nodeType: _n, ...rest } = f;
return rest;
}
const RENAMED: Record<string, string> = { nodeType: "@type", type: "mediaType" };
/** A call's arguments, as this shape of server expects them. */
export function argsToServer(shape: FileNodeShape, method: string, args: Obj): Obj {
if (shape === "0.16" || !method.startsWith("FileNode/")) return args;
const out: Obj = { ...args };
if (Array.isArray(out.properties)) out.properties = [...new Set((out.properties as string[]).map((p) => RENAMED[p] ?? p))];
if (out.create) out.create = mapValues(out.create, nodeToServer);
if (out.update) out.update = mapValues(out.update, nodeToServer);
if (out.filter) out.filter = filterToServer(out.filter);
if (Array.isArray(out.sort)) out.sort = (out.sort as Obj[]).map((c) => (c.property === "type" ? { ...c, property: "mediaType" } : c));
return out;
}
/** A method's answer, in the names the client reads. */
export function resultFromServer(shape: FileNodeShape, method: string, result: Obj): Obj {
if (shape === "0.16" || !method.startsWith("FileNode/")) return result;
const out: Obj = { ...result };
if (Array.isArray(out.list)) out.list = out.list.map(nodeFromServer);
if (out.created) out.created = mapValues(out.created, (v) => nodeFromServer(v));
if (out.updated) out.updated = mapValues(out.updated, (v) => nodeFromServer(v));
return out;
}
@@ -62,8 +62,13 @@ describe("shared accounts", () => {
it("are asked about files together when Files wants to know", async () => {
await useFiles.getState().discoverShared();
expect(requests).toHaveLength(1);
expect(requests[0]!.map(([n, a]) => `${n} ${a.accountId}`)).toEqual(SHARED.map((id) => `FileNode/query ${id}`));
// The first FileNode call of a session asks the reader's own account which
// shape of FileNode the server wants (jmap/fileNodeShape.ts); the shared
// accounts are still asked in one request after it.
const [probe, ...rest] = requests;
expect(probe!.map(([n, a, id]) => `${n} ${a.accountId} ${id}`)).toEqual(["FileNode/query own shape"]);
expect(rest).toHaveLength(1);
expect(rest[0]!.map(([n, a]) => `${n} ${a.accountId}`)).toEqual(SHARED.map((id) => `FileNode/query ${id}`));
expect(useFiles.getState().sharedAccounts).toEqual([{ id: "s2", name: "[email protected]" }]);
});
+19 -3
View File
@@ -332,15 +332,31 @@ interface CalendarState {
* Explicit property list: when `properties` is null Stalwart omits the JMAP-only
* fields baseEventId / utcStart / utcEnd, and we need baseEventId to update
* recurring instances (synthetic ids can't be patched directly).
*
* `recurrenceOverrides` is left out. Stalwart 1.0 refuses a get that asks for
* it together with `utcStart` or `utcEnd` -- the whole call, with no ids at
* all -- and every read here wants the times: an occurrence is placed on the
* grid by them, and an occurrence's overrides are null anyway. The one read
* that needs the overrides, the export, asks with `SERIES_PROPS` instead.
*/
const EVENT_PROPS = [
export const EVENT_PROPS = [
"id", "baseEventId", "calendarIds", "isDraft", "isOrigin", "utcStart", "utcEnd", "useDefaultAlerts", "mayInviteSelf", "mayInviteOthers", "hideAttendees",
"uid", "relatedTo", "prodId", "created", "updated", "sequence", "title", "description", "descriptionContentType", "showWithoutTime",
"locations", "virtualLocations", "links", "locale", "keywords", "categories", "color", "recurrenceId", "recurrenceIdTimeZone",
"recurrenceRules", "recurrenceRule", "excludedRecurrenceRules", "recurrenceOverrides", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "organizerCalendarAddress",
"recurrenceRules", "recurrenceRule", "excludedRecurrenceRules", "excluded", "priority", "freeBusyStatus", "privacy", "replyTo", "organizerCalendarAddress",
"sentBy", "participants", "requestStatus", "alerts", "timeZone", "start", "duration", "status",
];
/**
* A stored event whole, for writing it out: its "just this date" edits
* included, its server-computed times not. A master's own times are in
* `start`, `duration` and `timeZone`, which is all an export writes.
*/
export const SERIES_PROPS = [
...EVENT_PROPS.filter((p) => p !== "utcStart" && p !== "utcEnd"),
"recurrenceOverrides",
];
/**
* An event as it arrived, minus everything that belonged to where it came from.
*
@@ -1029,7 +1045,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
*/
async exportIcs(calendarId) {
const accountId = get().accountId!;
const events = await eventsInCalendar(accountId, calendarId, EVENT_PROPS);
const events = await eventsInCalendar(accountId, calendarId, SERIES_PROPS);
if (!events.length) throw new Error("there is nothing in it to export");
return { text: toIcs(events, get().calendars[calendarId]?.name), count: events.length };
},