From 8d90bca6b419f24161714c28d65929f291605f4e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 24 Aug 2026 10:05:08 -0700 Subject: [PATCH] List Files through get, because query cannot see a folder before 0.16 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a folder on the live 0.15.5 server did nothing visible, with no error and nothing after a reload. The folder was real all along: FileNode/query masks its results with document_ids(false) — resources that are *not* containers — so it returns files and never folders, and says nothing about the omission. FileNode/get carries no such mask, so on those servers the whole tree comes from a single get with ids:null instead. That also stops ensureFolder making a fresh "ihasmail" folder on every signature save, having never been able to find the one already there. --- web/src/lib/__tests__/filenode.test.ts | 20 ++++++++++++++- web/src/lib/filenode.ts | 13 ++++++++++ web/src/lib/signatureImages.ts | 35 +++++++++++++++----------- web/src/store/files.ts | 33 ++++++++++++++---------- 4 files changed, 73 insertions(+), 28 deletions(-) diff --git a/web/src/lib/__tests__/filenode.test.ts b/web/src/lib/__tests__/filenode.test.ts index c932c94..14dd61e 100644 --- a/web/src/lib/__tests__/filenode.test.ts +++ b/web/src/lib/__tests__/filenode.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { client } from "@/jmap/client"; -import { directoryCreate, fileCreate, fileNodeProps, supportsNodeType, normalizeFileNodes } from "../filenode"; +import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "../filenode"; import type { FileNode, JmapSession } from "@/jmap/types"; /** @@ -117,3 +117,21 @@ describe("rights on a pre-0.16 server", () => { expect(node!.nodeType).toBe("directory"); }); }); + +/** + * Before 0.16, FileNode/query masks its results with `document_ids(false)` — + * only resources that are *not* containers. It therefore returns files and + * never folders, with no error to explain the omission: a folder created there + * exists but never comes back in a listing. FileNode/get carries no such mask. + */ +describe("directory-blind query", () => { + it("is worked around on older servers", () => { + client.session = session(OLD_SERVER); + expect(queryOmitsDirectories()).toBe(true); + }); + + it("is not worked around where query can see folders", () => { + client.session = session(NEW_SERVER); + expect(queryOmitsDirectories()).toBe(false); + }); +}); diff --git a/web/src/lib/filenode.ts b/web/src/lib/filenode.ts index a4270a3..fdcd40e 100644 --- a/web/src/lib/filenode.ts +++ b/web/src/lib/filenode.ts @@ -24,6 +24,19 @@ export function supportsNodeType(): boolean { const BASE_PROPS = ["id", "parentId", "blobId", "size", "name", "type", "created", "modified", "myRights", "role", "executable"]; +/** + * Whether `FileNode/query` is blind to directories. + * + * Before 0.16 the query masks its results with `document_ids(false)`, which + * keeps only resources that are *not* containers — so it returns files and + * never folders, with no error to say so. A folder created there is real, and + * simply never comes back in a listing. `FileNode/get` has no such mask, so + * asking it for every id is the only way to see the whole tree. + */ +export function queryOmitsDirectories(): boolean { + return !supportsNodeType(); +} + /** Properties to request, asking for `nodeType` only where it exists. */ export function fileNodeProps(): string[] { return supportsNodeType() ? [...BASE_PROPS, "nodeType"] : BASE_PROPS; diff --git a/web/src/lib/signatureImages.ts b/web/src/lib/signatureImages.ts index 52d4d8c..5eeece6 100644 --- a/web/src/lib/signatureImages.ts +++ b/web/src/lib/signatureImages.ts @@ -6,7 +6,7 @@ */ import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; -import { directoryCreate, fileCreate, supportsNodeType, normalizeFileNodes } from "@/lib/filenode"; +import { directoryCreate, fileCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode"; import { useSession } from "@/store/session"; import { toast } from "@/ui/toast"; @@ -17,19 +17,26 @@ const folderProps = () => (supportsNodeType() ? ["id", "name", "nodeType", "pare async function ensureFolder(accountId: string): Promise { let list: FileNode[] = []; - try { - const res = await client.chain([ - ["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], - ]); - list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); - } catch { - // Older servers: no filter support — scan everything. - const res = await client.chain([ - ["FileNode/query", { accountId, limit: 1000 }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], - ]); - list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); + if (queryOmitsDirectories()) { + // Query cannot see a directory on these servers, so it would never find the + // folder and we would make a fresh one on every save. Ask get for the lot. + const res = await client.call>("FileNode/get", { accountId, ids: null, properties: folderProps() }); + list = normalizeFileNodes(res.list); + } else { + try { + const res = await client.chain([ + ["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], + ]); + list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); + } catch { + // Filters unsupported: scan everything and pick it out here. + const res = await client.chain([ + ["FileNode/query", { accountId, limit: 1000 }, "q"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"], + ]); + list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); + } } const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId); if (existing) return existing.id; diff --git a/web/src/store/files.ts b/web/src/store/files.ts index df6821d..cb8f7f7 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client"; -import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes } from "@/lib/filenode"; +import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories } from "@/lib/filenode"; import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types"; import { useSession } from "./session"; @@ -33,17 +33,24 @@ const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name /** Fetch all nodes (paged, no filter) and rebuild the full children map. */ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial) => void): Promise { const all: FileNode[] = []; - let position = 0; - for (let guard = 0; guard < 100; guard++) { - const res = await client.chain([ - ["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"], - ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"], - ]); - const q = res.get("q")?.[0] as unknown as QueryResponse; - const g = res.get("g")?.[0] as unknown as GetResponse; - all.push(...normalizeFileNodes(g.list)); - position += q.ids.length; - if (!q.ids.length || (q.total != null && position >= q.total)) break; + if (queryOmitsDirectories()) { + // Query would hand back files only, so every folder — including one just + // created — would be missing with nothing to say why. Ask get for the lot. + const res = await client.call>("FileNode/get", { accountId, ids: null, properties: fileNodeProps() }); + all.push(...normalizeFileNodes(res.list)); + } else { + let position = 0; + for (let guard = 0; guard < 100; guard++) { + const res = await client.chain([ + ["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"], + ["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"], + ]); + const q = res.get("q")?.[0] as unknown as QueryResponse; + const g = res.get("g")?.[0] as unknown as GetResponse; + all.push(...normalizeFileNodes(g.list)); + position += q.ids.length; + if (!q.ids.length || (q.total != null && position >= q.total)) break; + } } const nodes: Record = {}; const children: Record = { root: [] }; @@ -77,7 +84,7 @@ export const useFiles = create((set, get) => ({ if (!accountId) return; set({ loading: true }); try { - if (!filtersSupported) { + if (!filtersSupported || queryOmitsDirectories()) { await loadAllNodes(accountId, set); return; }