mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
feat: save a document edited in its program back into Files, and ask Keep both or Replace when it changed there meanwhile
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// pim-p2-1: a document opened from Files in its program goes back into Files
|
||||
// when the program saves, first choice on the card. If the file changed on
|
||||
// the server meanwhile, the card asks Keep both or Replace.
|
||||
|
||||
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }));
|
||||
const files = vi.hoisted(() => ({ client: null as unknown, refresh: vi.fn() }));
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast }));
|
||||
vi.mock('@/lib/device-policy', () => ({ canDownload: () => true, canExport: () => true }));
|
||||
vi.mock('@/lib/offline/offline-mode', () => ({ scopeDecided: () => true }));
|
||||
vi.mock('@/lib/desktop/mail-links', () => ({ openMailLink: vi.fn() }));
|
||||
vi.mock('@/lib/desktop/mail-notifications', () => ({ contentHiddenFor: () => false }));
|
||||
vi.mock('@/lib/launch-handoff', () => ({ handOffDraft: vi.fn() }));
|
||||
vi.mock('@/lib/popout/open', () => ({ openDraftWindow: vi.fn() }));
|
||||
vi.mock('@/lib/download-guard', () => ({ saveBlobAs: vi.fn(() => true) }));
|
||||
vi.mock('@/lib/org/account-subjects', () => ({ useAccountSubjects: { subscribe: () => () => {} } }));
|
||||
vi.mock('@/stores/policy-store', () => ({ usePolicyStore: { subscribe: () => () => {} } }));
|
||||
vi.mock('@/lib/sign-out-cleanup', () => ({ addSignOutGate: () => () => {}, onBeforeSignOut: () => () => {}, onFullSignOut: () => {} }));
|
||||
vi.mock('@/stores/account-store', () => {
|
||||
const list = [{ id: 'acct', cookieSlot: 2 }];
|
||||
const useAccountStore = (select: (s: { accounts: typeof list }) => unknown) => select({ accounts: list });
|
||||
useAccountStore.getState = () => ({ accounts: list });
|
||||
return { useAccountStore };
|
||||
});
|
||||
vi.mock('@/stores/auth-store', () => {
|
||||
const useAuthStore = (select: (s: { activeAccountId: string | null }) => unknown) => select({ activeAccountId: 'acct' });
|
||||
useAuthStore.getState = () => ({ activeAccountId: 'acct', client: null, getClientForAccount: () => undefined });
|
||||
return { useAuthStore };
|
||||
});
|
||||
vi.mock('@/stores/file-store', () => ({
|
||||
useFileStore: { getState: () => ({ client: files.client, currentAccountId: 'acct', refresh: files.refresh }) },
|
||||
}));
|
||||
|
||||
import { EditedFilesHost } from '../edited-files-host';
|
||||
import { parseEditSource, resetEditSessions, useEditSessions } from '@/lib/desktop/edit-attachments';
|
||||
|
||||
const NODE_SOURCE = JSON.stringify({ kind: 'node', nodeId: 'n1', blobId: 'b-old', parentId: 'p1' });
|
||||
const session = (over: Record<string, unknown> = {}) => ({
|
||||
id: 's1', name: 'budget-2026.xlsx', type: 'x/y', account: 'acct', source: NODE_SOURCE, changed: true, version: 2, hash: 'h2', claimed: false, held: false, savedAs: false, lastChance: false, ...over,
|
||||
});
|
||||
|
||||
let edit: Record<string, ReturnType<typeof vi.fn>>;
|
||||
let client: {
|
||||
getFileNodes: ReturnType<typeof vi.fn>;
|
||||
uploadBlob: ReturnType<typeof vi.fn>;
|
||||
replaceFileNodeBlob: ReturnType<typeof vi.fn>;
|
||||
createFileNode: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function show() {
|
||||
const sessions = [session()];
|
||||
edit.list.mockResolvedValue(sessions);
|
||||
useEditSessions.setState({ sessions });
|
||||
return render(<EditedFilesHost />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
client = {
|
||||
getFileNodes: vi.fn(async () => [{ id: 'n1', blobId: 'b-old', name: 'budget-2026.xlsx', parentId: 'p1' }]),
|
||||
uploadBlob: vi.fn(async () => ({ blobId: 'b-new', size: 1, type: 'x/y' })),
|
||||
replaceFileNodeBlob: vi.fn(async () => {}),
|
||||
createFileNode: vi.fn(async () => ({ id: 'n2', name: 'budget-2026 (2).xlsx', parentId: 'p1' })),
|
||||
};
|
||||
files.client = client;
|
||||
edit = {
|
||||
canEdit: vi.fn(() => true),
|
||||
list: vi.fn(async () => []),
|
||||
read: vi.fn(async () => ({ ok: true, name: 'budget-2026.xlsx', type: 'x/y', data: new Uint8Array([1, 2]), hash: 'h2' })),
|
||||
taken: vi.fn(async () => {}),
|
||||
setSource: vi.fn(),
|
||||
discard: vi.fn(async () => ({ held: [] })),
|
||||
forceDiscard: vi.fn(async () => ({ held: [] })),
|
||||
askClear: vi.fn(async () => true),
|
||||
shown: vi.fn(),
|
||||
onChange: vi.fn(() => () => {}),
|
||||
};
|
||||
(window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, edit, os: { notify: vi.fn(async () => ({})) } };
|
||||
resetEditSessions();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
});
|
||||
|
||||
describe('a file opened from Files remembers its node', () => {
|
||||
it('reads the node source back', () => {
|
||||
expect(parseEditSource(JSON.stringify({ kind: 'node', nodeId: 'acct2:n1', blobId: 'b1', parentId: null })))
|
||||
.toEqual({ kind: 'node', nodeId: 'acct2:n1', blobId: 'b1', parentId: null });
|
||||
// Older sessions keep working.
|
||||
expect(parseEditSource('{"kind":"file"}')).toEqual({ kind: 'file' });
|
||||
expect(parseEditSource(JSON.stringify({ kind: 'node' }))).toEqual({ kind: 'file' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save to Files (pim-p2-1)', () => {
|
||||
it('is the first choice on the card of a file from Files', () => {
|
||||
show();
|
||||
const buttons = screen.getAllByRole('button').filter((b) => b.textContent);
|
||||
expect(buttons[0].textContent).toBe('save_to_files');
|
||||
expect(screen.getByText('new_message')).toBeTruthy();
|
||||
expect(screen.getByText('save_copy')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('puts the new content into the same node when nobody changed it', async () => {
|
||||
show();
|
||||
fireEvent.click(screen.getByText('save_to_files'));
|
||||
await waitFor(() => expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('n1', 'b-new'));
|
||||
expect(client.createFileNode).not.toHaveBeenCalled();
|
||||
expect(edit.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'node', nodeId: 'n1', blobId: 'b-new', parentId: 'p1' }));
|
||||
expect(edit.taken).toHaveBeenCalledWith('s1', 'h2');
|
||||
expect(toast.success).toHaveBeenCalledWith('saved');
|
||||
expect(files.refresh).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks Keep both or Replace when the file changed on the server, and Keep both adds a copy beside it', async () => {
|
||||
client.getFileNodes.mockResolvedValue([{ id: 'n1', blobId: 'b-someone-else', name: 'budget-2026.xlsx', parentId: 'p1' }]);
|
||||
show();
|
||||
fireEvent.click(screen.getByText('save_to_files'));
|
||||
await waitFor(() => expect(screen.getByTestId('edited-file-conflict').textContent).toBe('changed_on_server'));
|
||||
expect(client.replaceFileNodeBlob).not.toHaveBeenCalled();
|
||||
expect(edit.taken).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('replace')).toBeTruthy();
|
||||
fireEvent.click(screen.getByText('keep_both'));
|
||||
await waitFor(() => expect(client.createFileNode).toHaveBeenCalledWith('budget-2026.xlsx', 'b-new', 'x/y', 2, 'p1'));
|
||||
expect(client.replaceFileNodeBlob).not.toHaveBeenCalled();
|
||||
expect(edit.setSource).toHaveBeenCalledWith('s1', JSON.stringify({ kind: 'node', nodeId: 'n2', blobId: 'b-new', parentId: 'p1' }));
|
||||
expect(edit.taken).toHaveBeenCalledWith('s1', 'h2');
|
||||
expect(toast.success).toHaveBeenCalledWith('saved_copy');
|
||||
});
|
||||
|
||||
it('Replace writes over the change', async () => {
|
||||
client.getFileNodes.mockResolvedValue([{ id: 'n1', blobId: 'b-someone-else', name: 'budget-2026.xlsx', parentId: 'p1' }]);
|
||||
show();
|
||||
fireEvent.click(screen.getByText('save_to_files'));
|
||||
fireEvent.click(await screen.findByText('replace'));
|
||||
await waitFor(() => expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('n1', 'b-new'));
|
||||
});
|
||||
|
||||
it('offers a new file when the original is gone', async () => {
|
||||
client.getFileNodes.mockResolvedValue([]);
|
||||
show();
|
||||
fireEvent.click(screen.getByText('save_to_files'));
|
||||
await waitFor(() => expect(screen.getByTestId('edited-file-conflict').textContent).toBe('gone_on_server'));
|
||||
expect(screen.queryByText('replace')).toBeNull();
|
||||
expect(screen.getByText('save_as_new')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { editForbidden, followEditSessions, getEditBridge, parkFilesForReply, parseEditSource, readEditedFile, useEditSessions } from "@/lib/desktop/edit-attachments";
|
||||
import { filesClientFor, refreshFilesFor, saveBackToNode, saveBesideNode, type NodeEditSource } from "@/lib/desktop/save-to-files";
|
||||
import { openMailLink } from "@/lib/desktop/mail-links";
|
||||
import { contentHiddenFor } from "@/lib/desktop/mail-notifications";
|
||||
import type { DesktopEditSession } from "@/lib/desktop/types";
|
||||
@@ -35,7 +36,10 @@ function useSignedInAccounts(): Set<string> {
|
||||
*/
|
||||
export function EditedFilesHost() {
|
||||
const t = useTranslations("desktopEdit");
|
||||
const tFiles = useTranslations("ux_p2_pim_files");
|
||||
const router = useRouter();
|
||||
// A file from Files that changed on the server, or went, since it was opened: what to do is asked on its card.
|
||||
const [conflicts, setConflicts] = useState<Record<string, "changed" | "gone">>({});
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const sessions = useEditSessions((s) => s.sessions);
|
||||
@@ -148,6 +152,42 @@ export function EditedFilesHost() {
|
||||
if (saveBlobAs(file, file.name, { kind: "attachment", subject: session.account })) void getEditBridge()?.taken(session.id, hash);
|
||||
});
|
||||
|
||||
/**
|
||||
* Save to Files (pim-p2-1): back into the node it came from, unless that changed on the
|
||||
* server meanwhile; then the card asks Keep both or Replace. `how`: "check" first,
|
||||
* "replace" over a change, "beside" as a new file next to it.
|
||||
*/
|
||||
const saveToFiles = (session: DesktopEditSession, source: NodeEditSource, how: "check" | "replace" | "beside") => withFile(session, async (file, hash) => {
|
||||
const client = filesClientFor(session.account);
|
||||
const bridge = getEditBridge();
|
||||
if (!client || !bridge) {
|
||||
toast.error(tFiles("save_failed", { name: session.name }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (how === "beside") {
|
||||
const saved = await saveBesideNode(client, source, file);
|
||||
bridge.setSource(session.id, JSON.stringify(saved.source));
|
||||
await bridge.taken(session.id, hash);
|
||||
toast.success(tFiles("saved_copy", { name: saved.name }));
|
||||
} else {
|
||||
const result = await saveBackToNode(client, source, file, how === "replace");
|
||||
if (result.status !== "saved") {
|
||||
setConflicts((all) => ({ ...all, [session.id]: result.status }));
|
||||
return;
|
||||
}
|
||||
bridge.setSource(session.id, JSON.stringify({ ...source, blobId: result.blobId }));
|
||||
await bridge.taken(session.id, hash);
|
||||
toast.success(tFiles("saved", { name: session.name }));
|
||||
}
|
||||
setConflicts(({ [session.id]: _done, ...rest }) => rest);
|
||||
refreshFilesFor(client);
|
||||
} catch (error) {
|
||||
console.error("Could not save an edited file to Files:", error);
|
||||
toast.error(tFiles("save_failed", { name: session.name }));
|
||||
}
|
||||
});
|
||||
|
||||
const accountLabel = (session: DesktopEditSession) => {
|
||||
if (accountList.length < 2) return null;
|
||||
const entry = accountList.find((a) => a.id === session.account);
|
||||
@@ -172,6 +212,8 @@ export function EditedFilesHost() {
|
||||
const source = parseEditSource(session.source);
|
||||
const draftId = source.kind === "draft" && !session.savedAs ? source.draftId : null;
|
||||
const account = accountLabel(session);
|
||||
const node = source.kind === "node" ? source : null;
|
||||
const conflict = node ? conflicts[session.id] : undefined;
|
||||
return (
|
||||
<div key={session.id} data-testid="edited-file" className="flex flex-col gap-2 rounded-lg border border-border bg-background p-3 shadow-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -181,6 +223,11 @@ export function EditedFilesHost() {
|
||||
{account && <p className="truncate text-xs text-muted-foreground" data-testid="edited-file-account">{account}</p>}
|
||||
<p className="text-xs text-muted-foreground">{t("changed_body")}</p>
|
||||
{session.lastChance && <p className="text-xs font-medium text-foreground" data-testid="edited-file-last-chance">{t("last_chance")}</p>}
|
||||
{conflict && (
|
||||
<p className="text-xs font-medium text-foreground" role="alert" data-testid="edited-file-conflict">
|
||||
{conflict === "changed" ? tFiles("changed_on_server", { name: session.name }) : tFiles("gone_on_server", { name: session.name })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -192,7 +239,21 @@ export function EditedFilesHost() {
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{node && conflict ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={busy === session.id} onClick={() => void saveToFiles(session, node, "beside")}>
|
||||
{conflict === "changed" ? tFiles("keep_both") : tFiles("save_as_new")}
|
||||
</Button>
|
||||
{conflict === "changed" && (
|
||||
<Button size="sm" variant="outline" disabled={busy === session.id} onClick={() => void saveToFiles(session, node, "replace")}>{tFiles("replace")}</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" disabled={busy === session.id} onClick={() => setConflicts(({ [session.id]: _gone, ...rest }) => rest)}>{tFiles("cancel")}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{node && (
|
||||
<Button size="sm" disabled={busy === session.id} onClick={() => void saveToFiles(session, node, "check")}>{tFiles("save_to_files")}</Button>
|
||||
)}
|
||||
{source.kind === "mail" && (
|
||||
<Button size="sm" disabled={busy === session.id} onClick={() => void attachToReply(session, source.emailId, source.ownerAccountId)}>{t("reply")}</Button>
|
||||
)}
|
||||
@@ -200,10 +261,11 @@ export function EditedFilesHost() {
|
||||
<Button size="sm" onClick={() => openDraftWindow({ draftId, localAccountId: session.account, ownerAccountId: source.kind === "draft" ? source.ownerAccountId : undefined })}>{t("open_draft")}</Button>
|
||||
)}
|
||||
{source.kind !== "mail" && !draftId && (
|
||||
<Button size="sm" disabled={busy === session.id} onClick={() => void attachToNew(session)}>{t("new_message")}</Button>
|
||||
<Button size="sm" variant={node ? "outline" : "default"} disabled={busy === session.id} onClick={() => void attachToNew(session)}>{t("new_message")}</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" disabled={busy === session.id} onClick={() => void saveCopy(session)}>{t("save_copy")}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -27,13 +27,27 @@ export type EditSource =
|
||||
| { kind: 'mail'; emailId: string; ownerAccountId?: string }
|
||||
/** A draft's attachment: offers the update, in the composer holding it. */
|
||||
| { kind: 'draft'; draftId: string | null; ownerAccountId?: string }
|
||||
/**
|
||||
* A file in Files, opened with "Open in default app": a save goes back into this node.
|
||||
* `blobId` is the content last known to be there, to tell whether someone changed it
|
||||
* on the server meanwhile.
|
||||
*/
|
||||
| { kind: 'node'; nodeId: string; blobId: string | null; parentId: string | null }
|
||||
/** Its message went or it was taken off a draft: only the file is left. */
|
||||
| { kind: 'file' };
|
||||
|
||||
export function parseEditSource(raw: string): EditSource {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<EditSource> & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown };
|
||||
const parsed = JSON.parse(raw) as Partial<EditSource> & { emailId?: unknown; draftId?: unknown; ownerAccountId?: unknown; nodeId?: unknown; blobId?: unknown; parentId?: unknown };
|
||||
const ownerAccountId = typeof parsed.ownerAccountId === 'string' ? parsed.ownerAccountId : undefined;
|
||||
if (parsed.kind === 'node' && typeof parsed.nodeId === 'string' && parsed.nodeId) {
|
||||
return {
|
||||
kind: 'node',
|
||||
nodeId: parsed.nodeId,
|
||||
blobId: typeof parsed.blobId === 'string' ? parsed.blobId : null,
|
||||
parentId: typeof parsed.parentId === 'string' ? parsed.parentId : null,
|
||||
};
|
||||
}
|
||||
if (parsed.kind === 'mail' && typeof parsed.emailId === 'string') return { kind: 'mail', emailId: parsed.emailId, ownerAccountId };
|
||||
if (parsed.kind === 'draft') return { kind: 'draft', draftId: typeof parsed.draftId === 'string' ? parsed.draftId : null, ownerAccountId };
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* "Save to Files" for a document opened from Files in its own program (pim-p2-1): the saved
|
||||
* bytes go back into the node they came from, as an upload's Replace does (same id, so shares
|
||||
* and links stay), the way OneDrive or Nextcloud write back an edited file.
|
||||
*
|
||||
* Before that the node is read again: when its content is no longer what was opened (someone
|
||||
* changed it on the server, or it was deleted), nothing is overwritten and the caller asks
|
||||
* Keep both (a numbered copy beside it) or Replace.
|
||||
*/
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useFileStore } from '@/stores/file-store';
|
||||
import type { EditSource } from './edit-attachments';
|
||||
|
||||
export type NodeEditSource = Extract<EditSource, { kind: 'node' }>;
|
||||
|
||||
export type SaveBackResult =
|
||||
| { status: 'saved'; blobId: string }
|
||||
/** Someone changed the file on the server since it was opened. */
|
||||
| { status: 'changed' }
|
||||
/** It isn't there any more. */
|
||||
| { status: 'gone' };
|
||||
|
||||
/** The client for the account the file belongs to, whichever account is on screen. */
|
||||
export function filesClientFor(account: string): IJMAPClient | null {
|
||||
const files = useFileStore.getState();
|
||||
if (files.client && files.currentAccountId === account) return files.client;
|
||||
const auth = useAuthStore.getState();
|
||||
return auth.getClientForAccount?.(account) ?? (auth.activeAccountId === account ? auth.client : null) ?? null;
|
||||
}
|
||||
|
||||
/** Puts the file into its node. `force`: also when it changed on the server (Replace). */
|
||||
export async function saveBackToNode(client: IJMAPClient, source: NodeEditSource, file: File, force = false): Promise<SaveBackResult> {
|
||||
const [node] = await client.getFileNodes([source.nodeId], ['id', 'blobId', 'name', 'parentId']);
|
||||
if (!node) return { status: 'gone' };
|
||||
if (!force && source.blobId && node.blobId !== source.blobId) return { status: 'changed' };
|
||||
const { blobId } = await client.uploadBlob(file);
|
||||
await client.replaceFileNodeBlob(source.nodeId, blobId);
|
||||
return { status: 'saved', blobId };
|
||||
}
|
||||
|
||||
/** Keep both: a new file beside the original, numbered by the server when the name is taken. */
|
||||
export async function saveBesideNode(client: IJMAPClient, source: NodeEditSource, file: File): Promise<{ source: NodeEditSource; name: string }> {
|
||||
const { blobId, type } = await client.uploadBlob(file);
|
||||
let created;
|
||||
try {
|
||||
created = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, source.parentId);
|
||||
} catch (error) {
|
||||
// The folder went too: the top of Files.
|
||||
if (source.parentId === null) throw error;
|
||||
created = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, null);
|
||||
}
|
||||
return { source: { kind: 'node', nodeId: created.id, blobId, parentId: created.parentId ?? null }, name: created.name };
|
||||
}
|
||||
|
||||
/** Files on screen shows the new size and date. */
|
||||
export function refreshFilesFor(client: IJMAPClient): void {
|
||||
const files = useFileStore.getState();
|
||||
if (files.client === client) void files.refresh().catch(() => {});
|
||||
}
|
||||
@@ -10450,6 +10450,21 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> {
|
||||
// A node of a shared drive is known as "accountId:nodeId" (namespaceFileNode):
|
||||
// read it in its owner's account and hand it back under the same name.
|
||||
const shared = ids && ids.length > 0 ? this.resolveFileNodeId(ids[0]) : null;
|
||||
if (shared && shared.accountId !== this.getFilesAccountId()) {
|
||||
const rawIds = ids!.map((id) => this.resolveFileNodeId(id)).filter((node) => node.accountId === shared.accountId).map((node) => node.id as string);
|
||||
const response = await this.request(
|
||||
[["FileNode/get", { accountId: shared.accountId, ids: rawIds, properties: properties || JMAPClient.FILE_NODE_PROPERTIES }, "fn0"]],
|
||||
this.fileUsing(),
|
||||
);
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
throw new Error(result?.[1]?.description || "FileNode/get failed");
|
||||
}
|
||||
return ((result[1].list || []) as FileNode[]).map((node) => this.namespaceFileNode(fromWireFileNode(node), shared.accountId));
|
||||
}
|
||||
const accountId = this.getFilesAccountId();
|
||||
const args: Record<string, unknown> = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user