mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-ux-p2-pim-files' into feat/do-ux-p2-pim-fix
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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -146,16 +146,22 @@ describe('a drag from outside', () => {
|
||||
clear();
|
||||
});
|
||||
|
||||
it('on the Files screen, "Upload to <folder>" hands the files to that folder', async () => {
|
||||
// pim-p2-6: on the Files screen a drop uploads where it lands, with no question; the
|
||||
// list takes it (components/files). A file pasted there goes up into the folder in view.
|
||||
it('on the Files screen, shows no choices and leaves the drop to the list', async () => {
|
||||
const take = vi.fn();
|
||||
const clear = setFilesDropTarget({ folder: 'Reports', take, visible: () => true });
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
expect(zone('upload').textContent).toContain('zone_upload_folder:{"folder":"Reports"}');
|
||||
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
|
||||
await flush();
|
||||
expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false });
|
||||
clear();
|
||||
try {
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
|
||||
act(() => requestFileChoice({ files: [file] }));
|
||||
await flush();
|
||||
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
|
||||
expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false });
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('from another screen, Files gets the files after the app goes there', async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { readDroppedFiles, type DroppedFiles } from "@/lib/file-drop/read";
|
||||
import {
|
||||
defaultZone,
|
||||
fileDropZones,
|
||||
filesTakeDropsAsIs,
|
||||
filesWithinLimits,
|
||||
kindForName,
|
||||
type DropFiles,
|
||||
@@ -72,6 +73,7 @@ function currentPlace(pathname: string, translate: (key: string) => string): { p
|
||||
popout: isPopoutPath(window.location.pathname),
|
||||
files: !!client?.supportsFiles?.() && features.isFeatureEnabled("filesEnabled"),
|
||||
filesFolder: filesDropTarget()?.folder ?? null,
|
||||
onFiles: filesDropTarget() !== null,
|
||||
calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"),
|
||||
contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"),
|
||||
contactsScreen: matchSurface(pathname)?.surface === "contacts",
|
||||
@@ -179,6 +181,8 @@ export function FileDropHost() {
|
||||
enter: (files) => {
|
||||
if (overlayBlocked()) return false;
|
||||
const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox);
|
||||
// The Files screen takes the drop itself: the folder in view, or the folder row it lands on.
|
||||
if (filesTakeDropsAsIs(place)) return false;
|
||||
const zones = fileDropZones(files, place);
|
||||
if (zones.length === 0) return false;
|
||||
setActive(null);
|
||||
|
||||
@@ -19,14 +19,15 @@ const ICONS: Record<FileDropZoneId, ComponentType<{ className?: string }>> = {
|
||||
|
||||
export function useZoneText() {
|
||||
const t = useTranslations("fileDrop");
|
||||
const tAll = useTranslations("ux_p2_pim_files");
|
||||
const label = (zone: FileDropZone): string => {
|
||||
switch (zone.id) {
|
||||
case "attach-here": return t("zone_attach_here");
|
||||
case "insert-inline": return t("zone_insert_inline");
|
||||
case "new-message": return t("zone_new_message");
|
||||
case "upload": return zone.folder ? t("zone_upload_folder", { folder: zone.folder }) : t("zone_upload_files");
|
||||
case "calendar": return t("zone_calendar");
|
||||
case "contacts": return t("zone_contacts");
|
||||
case "calendar": return zone.several ? tAll("zone_calendar_all") : t("zone_calendar");
|
||||
case "contacts": return zone.several ? tAll("zone_contacts_all") : t("zone_contacts");
|
||||
case "open-eml": return t("zone_open_eml");
|
||||
case "import-eml": return t("zone_import_eml", { folder: zone.folder ?? "" });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FileNoPreview } from '../file-no-preview';
|
||||
import { FilePreviewModal } from '../file-preview-modal';
|
||||
import { fileAppAction } from '@/lib/files-app-actions';
|
||||
import type { FileResource } from '@/stores/file-store';
|
||||
|
||||
// pim-p2-18: the pane of a file with no preview has the preview's header with
|
||||
// Previous/Next and one Download. pim-p2-7: the app's own action for a file.
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false }));
|
||||
|
||||
const budget = {
|
||||
id: 'x', name: 'budget.xlsx', serverName: 'budget.xlsx', isDirectory: false, contentType: 'application/vnd.ms-excel',
|
||||
contentLength: 10, lastModified: '2026-10-01T00:00:00Z', blobId: 'b-x', parentId: null,
|
||||
} as FileResource;
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('the pane of a file with no preview (pim-p2-18)', () => {
|
||||
it('shows Download once and goes to the next file', () => {
|
||||
const onGo = vi.fn();
|
||||
render(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={vi.fn()} neighbours={{ previous: 'a.zip', next: 'c.bin', onGo }} />);
|
||||
expect(screen.getAllByRole('button', { name: /download/ })).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'next_file' }));
|
||||
expect(onGo).toHaveBeenCalledWith('c.bin');
|
||||
});
|
||||
|
||||
it('has the app action first', () => {
|
||||
const onRun = vi.fn();
|
||||
render(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={vi.fn()} appAction={{ label: 'import_contacts', onRun }} />);
|
||||
fireEvent.click(screen.getByTestId('file-app-action'));
|
||||
expect(onRun).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the preview offers the app action (pim-p2-7)', () => {
|
||||
it('as a labelled button in the header', () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<FilePreviewModal
|
||||
name="contacts-export.csv"
|
||||
onClose={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
getFileContent={() => new Promise(() => {})}
|
||||
extraAction={{ label: 'import_contacts', onClick }}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'import_contacts' }));
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('knows which files have one', () => {
|
||||
expect(fileAppAction('forwarded.eml')).toBe('import-mail');
|
||||
expect(fileAppAction('contacts-export.csv')).toBe('import-contacts');
|
||||
expect(fileAppAction('team.vcf')).toBe('import-contacts');
|
||||
expect(fileAppAction('trip.ics')).toBe('add-calendar');
|
||||
expect(fileAppAction('budget.xlsx')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// Usability pass 2, Files (pim-5): the ⋮ of a row near the bottom opened its
|
||||
// menu over itself, so a second click on the button hit "Delete".
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
|
||||
vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false }));
|
||||
|
||||
const res = (id: string, name: string, extra: Partial<FileResource> = {}): FileResource => ({
|
||||
id, name, serverName: name, isDirectory: false, contentType: 'text/plain', contentLength: 10,
|
||||
lastModified: '2026-10-01T00:00:00Z', blobId: `b-${id}`, parentId: null, ...extra,
|
||||
} as FileResource);
|
||||
const notes = res('n', 'notes.txt');
|
||||
const budget = res('x', 'budget.xlsx', { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const docs = res('d', 'Documents', { isDirectory: true, contentType: '', blobId: null });
|
||||
|
||||
export function renderBrowser(props: Partial<React.ComponentProps<typeof FileBrowser>> = {}, resources: FileResource[] = [docs, notes, budget]) {
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({ client, sharedRoots: [], allFolders: [docs], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true });
|
||||
});
|
||||
const h = {
|
||||
onPreviewFile: vi.fn(),
|
||||
onPreviewImage: vi.fn(),
|
||||
onDownload: vi.fn(async () => {}),
|
||||
onShowFileInfo: vi.fn(),
|
||||
onSetSelection: vi.fn(),
|
||||
onUploadFiles: vi.fn(async () => {}),
|
||||
};
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={resources}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set()}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onNavigate={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onCreateFolder={noop}
|
||||
onUploadFolder={noop}
|
||||
onCancelUpload={vi.fn()}
|
||||
onDelete={noop}
|
||||
onBatchDelete={noop}
|
||||
onRename={noop}
|
||||
onBatchDownload={noop}
|
||||
onRefresh={noop}
|
||||
onClearSelection={vi.fn()}
|
||||
onCut={vi.fn()}
|
||||
onCopy={vi.fn()}
|
||||
onPaste={noop}
|
||||
onMoveTo={noop}
|
||||
onCreateFile={noop}
|
||||
onDuplicate={noop}
|
||||
getImageUrl={async () => ''}
|
||||
favorites={[]}
|
||||
recentFiles={[]}
|
||||
onToggleFavorite={vi.fn()}
|
||||
onOpenRecent={vi.fn()}
|
||||
onRenameNode={noop}
|
||||
onDeleteNode={noop}
|
||||
onDownloadNode={noop}
|
||||
onRevealNode={noop}
|
||||
onRemoveRecent={vi.fn()}
|
||||
client={client}
|
||||
ownAccountId="a"
|
||||
{...h}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return h;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); });
|
||||
});
|
||||
|
||||
describe('the ⋮ of a row opens its menu beside the button, never over it (pim-5)', () => {
|
||||
it('goes above a button near the bottom of the window', () => {
|
||||
// 1280×720, as in the audit: the last row's ⋮ at y 536–568, a menu 357 px tall.
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280);
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(720);
|
||||
const button = { left: 1200, right: 1232, top: 536, bottom: 568, width: 32, height: 32 };
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.getAttribute('role') === 'menu') return { x: 0, y: 0, left: 0, top: 0, right: 220, bottom: 357, width: 220, height: 357, toJSON: () => ({}) } as DOMRect;
|
||||
if (this.hasAttribute('data-item-action')) return { x: button.left, y: button.top, ...button, toJSON: () => ({}) } as DOMRect;
|
||||
return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect;
|
||||
});
|
||||
renderBrowser();
|
||||
const kebab = document.querySelector<HTMLElement>('[data-resource="budget.xlsx"] [data-item-action][title="context_menu"]')!;
|
||||
expect(kebab).toBeTruthy();
|
||||
fireEvent.click(kebab, { detail: 1, clientX: 1216, clientY: 552 });
|
||||
const menu = screen.getByRole('menu');
|
||||
const top = parseFloat(menu.style.top);
|
||||
// Above the button: the menu ends before the button starts.
|
||||
expect(top + 357).toBeLessThanOrEqual(button.top);
|
||||
// The row stays marked while its menu is open.
|
||||
expect(document.querySelector('[data-resource="budget.xlsx"]')?.closest('[data-menu-opener]') ?? document.querySelector('[data-menu-opener]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
const filesTransfer = (files: File[]) => ({
|
||||
types: ['Files'],
|
||||
files,
|
||||
items: [],
|
||||
dropEffect: 'none',
|
||||
effectAllowed: 'all',
|
||||
getData: () => '',
|
||||
setData: () => {},
|
||||
});
|
||||
|
||||
describe('files from outside dropped on Files (pim-p2-6)', () => {
|
||||
it('go into the folder whose row they land on', async () => {
|
||||
const h = renderBrowser();
|
||||
const file = new File(['x'], 'report.pdf', { type: 'application/pdf' });
|
||||
const row = document.querySelector<HTMLElement>('[data-resource="Documents"]')!;
|
||||
fireEvent.dragOver(row, { dataTransfer: filesTransfer([file]) });
|
||||
fireEvent.drop(row, { dataTransfer: filesTransfer([file]) });
|
||||
await vi.waitFor(() => expect(h.onUploadFiles).toHaveBeenCalledTimes(1));
|
||||
expect(h.onUploadFiles).toHaveBeenCalledWith([file], expect.objectContaining({ id: 'd', name: 'Documents' }));
|
||||
});
|
||||
|
||||
it('go into the folder in view when they land on a file or the list', async () => {
|
||||
const h = renderBrowser();
|
||||
const file = new File(['x'], 'report.pdf', { type: 'application/pdf' });
|
||||
const row = document.querySelector<HTMLElement>('[data-resource="notes.txt"]')!;
|
||||
fireEvent.drop(row, { dataTransfer: filesTransfer([file]) });
|
||||
await vi.waitFor(() => expect(h.onUploadFiles).toHaveBeenCalledTimes(1));
|
||||
expect((h.onUploadFiles.mock.calls[0] as unknown[])[1]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('files the app knows (pim-p2-7)', () => {
|
||||
it('opens a saved message in the preview, as a mail', () => {
|
||||
const eml = res('m', 'forwarded.eml', { contentType: 'message/rfc822' });
|
||||
const h = renderBrowser({}, [docs, notes, eml]);
|
||||
fireEvent.doubleClick(screen.getByTitle('forwarded.eml'));
|
||||
expect(h.onPreviewFile).toHaveBeenCalledWith('forwarded.eml');
|
||||
expect(h.onShowFileInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('puts the app action in the item menu', () => {
|
||||
const csv = res('c', 'contacts-export.csv', { contentType: 'text/csv' });
|
||||
const onRun = vi.fn();
|
||||
renderBrowser({ appActionFor: (r) => (r.name.endsWith('.csv') ? { kind: 'import-contacts', label: 'import_contacts', onRun } : null) }, [docs, notes, csv]);
|
||||
fireEvent.contextMenu(screen.getByTitle('contacts-export.csv'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'import_contacts' }));
|
||||
expect(onRun).toHaveBeenCalledTimes(1);
|
||||
fireEvent.contextMenu(screen.getByTitle('notes.txt'));
|
||||
expect(screen.queryByRole('menuitem', { name: 'import_contacts' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, Pin, PinOff,
|
||||
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
|
||||
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink,
|
||||
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink, Inbox, Calendar, UserPlus,
|
||||
} from "@/components/icons";
|
||||
import { useIsFullDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -28,11 +28,13 @@ import { FilesSidebar, type CreateKind } from "@/components/files/files-sidebar"
|
||||
import { FilesHeader, type Crumb, type SortDir, type SortKey, type ViewMode } from "@/components/files/files-header";
|
||||
import { FileGridView, FileListView, type FileItemHandlers } from "@/components/files/file-views";
|
||||
import { FileDetailsPanel } from "@/components/files/file-details-panel";
|
||||
import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { INTERNAL_DRAG_TYPE, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { AppMain } from "@/components/layout/app-landmarks";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import { getFilePreviewKind } from "@/lib/file-preview";
|
||||
import type { FileAppAction } from "@/lib/files-app-actions";
|
||||
import { useAppSidePanel } from "@/components/plugins/app-side-panel";
|
||||
import { MAX_SIDE_PANEL_FILES } from "@/lib/plugin-sandbox/assistant/f.types";
|
||||
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
|
||||
@@ -74,8 +76,11 @@ interface FileBrowserProps {
|
||||
clipboard: ClipboardState | null;
|
||||
onNavigate: (path: string, resourceId?: string | null) => void;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onUploadFiles: (files: File[]) => Promise<void>;
|
||||
onUploadFolder: (files: File[]) => Promise<void>;
|
||||
/** What the app itself does with a file: "Import into contacts…" and the like (pim-p2-7). */
|
||||
appActionFor?: (resource: FileResource) => { kind: FileAppAction; label: string; onRun: () => void } | null;
|
||||
/** `into`: a folder of the one in view, when files are dropped on its row. */
|
||||
onUploadFiles: (files: File[], into?: FileResource) => Promise<void>;
|
||||
onUploadFolder: (files: File[], into?: FileResource) => Promise<void>;
|
||||
onCancelUpload: () => void;
|
||||
/** Delete: into the trash, or for good when in the trash or a shared drive. */
|
||||
onDelete: (name: string) => Promise<void>;
|
||||
@@ -149,7 +154,8 @@ interface FileBrowserProps {
|
||||
}
|
||||
|
||||
function isPreviewable(name: string): boolean {
|
||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||
// A saved message opens like a mail (pim-p2-7).
|
||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name) || getFilePreviewKind(name) === "eml";
|
||||
}
|
||||
|
||||
type MenuData = { kind: "item"; resource: FileResource } | { kind: "empty" } | { kind: "new" };
|
||||
@@ -231,6 +237,7 @@ export function FileBrowser({
|
||||
onShowFileInfo,
|
||||
canOpenInApp,
|
||||
onOpenInApp,
|
||||
appActionFor,
|
||||
onShared,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
@@ -615,6 +622,16 @@ export function FileBrowser({
|
||||
void onMoveTo(names, targetId);
|
||||
}, [onMoveTo]);
|
||||
|
||||
// Files from outside dropped on a folder's row go into that folder (pim-p2-6).
|
||||
const uploadIntoRef = useRef<(files: File[], asFolder: boolean, into: FileResource) => Promise<void>>(async () => {});
|
||||
uploadIntoRef.current = async (files, asFolder, into) => {
|
||||
if (files.length === 0) return;
|
||||
if (asFolder) await onUploadFolder(files, into);
|
||||
else await onUploadFiles(files, into);
|
||||
};
|
||||
const takesFilesFromOutside = useCallback((e: React.DragEvent, resource: FileResource) =>
|
||||
resource.isDirectory && !resource.isSymlink && canCreate && !isInternalDrag(e) && Array.from(e.dataTransfer.types ?? []).includes("Files"), [canCreate]);
|
||||
|
||||
const itemDragProps = useCallback((resource: FileResource) => ({
|
||||
draggable: !touch && !inTrash,
|
||||
onDragStart: (e: React.DragEvent) => {
|
||||
@@ -623,6 +640,12 @@ export function FileBrowser({
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
},
|
||||
onDragOver: (e: React.DragEvent) => {
|
||||
if (takesFilesFromOutside(e, resource)) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDropTarget(resource.name);
|
||||
return;
|
||||
}
|
||||
if (!resource.isDirectory || !isInternalDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -631,6 +654,16 @@ export function FileBrowser({
|
||||
},
|
||||
onDragLeave: () => setDropTarget((cur) => (cur === resource.name ? null : cur)),
|
||||
onDrop: (e: React.DragEvent) => {
|
||||
if (takesFilesFromOutside(e, resource)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropTarget(null);
|
||||
dragDepth.current = 0;
|
||||
setIsDraggingFiles(false);
|
||||
const transfer = e.dataTransfer;
|
||||
void getDroppedFilesAndFolders(transfer).then(({ files, hasDirectories }) => uploadIntoRef.current(files, hasDirectories, resource));
|
||||
return;
|
||||
}
|
||||
if (!resource.isDirectory || !isInternalDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -638,7 +671,7 @@ export function FileBrowser({
|
||||
const names = readDraggedNames(e).filter((n) => n !== resource.name);
|
||||
if (names.length > 0) moveNames(names, resource.id);
|
||||
},
|
||||
}), [touch, inTrash, selectedResources, moveNames]);
|
||||
}), [touch, inTrash, selectedResources, moveNames, takesFilesFromOutside]);
|
||||
|
||||
const runUpload = useCallback(async (files: File[], asFolder: boolean) => {
|
||||
if (files.length === 0) return;
|
||||
@@ -880,6 +913,7 @@ export function FileBrowser({
|
||||
|
||||
// ---- Context menu ----------------------------------------------------------
|
||||
const openMenu = menu.openContextMenu;
|
||||
const openMenuFrom = menu.openContextMenuFrom;
|
||||
const openItemMenu = useCallback((e: React.MouseEvent, resource: FileResource) => {
|
||||
// Acting on an item outside the selection makes it the selection, like a
|
||||
// desktop file manager.
|
||||
@@ -891,9 +925,9 @@ export function FileBrowser({
|
||||
setKeyboardNav(false);
|
||||
const target = e.currentTarget as HTMLElement | null;
|
||||
// The "..." button opens the menu under itself; a right-click at the pointer.
|
||||
if (e.type === "click" && target) openMenu(anchorEvent(target, "end"), { kind: "item", resource });
|
||||
if (e.type === "click" && target) openMenuFrom(target, { kind: "item", resource }, "end");
|
||||
else openMenu(e, { kind: "item", resource });
|
||||
}, [selectedResources, onSetSelection, openMenu]);
|
||||
}, [selectedResources, onSetSelection, openMenu, openMenuFrom]);
|
||||
|
||||
const closeMenu = menu.closeContextMenu;
|
||||
const menuData = menu.contextMenu.data;
|
||||
@@ -973,6 +1007,12 @@ export function FileBrowser({
|
||||
{canOpenInApp?.(resource) && onOpenInApp && (
|
||||
<ContextMenuItem icon={ExternalLink} label={tUx("open_in_app")} onClick={() => { closeMenu(); onOpenInApp(resource); }} />
|
||||
)}
|
||||
{(() => {
|
||||
const action = appActionFor?.(resource);
|
||||
if (!action) return null;
|
||||
const icon = action.kind === "import-mail" ? Inbox : action.kind === "add-calendar" ? Calendar : UserPlus;
|
||||
return <ContextMenuItem icon={icon} label={action.label} onClick={() => { closeMenu(); action.onRun(); }} />;
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{canKeepOffline(resource) && (
|
||||
@@ -1417,7 +1457,7 @@ export function FileBrowser({
|
||||
size="icon-lg"
|
||||
className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg"
|
||||
aria-label={t("new")}
|
||||
onClick={(e) => menu.openContextMenu(anchorEvent(e.currentTarget, "end"), { kind: "new" })}
|
||||
onClick={(e) => menu.openContextMenuFrom(e.currentTarget, { kind: "new" }, "end")}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
@@ -230,16 +230,3 @@ export function MiddleCutName({ name, title, className }: { name: string; title?
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Open a shared context menu as a dropdown under a button. */
|
||||
export function anchorEvent(el: HTMLElement, align: "start" | "end" = "start"): React.MouseEvent {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const rtl = typeof document !== "undefined" && document.dir === "rtl";
|
||||
const alignEnd = (align === "end") !== rtl;
|
||||
return {
|
||||
clientX: alignEnd ? rect.right - 200 : rect.left,
|
||||
clientY: rect.bottom + 4,
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
} as unknown as React.MouseEvent;
|
||||
}
|
||||
|
||||
@@ -14,22 +14,26 @@ const ICON_OPTIONS = { showIcons: true, coloredIcons: true };
|
||||
* A file the app can't show and no office editor opens (a spreadsheet with
|
||||
* no Collabora or ONLYOFFICE set up, a zip): its icon, name and size, with
|
||||
* Download and, in the desktop app, Open in the program the computer uses
|
||||
* for it (pim-16). It used to download at once, with no word.
|
||||
* for it (pim-16). It used to download at once, with no word. The header is
|
||||
* the preview's, with the folder's previous and next file; Download is in
|
||||
* the body only, once (pim-p2-18).
|
||||
*/
|
||||
export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, neighbours }: {
|
||||
export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, appAction, neighbours }: {
|
||||
resource: FileResource;
|
||||
onClose: () => void;
|
||||
/** Left out when a device policy turned downloads off. */
|
||||
onDownload?: () => void;
|
||||
/** Only in the desktop app, for a file it may open. */
|
||||
onOpenInApp?: () => void;
|
||||
/** What the app itself does with this kind of file: "Import into contacts…" (pim-p2-7). */
|
||||
appAction?: { label: string; onRun: () => void };
|
||||
neighbours?: PreviewNeighbours;
|
||||
}) {
|
||||
const t = useTranslations("files");
|
||||
const tUx = useTranslations("ux_pim_files");
|
||||
useCloseOnBack({ onBack: onClose });
|
||||
return (
|
||||
<PreviewShell name={resource.name} onClose={onClose} onDownload={onDownload} neighbours={neighbours}>
|
||||
<PreviewShell name={resource.name} onClose={onClose} neighbours={neighbours}>
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
data-testid="file-no-preview"
|
||||
@@ -41,14 +45,19 @@ export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, neig
|
||||
<p className="text-xs text-muted-foreground"><FileSize bytes={resource.contentLength} /></p>
|
||||
<p className="text-sm text-muted-foreground">{tUx("no_preview")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-1">
|
||||
{appAction && (
|
||||
<Button size="sm" onClick={appAction.onRun} data-testid="file-app-action">
|
||||
{appAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{onOpenInApp && (
|
||||
<Button size="sm" onClick={onOpenInApp} className="gap-1.5">
|
||||
<Button size="sm" variant={appAction ? "outline" : "default"} onClick={onOpenInApp} className="gap-1.5">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{tUx("open_in_app")}
|
||||
</Button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<Button size="sm" variant={onOpenInApp ? "outline" : "default"} onClick={onDownload} className="gap-1.5">
|
||||
<Button size="sm" variant={onOpenInApp || appAction ? "outline" : "default"} onClick={onDownload} className="gap-1.5">
|
||||
<Download className="w-4 h-4" />
|
||||
{t("download")}
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ZoomableImage } from "@/components/files/zoomable-image";
|
||||
import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmlPreview, type EmlAttachment, type ParsedEml } from "@/components/files/eml-preview";
|
||||
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
|
||||
import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
|
||||
@@ -76,6 +77,12 @@ interface FilePreviewModalProps {
|
||||
* accounts signed in here.
|
||||
*/
|
||||
policyAccount?: string | { sourceClientAccountId?: string | null } | null;
|
||||
/**
|
||||
* What the app itself does with this kind of file, as a labelled button in
|
||||
* the header: "Import to a mail folder…" for a saved message, "Import into
|
||||
* contacts…" for a .csv (pim-p2-7).
|
||||
*/
|
||||
extraAction?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
function SimpleMarkdown({ content }: { content: string }) {
|
||||
@@ -169,7 +176,7 @@ function processInline(text: string): React.ReactNode {
|
||||
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||
}
|
||||
|
||||
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff, policyAccount }: FilePreviewModalProps) {
|
||||
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff, policyAccount, extraAction }: FilePreviewModalProps) {
|
||||
const t = useTranslations("files");
|
||||
// No downloads: no download button, no new tab, no save from the
|
||||
// browser's own PDF viewer or media controls. No printing: the PDF goes
|
||||
@@ -375,11 +382,20 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
neighbours={neighbours}
|
||||
// An installed app hands the new window to the system browser, which
|
||||
// cannot load our blob: URL (#865): no dead button there.
|
||||
actions={objectUrl && canOpenInNewTab && !downloadsOff && canOpenUrlInNewWindow(objectUrl) ? (
|
||||
<IconButton label={t("open_in_new_tab")} onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
) : undefined}
|
||||
actions={(
|
||||
<>
|
||||
{extraAction && (
|
||||
<Button size="sm" variant="outline" className="me-1" data-testid="preview-extra-action" onClick={extraAction.onClick}>
|
||||
{extraAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{objectUrl && canOpenInNewTab && !downloadsOff && canOpenUrlInNewWindow(objectUrl) && (
|
||||
<IconButton label={t("open_in_new_tab")} onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center overflow-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))]">
|
||||
{loading && <Spinner label={t("loading")} className="w-8 h-8 text-white/80" />}
|
||||
|
||||
@@ -9,7 +9,9 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type FileResource, type UploadReport } from "@/stores/file-store";
|
||||
import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target";
|
||||
import { filesDropTarget, hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import { isInternalDrag } from "@/components/files/file-item-parts";
|
||||
import { isTrashLocation } from "@/lib/files-trash";
|
||||
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -37,6 +39,10 @@ import { savePendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import { formatRecipient } from "@/lib/email-composer-utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useEditAttachment } from "@/hooks/use-edit-attachment";
|
||||
import { fileAppAction, type FileAppAction } from "@/lib/files-app-actions";
|
||||
import { handOffFiles } from "@/lib/launch-handoff";
|
||||
import { ImportEmlDialog } from "@/components/files/import-eml-dialog";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { resolveConflictsInTurn, type ConflictAnswer, type UploadConflictResolver } from "@/lib/files-upload-conflicts";
|
||||
import { useWopiStatus, canWopiOpen, fileExtension } from "@/hooks/use-wopi-status";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
@@ -409,7 +415,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}),
|
||||
), []);
|
||||
|
||||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||||
const handleUploadFiles = useCallback(async (files: File[], into?: FileResource) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||
files = files.filter(f => f.size <= maxSizeUpload);
|
||||
@@ -419,14 +425,14 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
showUploadReport(await uploadFiles(files, { resolveConflicts }));
|
||||
showUploadReport(await uploadFiles(files, { resolveConflicts, ...(into ? { into: { id: into.id, name: into.name } } : {}) }));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload files:", err);
|
||||
toast.error(t("upload_error"), failureReason(err));
|
||||
}
|
||||
}, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]);
|
||||
|
||||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||||
const handleUploadFolder = useCallback(async (files: File[], into?: FileResource) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||
files = files.filter(f => f.size <= maxSizeUpload);
|
||||
@@ -436,7 +442,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
showUploadReport(await uploadFolder(files, { resolveConflicts }));
|
||||
showUploadReport(await uploadFolder(files, { resolveConflicts, ...(into ? { into: { id: into.id, name: into.name } } : {}) }));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload folder:", err);
|
||||
toast.error(t("upload_error"), failureReason(err));
|
||||
@@ -693,11 +699,56 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
name: r.name,
|
||||
type: r.contentType || "application/octet-stream",
|
||||
account: filesLocalAccount,
|
||||
source: { kind: "file" },
|
||||
// Saved in its program, it goes back into this node (pim-p2-1).
|
||||
source: { kind: "node", nodeId: r.id, blobId: r.blobId ?? null, parentId: r.parentId ?? null },
|
||||
load: async () => (await getFileContent(r.name)).blob,
|
||||
});
|
||||
// The program has it now: the pane has nothing more to offer (pim-p2-18).
|
||||
setInfoFile((open) => (open === r.name ? null : open));
|
||||
}, [openInDesktopApp, filesLocalAccount, getFileContent]);
|
||||
|
||||
// What the app itself does with a file kept here (pim-p2-7).
|
||||
const tFiles2 = useTranslations("ux_p2_pim_files");
|
||||
const calendarSupported = useCalendarStore((s) => s.supportsCalendar);
|
||||
const calendarAllowed = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled"));
|
||||
const contactsAllowed = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
|
||||
const calendarOn = calendarSupported && calendarAllowed;
|
||||
const contactsOn = !!storeClient?.supportsContacts?.() && contactsAllowed;
|
||||
const [importEml, setImportEml] = useState<File | null>(null);
|
||||
const appActionOf = useCallback((r: FileResource): FileAppAction | null => {
|
||||
if (r.isDirectory || r.isSymlink || r.trash) return null;
|
||||
const action = fileAppAction(r.name, r.contentType);
|
||||
if (action === "add-calendar" && !calendarOn) return null;
|
||||
if (action === "import-contacts" && !contactsOn) return null;
|
||||
return action;
|
||||
}, [calendarOn, contactsOn]);
|
||||
const runAppAction = useCallback(async (r: FileResource) => {
|
||||
const action = appActionOf(r);
|
||||
if (!action) return;
|
||||
let file: File;
|
||||
try {
|
||||
const { blob, contentType } = await getFileContent(r.name);
|
||||
file = new File([blob], r.name, { type: contentType || r.contentType || blob.type });
|
||||
} catch (err) {
|
||||
console.error("Could not read a file for its import:", err);
|
||||
toast.error(t("download_error"));
|
||||
return;
|
||||
}
|
||||
if (action === "import-mail") {
|
||||
setImportEml(file);
|
||||
return;
|
||||
}
|
||||
setPreviewFile(null);
|
||||
setInfoFile(null);
|
||||
handOffFiles([file], action === "add-calendar" ? "calendar" : "contacts");
|
||||
}, [appActionOf, getFileContent, t]);
|
||||
const appActionFor = useCallback((r: FileResource) => {
|
||||
const action = appActionOf(r);
|
||||
if (!action) return null;
|
||||
const label = action === "import-mail" ? tFiles2("import_to_mail") : action === "add-calendar" ? tFiles2("add_to_calendar") : tFiles2("import_contacts");
|
||||
return { kind: action, label, onRun: () => void runAppAction(r) };
|
||||
}, [appActionOf, runAppAction, tFiles2]);
|
||||
|
||||
const handleRenameNode = useCallback(async (id: string, newName: string) => {
|
||||
try {
|
||||
if (!await renameNode(id, newName)) return;
|
||||
@@ -784,29 +835,31 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
// The previewed file as plugins see it (file-preview-section).
|
||||
const previewedName = previewFile ?? previewImage;
|
||||
// ←/→ in a preview go through the folder's other previewable files.
|
||||
// ←/→ in a preview go through the folder's other files that open in one:
|
||||
// previews and the pane of a file with no preview (pim-p2-18). Office
|
||||
// documents open the editor, so the arrows pass them by.
|
||||
const shownName = previewedName ?? infoFile;
|
||||
const previewNeighbours = useMemo(() => {
|
||||
if (!previewedName) return undefined;
|
||||
if (!shownName) return undefined;
|
||||
const names = resources
|
||||
.filter((r) => !r.isDirectory && !r.isSymlink && (isImageFile(r.name) || isFilePreviewable(r.name)))
|
||||
.filter((r) => !r.isDirectory && !r.isSymlink && (isImageFile(r.name) || isFilePreviewable(r.name) || !isOfficeEditable(r.name)))
|
||||
.map((r) => r.name);
|
||||
const i = names.indexOf(previewedName);
|
||||
const i = names.indexOf(shownName);
|
||||
if (i === -1) return undefined;
|
||||
return {
|
||||
previous: names[i - 1] ?? null,
|
||||
next: names[i + 1] ?? null,
|
||||
onGo: (name: string) => {
|
||||
addRecentFile(name, resources.find((r) => r.name === name)?.id ?? name);
|
||||
if (isImageFile(name)) {
|
||||
setPreviewFile(null);
|
||||
setPreviewImage(name);
|
||||
} else {
|
||||
setPreviewImage(null);
|
||||
setPreviewFile(name);
|
||||
}
|
||||
setPreviewFile(null);
|
||||
setPreviewImage(null);
|
||||
setInfoFile(null);
|
||||
if (isImageFile(name)) setPreviewImage(name);
|
||||
else if (isFilePreviewable(name)) setPreviewFile(name);
|
||||
else setInfoFile(name);
|
||||
},
|
||||
};
|
||||
}, [previewedName, resources, addRecentFile]);
|
||||
}, [shownName, resources, addRecentFile, isOfficeEditable]);
|
||||
const previewedView = useMemo(() => {
|
||||
const resource = previewedName ? resources.find((r) => r.name === previewedName) : undefined;
|
||||
return resource ? resourceToView(resource, currentPath) : null;
|
||||
@@ -926,7 +979,20 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={dropRootRef} className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
|
||||
<div
|
||||
ref={dropRootRef}
|
||||
className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}
|
||||
// Files from outside dropped anywhere on the screen go into the folder in view,
|
||||
// as the list itself does (pim-p2-6). The list and a folder's row take theirs first.
|
||||
onDrop={(e) => {
|
||||
if (e.defaultPrevented || !canTakeDrops || isInternalDrag(e) || !Array.from(e.dataTransfer.types ?? []).includes("Files")) return;
|
||||
if (filesDropTarget() === null) return;
|
||||
e.preventDefault();
|
||||
void getDroppedFilesAndFolders(e.dataTransfer).then(({ files, hasDirectories }) => (
|
||||
hasDirectories ? dropUploadRef.current.folder(files) : dropUploadRef.current.files(files)
|
||||
));
|
||||
}}
|
||||
>
|
||||
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
|
||||
<AppTopBannerSlot />
|
||||
{refreshIndicator}
|
||||
@@ -1033,6 +1099,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
onShowFileInfo={handleShowFileInfo}
|
||||
canOpenInApp={canOpenInApp}
|
||||
onOpenInApp={handleOpenInApp}
|
||||
appActionFor={appActionFor}
|
||||
onShared={handleShared}
|
||||
/>
|
||||
)}
|
||||
@@ -1072,6 +1139,11 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
getFileContent={() => getFileContent(previewFile)}
|
||||
file={previewedView}
|
||||
neighbours={previewNeighbours}
|
||||
extraAction={(() => {
|
||||
const resource = resources.find((r) => r.name === previewFile);
|
||||
const action = resource ? appActionFor(resource) : null;
|
||||
return action ? { label: action.label, onClick: action.onRun } : undefined;
|
||||
})()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1085,6 +1157,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
onClose={() => setInfoFile(null)}
|
||||
onDownload={filesDownloadsOff ? undefined : () => void handleDownload(infoFile)}
|
||||
onOpenInApp={canOpenInApp(resource) ? () => handleOpenInApp(resource) : undefined}
|
||||
appAction={appActionFor(resource) ?? undefined}
|
||||
neighbours={previewNeighbours}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
@@ -1128,6 +1202,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<UploadConflictDialog question={conflictQuestion} />
|
||||
<ImportEmlDialog file={importEml} onClose={() => setImportEml(null)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
|
||||
@@ -303,7 +303,7 @@ export function FilesHeader({
|
||||
className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
aria-label={t("show_path")}
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => overflowMenu.openContextMenu(anchorEvent(e.currentTarget), hidden)}
|
||||
onClick={(e) => overflowMenu.openContextMenuFrom(e.currentTarget, hidden)}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -347,7 +347,7 @@ export function FilesHeader({
|
||||
onMouseDown={(e) => { if (sortMenu.contextMenu.isOpen) e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
if (sortMenu.contextMenu.isOpen) sortMenu.closeContextMenu();
|
||||
else sortMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null);
|
||||
else sortMenu.openContextMenuFrom(e.currentTarget, null, "end");
|
||||
}}
|
||||
>
|
||||
<ArrowUpDown />
|
||||
@@ -384,7 +384,7 @@ export function FilesHeader({
|
||||
below="md"
|
||||
menuItems={(close) => (
|
||||
<>
|
||||
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenu(anchorEvent(toolsRef.current, "end"), null); }} />}
|
||||
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenuFrom(toolsRef.current, null, "end"); }} />}
|
||||
{narrow && (viewMode === "list"
|
||||
? <ContextMenuItem icon={LayoutGrid} label={t("grid_view")} onClick={() => { close(); onViewModeChange("grid"); }} />
|
||||
: <ContextMenuItem icon={LayoutList} label={t("list_view")} onClick={() => { close(); onViewModeChange("list"); }} />)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sharedFilePages, type FileResource } from "@/stores/file-store";
|
||||
import { getFileIconByName } from "@/components/files/file-icons";
|
||||
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
|
||||
const COLLAPSED_KEY = "files-sidebar-collapsed";
|
||||
const RECENT_LIMIT = 8;
|
||||
@@ -297,7 +297,7 @@ export function FilesSidebar({
|
||||
onMouseDown={(e) => { if (newMenu.contextMenu.isOpen) e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
if (newMenu.contextMenu.isOpen) newMenu.closeContextMenu();
|
||||
else newMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null);
|
||||
else newMenu.openContextMenuFrom(e.currentTarget, null, "end");
|
||||
}}
|
||||
>
|
||||
<Plus />
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { MoveFolderList, useMoveMenu } from "@/components/email/move-target-list";
|
||||
import { localizeMailboxName } from "@/lib/mailbox-label";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
/**
|
||||
* "Import to a mail folder…" for a message saved in Files (pim-p2-7): the user's own folders
|
||||
* that take new mail, in the Move list's order with its filter, and Email/import into the one
|
||||
* picked, as a dropped .eml goes into the folder in view.
|
||||
*/
|
||||
export function ImportEmlDialog({ file, onClose }: { file: File | null; onClose: () => void }) {
|
||||
const t = useTranslations("ux_p2_pim_files");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const all = useEmailStore((s) => s.mailboxes);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Files may be the first screen opened: the folders aren't loaded yet.
|
||||
useEffect(() => {
|
||||
if (!file || !client || useEmailStore.getState().mailboxes.length > 0) return;
|
||||
void useEmailStore.getState().fetchMailboxes(client).catch(() => {});
|
||||
}, [file, client]);
|
||||
|
||||
const mailboxes = useMemo(
|
||||
() => all.filter((mailbox) => !mailbox.isShared && mailbox.myRights?.mayAddItems !== false),
|
||||
[all],
|
||||
);
|
||||
const model = useMoveMenu(mailboxes, { currentMailboxId: null });
|
||||
|
||||
const importInto = async (mailboxId: string) => {
|
||||
const mailbox = mailboxes.find((entry) => entry.id === mailboxId);
|
||||
if (!file || !client || !mailbox || busy) return;
|
||||
const folder = localizeMailboxName(mailbox.role, mailbox.name, (key) => tSidebar(`mailboxes.${key}`));
|
||||
setBusy(true);
|
||||
try {
|
||||
await client.importRawEmail(file, { [mailbox.id]: true }, { $seen: true }, mailbox.accountId);
|
||||
toast.success(t("imported_to_mail", { folder }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Importing a message from Files failed:", error);
|
||||
toast.error(t("import_to_mail_failed", { name: file.name }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog isOpen={!!file} onClose={onClose} title={t("import_to_mail_title")} bodyClassName="px-2" size="sm" testId="import-eml-dialog">
|
||||
<div role="menu" aria-label={t("import_to_mail_title")} className="max-h-[60vh] overflow-y-auto">
|
||||
<MoveFolderList variant="dialog" alwaysFilter noRecent model={model} testIdPrefix="import-eml:" onPick={(id) => void importInto(id)} />
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { useHandoffFiles } from '../use-handoff-files';
|
||||
import { handOffFiles, takeHandoff } from '@/lib/launch-handoff';
|
||||
import { mergeCalendarTexts, mergeVCardTexts } from '@/lib/file-drop/merge-imports';
|
||||
|
||||
// pim-p2-10: several .ics or .vcf dropped together go into one import list.
|
||||
|
||||
const ics = (uid: string, title: string) => [
|
||||
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Test//EN',
|
||||
'BEGIN:VEVENT', `UID:${uid}`, `SUMMARY:${title}`, 'DTSTART:20261110T090000Z', 'END:VEVENT',
|
||||
'END:VCALENDAR', '',
|
||||
].join('\r\n');
|
||||
const vcf = (name: string) => ['BEGIN:VCARD', 'VERSION:3.0', `FN:${name}`, 'END:VCARD', ''].join('\r\n');
|
||||
|
||||
afterEach(() => {
|
||||
for (const kind of ['calendar', 'contacts'] as const) while (takeHandoff(kind)) { /* drain */ }
|
||||
});
|
||||
|
||||
describe('merging calendars and cards', () => {
|
||||
it('puts every event of two calendars into one VCALENDAR', () => {
|
||||
const merged = mergeCalendarTexts([ics('a', 'First'), ics('b', 'Second')]);
|
||||
expect(merged.match(/BEGIN:VCALENDAR/g)).toHaveLength(1);
|
||||
expect(merged.match(/END:VCALENDAR/g)).toHaveLength(1);
|
||||
expect(merged.match(/BEGIN:VEVENT/g)).toHaveLength(2);
|
||||
expect(merged).toContain('SUMMARY:First');
|
||||
expect(merged).toContain('SUMMARY:Second');
|
||||
expect(merged.match(/VERSION:2.0/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('puts the cards one after the other', () => {
|
||||
const merged = mergeVCardTexts([vcf('Anna'), vcf('Ben')]);
|
||||
expect(merged.match(/BEGIN:VCARD/g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useHandoffFiles', () => {
|
||||
it('gives two dropped .ics as one file to the import', async () => {
|
||||
const { result } = renderHook(() => useHandoffFiles('calendar', true));
|
||||
act(() => {
|
||||
handOffFiles([
|
||||
new File([ics('a', 'First')], 'first.ics', { type: 'text/calendar' }),
|
||||
new File([ics('b', 'Second')], 'second.ics', { type: 'text/calendar' }),
|
||||
], 'calendar');
|
||||
});
|
||||
await waitFor(() => expect(result.current.file).not.toBeNull());
|
||||
const text = await result.current.file!.text();
|
||||
expect(text).toContain('SUMMARY:First');
|
||||
expect(text).toContain('SUMMARY:Second');
|
||||
act(() => result.current.done());
|
||||
expect(result.current.file).toBeNull();
|
||||
});
|
||||
|
||||
it('gives three .vcf as one file', async () => {
|
||||
const { result } = renderHook(() => useHandoffFiles('contacts', true));
|
||||
act(() => {
|
||||
handOffFiles(['Anna', 'Ben', 'Cleo'].map((n) => new File([vcf(n)], `${n}.vcf`, { type: 'text/vcard' })), 'contacts');
|
||||
});
|
||||
await waitFor(() => expect(result.current.file).not.toBeNull());
|
||||
expect((await result.current.file!.text()).match(/BEGIN:VCARD/g)).toHaveLength(3);
|
||||
act(() => result.current.done());
|
||||
expect(result.current.file).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,11 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
}, []);
|
||||
|
||||
const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => {
|
||||
setContextMenu({ isOpen: true, position: anchorPosition(element, align), data });
|
||||
const position = anchorPosition(element, align);
|
||||
// Focus goes back to it, and the row it's in stays marked while the menu is open.
|
||||
position.trigger = focusReturnTarget(element);
|
||||
position.opener = element.closest<HTMLElement>("[data-menu-row]") ?? element;
|
||||
setContextMenu({ isOpen: true, position, data });
|
||||
}, []);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { subscribeHandoff, takeHandoff, type HandoffKind } from "@/lib/launch-handoff";
|
||||
import { mergeImportFiles } from "@/lib/file-drop/merge-imports";
|
||||
|
||||
/**
|
||||
* The files handed to this part of the app (lib/launch-handoff.ts), one at a
|
||||
* time. Nothing is taken while `active` is false, for a surface that isn't
|
||||
* ready for them yet. `done` moves on to the next file.
|
||||
* ready for them yet. `done` moves on to the next file. Several calendars or
|
||||
* address cards handed over together come as one file, so one import lists
|
||||
* them all (pim-p2-10).
|
||||
*/
|
||||
export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: File | null; done: () => void } {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
@@ -16,7 +19,14 @@ export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: Fil
|
||||
const take = () => {
|
||||
for (let handoff = takeHandoff(kind); handoff; handoff = takeHandoff(kind)) {
|
||||
const received = handoff.files;
|
||||
if (received.length > 0) setFiles((waiting) => [...waiting, ...received]);
|
||||
if (received.length > 1 && (kind === "calendar" || kind === "contacts")) {
|
||||
void mergeImportFiles(kind, received).then(
|
||||
(merged) => setFiles((waiting) => [...waiting, merged]),
|
||||
() => setFiles((waiting) => [...waiting, ...received]),
|
||||
);
|
||||
} else if (received.length > 0) {
|
||||
setFiles((waiting) => [...waiting, ...received]);
|
||||
}
|
||||
}
|
||||
};
|
||||
take();
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability pass 2 in Files (pim-5, pim-p2-6, pim-p2-7, pim-p2-18) against a VM 106
|
||||
* Stalwart, 1.0 and 0.16. Every step that writes is read back with FileNode/get.
|
||||
*
|
||||
* - pim-p2-6: files dropped on the list go into the folder in view, files dropped on a
|
||||
* folder's row into that folder, with no overlay and no question.
|
||||
* - pim-p2-7: a saved .eml opens like a mail with "Import to a mail folder…"; a .csv
|
||||
* offers "Import into contacts…", which opens the contacts import.
|
||||
* - pim-5: at 1280×720 the ⋮ of the last row opens its menu beside the button, not over it.
|
||||
*
|
||||
* Account dso-ux-pim, password IT_DSO_PASSWORD. Every node made here is named
|
||||
* "UXP2-files <run> …" and removed afterwards. Nothing is imported.
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const FILENODE = 'urn:ietf:params:jmap:filenode';
|
||||
const RUN = Date.now().toString(36);
|
||||
const PREFIX = `UXP2-files ${RUN}`;
|
||||
|
||||
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
|
||||
|
||||
type Node = { id: string; name: string; parentId: string | null; blobId: string | null };
|
||||
|
||||
async function nodes(c: JmapClient): Promise<Node[]> {
|
||||
const r = await c.request([['FileNode/get', { accountId: c.filesAccountId, ids: null }, '0']], [CORE, FILENODE]);
|
||||
return r.methodResponses[0][1].list as Node[];
|
||||
}
|
||||
|
||||
async function ours(c: JmapClient): Promise<Node[]> {
|
||||
return (await nodes(c)).filter((n) => n.name.startsWith(PREFIX));
|
||||
}
|
||||
|
||||
async function cleanUp(c: JmapClient): Promise<void> {
|
||||
// Files first, then folders: a folder with children may be refused.
|
||||
for (let round = 0; round < 3; round++) {
|
||||
const all = await ours(c);
|
||||
if (all.length === 0) return;
|
||||
const parents = new Set(all.map((n) => n.parentId));
|
||||
const leaves = all.filter((n) => !parents.has(n.id)).map((n) => n.id);
|
||||
await c.request([['FileNode/set', { accountId: c.filesAccountId, destroy: leaves }, '0']], [CORE, FILENODE]);
|
||||
}
|
||||
}
|
||||
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
}
|
||||
|
||||
async function openFilesRoot(page: Page): Promise<void> {
|
||||
await page.goto('/files');
|
||||
await expect(page.getByRole('grid', { name: 'Files and folders' }).first()).toBeVisible({ timeout: 60000 });
|
||||
}
|
||||
|
||||
async function upload(page: Page, files: { name: string; text: string; mimeType?: string }[]): Promise<void> {
|
||||
await page.locator('input[type="file"][multiple]:not([webkitdirectory])').first().setInputFiles(
|
||||
files.map((f) => ({ name: f.name, mimeType: f.mimeType ?? 'text/plain', buffer: Buffer.from(f.text) })),
|
||||
);
|
||||
}
|
||||
|
||||
const row = (page: Page, name: string) => page.locator(`[data-resource="${name}"]`).first();
|
||||
|
||||
/** A drag from outside, as the browser sends it, ending in a drop on `target`. */
|
||||
async function dropFiles(page: Page, selector: string, files: { name: string; text: string }[]): Promise<void> {
|
||||
const transfer = await page.evaluateHandle((list) => {
|
||||
const dt = new DataTransfer();
|
||||
for (const f of list) dt.items.add(new File([f.text], f.name, { type: 'text/plain' }));
|
||||
return dt;
|
||||
}, files);
|
||||
const target = page.locator(selector).first();
|
||||
await target.dispatchEvent('dragenter', { dataTransfer: transfer });
|
||||
await target.dispatchEvent('dragover', { dataTransfer: transfer });
|
||||
// No choices on the Files screen.
|
||||
await expect(page.getByTestId('file-drop-overlay')).toHaveCount(0);
|
||||
await target.dispatchEvent('drop', { dataTransfer: transfer });
|
||||
}
|
||||
|
||||
const EML = [
|
||||
'From: Ben <ben@example.org>',
|
||||
'To: dso-ux-pim@example.org',
|
||||
`Subject: ${PREFIX} saved mail`,
|
||||
'Date: Tue, 03 Nov 2026 10:00:00 +0100',
|
||||
'Message-ID: <uxp2-files@example.org>',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Hello from a saved message.',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
const CSV = 'First Name,Last Name,E-mail Address\r\nAnna,Example,anna.uxp2@example.org\r\n';
|
||||
|
||||
test.describe('Files, usability pass 2', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.afterAll(async () => {
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
await cleanUp(a);
|
||||
});
|
||||
|
||||
test('a drop uploads where it lands, with no question (pim-p2-6)', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const folderName = `${PREFIX} folder`;
|
||||
const folderId = await a.createFileDirectory(folderName);
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await openFilesRoot(page);
|
||||
await expect(row(page, folderName)).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// On the list: the folder in view (the top of Files).
|
||||
await dropFiles(page, '[role="grid"][aria-label="Files and folders"]', [{ name: `${PREFIX} on list.txt`, text: 'list' }]);
|
||||
await expect.poll(async () => (await ours(a)).find((n) => n.name === `${PREFIX} on list.txt`)?.parentId ?? 'missing', { timeout: 30000 })
|
||||
.toBeNull();
|
||||
|
||||
// On the folder's row: into that folder.
|
||||
await dropFiles(page, `[data-resource="${folderName}"]`, [{ name: `${PREFIX} on row.txt`, text: 'row' }]);
|
||||
await expect.poll(async () => (await ours(a)).find((n) => n.name === `${PREFIX} on row.txt`)?.parentId ?? 'missing', { timeout: 30000 })
|
||||
.toBe(folderId);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('a saved mail opens as a mail, a .csv offers the contacts import (pim-p2-7)', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const eml = `${PREFIX} saved.eml`;
|
||||
const csv = `${PREFIX} contacts.csv`;
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await openFilesRoot(page);
|
||||
await upload(page, [{ name: eml, text: EML, mimeType: 'message/rfc822' }, { name: csv, text: CSV, mimeType: 'text/csv' }]);
|
||||
await expect.poll(async () => (await ours(a)).map((n) => n.name).filter((n) => n === eml || n === csv).sort(), { timeout: 30000 })
|
||||
.toEqual([csv, eml].sort());
|
||||
|
||||
await row(page, eml).dblclick();
|
||||
const preview = page.getByRole('dialog', { name: eml });
|
||||
await expect(preview).toBeVisible({ timeout: 30000 });
|
||||
await expect(preview.getByText(`${PREFIX} saved mail`).first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(preview.getByRole('button', { name: 'Import to a mail folder…' })).toBeVisible();
|
||||
await expect(page.getByText('There is no preview for this type of file.')).toHaveCount(0);
|
||||
await preview.getByRole('button', { name: 'Import to a mail folder…' }).click();
|
||||
await expect(page.getByTestId('import-eml-dialog')).toBeVisible({ timeout: 30000 });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('import-eml-dialog')).toBeHidden();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await row(page, csv).dblclick();
|
||||
const csvPreview = page.getByRole('dialog', { name: csv });
|
||||
await expect(csvPreview.getByRole('button', { name: 'Import into contacts…' })).toBeVisible({ timeout: 30000 });
|
||||
await csvPreview.getByRole('button', { name: 'Import into contacts…' }).click();
|
||||
await expect(page).toHaveURL(/\/contacts/, { timeout: 30000 });
|
||||
await expect(page.getByRole('dialog', { name: 'Import contacts' })).toBeVisible({ timeout: 30000 });
|
||||
await page.keyboard.press('Escape');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('the ⋮ of the last row opens beside its button at 1280×720 (pim-5)', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1280, height: 720 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await openFilesRoot(page);
|
||||
const rows = page.locator('[role="grid"][aria-label="Files and folders"] [data-resource]');
|
||||
await expect(rows.first()).toBeVisible({ timeout: 30000 });
|
||||
const last = rows.last();
|
||||
await last.scrollIntoViewIfNeeded();
|
||||
await last.hover();
|
||||
const kebab = last.locator('[data-item-action][title]').last();
|
||||
const button = (await kebab.boundingBox())!;
|
||||
await kebab.click();
|
||||
const menu = page.getByRole('menu').first();
|
||||
await expect(menu).toBeVisible();
|
||||
const box = (await menu.boundingBox())!;
|
||||
const overlaps = box.y < button.y + button.height && box.y + box.height > button.y
|
||||
&& box.x < button.x + button.width && box.x + box.width > button.x;
|
||||
expect(overlaps).toBe(false);
|
||||
await page.keyboard.press('Escape');
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fileDropZones, filesTakeDropsAsIs, type DropFiles, type DropPlace } from '../zones';
|
||||
|
||||
// Usability pass 2 (pim-p2-6, pim-p2-10).
|
||||
|
||||
const place = (patch: Partial<DropPlace> = {}): DropPlace => ({
|
||||
composer: false,
|
||||
inlineImages: false,
|
||||
popout: false,
|
||||
files: true,
|
||||
filesFolder: null,
|
||||
calendar: true,
|
||||
contacts: true,
|
||||
importFolder: null,
|
||||
offline: false,
|
||||
keepsOffline: false,
|
||||
maxUploadBytes: 0,
|
||||
maxMessageBytes: 0,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const drag = (kinds: DropFiles['kinds']): DropFiles => ({ count: kinds?.length ?? null, kinds, bytes: null });
|
||||
|
||||
describe('on the Files screen a drop uploads where you are, with no question (pim-p2-6)', () => {
|
||||
it('has only the upload into the folder in view, for any kind of file', () => {
|
||||
for (const kinds of [['other'], ['calendar'], ['eml', 'eml'], ['image']] as DropFiles['kinds'][]) {
|
||||
expect(fileDropZones(drag(kinds), place({ onFiles: true, filesFolder: 'Documents' }))).toEqual([{ id: 'upload', folder: 'Documents' }]);
|
||||
}
|
||||
expect(filesTakeDropsAsIs(place({ onFiles: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it('still asks with a composer open, and on every other screen', () => {
|
||||
expect(filesTakeDropsAsIs(place({ onFiles: true, composer: true }))).toBe(false);
|
||||
expect(fileDropZones(drag(['other']), place({ onFiles: true, composer: true })).map((z) => z.id)).toEqual(['attach-here', 'new-message', 'upload']);
|
||||
expect(filesTakeDropsAsIs(place())).toBe(false);
|
||||
expect(fileDropZones(drag(['other']), place()).map((z) => z.id)).toEqual(['new-message', 'upload']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('several .ics or .vcf can be added together (pim-p2-10)', () => {
|
||||
it('offers "Add all to calendar" for two .ics', () => {
|
||||
const zones = fileDropZones(drag(['calendar', 'calendar']), place());
|
||||
expect(zones[0]).toEqual({ id: 'calendar', several: true });
|
||||
});
|
||||
|
||||
it('offers "Add all to contacts" for three .vcf', () => {
|
||||
const zones = fileDropZones(drag(['contacts', 'contacts', 'contacts']), place());
|
||||
expect(zones[0]).toEqual({ id: 'contacts', several: true });
|
||||
});
|
||||
|
||||
it('keeps the plain choice for one file', () => {
|
||||
expect(fileDropZones(drag(['calendar']), place())[0]).toEqual({ id: 'calendar' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Several .ics or .vcf files added at once go into one import list (pim-p2-10): the import
|
||||
* dialogs read one file, so the files become one. vCards follow each other in a file as they
|
||||
* are. Calendars become one VCALENDAR: the first file's own lines (VERSION, PRODID, …), then
|
||||
* every file's components (VEVENT, VTODO, VTIMEZONE), in order.
|
||||
*/
|
||||
|
||||
const CRLF = '\r\n';
|
||||
|
||||
function lines(text: string): string[] {
|
||||
return text.replace(/\r\n?/g, '\n').split('\n');
|
||||
}
|
||||
|
||||
/** The component blocks of one VCALENDAR, and the calendar's own lines before the first of them. */
|
||||
function calendarParts(text: string): { head: string[]; components: string[] } {
|
||||
const all = lines(text);
|
||||
const begin = all.findIndex((line) => /^BEGIN:VCALENDAR\s*$/i.test(line));
|
||||
let end = -1;
|
||||
for (let i = all.length - 1; i >= 0; i--) {
|
||||
if (/^END:VCALENDAR\s*$/i.test(all[i])) { end = i; break; }
|
||||
}
|
||||
if (begin === -1 || end <= begin) return { head: [], components: [] };
|
||||
const inner = all.slice(begin + 1, end);
|
||||
const first = inner.findIndex((line) => /^BEGIN:/i.test(line));
|
||||
return first === -1
|
||||
? { head: inner.filter(Boolean), components: [] }
|
||||
: { head: inner.slice(0, first).filter(Boolean), components: inner.slice(first).filter(Boolean) };
|
||||
}
|
||||
|
||||
export function mergeCalendarTexts(texts: string[]): string {
|
||||
const parts = texts.map(calendarParts);
|
||||
const head = parts.find((part) => part.head.length > 0)?.head ?? ['VERSION:2.0'];
|
||||
return ['BEGIN:VCALENDAR', ...head, ...parts.flatMap((part) => part.components), 'END:VCALENDAR', ''].join(CRLF);
|
||||
}
|
||||
|
||||
export function mergeVCardTexts(texts: string[]): string {
|
||||
return texts.map((text) => text.replace(/\r\n?/g, '\n').trim()).filter(Boolean).join('\n').replace(/\n/g, CRLF) + CRLF;
|
||||
}
|
||||
|
||||
/** One file holding them all, named after the first; a single file stays as it is. */
|
||||
export async function mergeImportFiles(kind: 'calendar' | 'contacts', files: File[]): Promise<File> {
|
||||
if (files.length === 1) return files[0];
|
||||
const texts = await Promise.all(files.map((file) => file.text()));
|
||||
const [first] = files;
|
||||
return kind === 'calendar'
|
||||
? new File([mergeCalendarTexts(texts)], first.name, { type: 'text/calendar' })
|
||||
: new File([mergeVCardTexts(texts)], first.name, { type: 'text/vcard' });
|
||||
}
|
||||
+21
-2
@@ -38,6 +38,12 @@ export interface DropPlace {
|
||||
files: boolean;
|
||||
/** On the Files screen, the folder in view, when files can go there. */
|
||||
filesFolder: string | null;
|
||||
/**
|
||||
* The Files screen is on view and takes files where it is. With no composer open a drop
|
||||
* there uploads to the folder in view (or the folder row it lands on) without asking,
|
||||
* as Explorer and Finder do; the choices are for the other screens.
|
||||
*/
|
||||
onFiles?: boolean;
|
||||
calendar: boolean;
|
||||
contacts: boolean;
|
||||
/** The Contacts screen is open: a CSV dropped there is a contacts import. */
|
||||
@@ -61,6 +67,8 @@ export type FileDropNote =
|
||||
|
||||
export interface FileDropZone {
|
||||
id: FileDropZoneId;
|
||||
/** Calendar and contacts: several files, all added in one import ("Add all to calendar"). */
|
||||
several?: boolean;
|
||||
/** Names the folder for `upload` on the Files screen and for `import-eml`. */
|
||||
folder?: string;
|
||||
note?: FileDropNote;
|
||||
@@ -116,6 +124,11 @@ function serverOnly(place: DropPlace, zone: FileDropZone): FileDropZone {
|
||||
export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[] {
|
||||
const kind = sharedKind(files);
|
||||
const one = files.count === 1;
|
||||
// Files dropped or pasted on the Files screen go up into the folder in view, no question.
|
||||
if (filesTakeDropsAsIs(place)) {
|
||||
const note: FileDropNote | undefined = place.maxUploadBytes > 0 ? { key: 'note_limit_file', size: place.maxUploadBytes } : undefined;
|
||||
return [serverOnly(place, { id: 'upload', ...(place.filesFolder ? { folder: place.filesFolder } : {}), ...(note ? { note } : {}) })];
|
||||
}
|
||||
const main: FileDropZone[] = [];
|
||||
if (place.composer) main.push({ id: 'attach-here', ...mailNote(place) });
|
||||
if (!place.popout) main.push({ id: 'new-message', ...mailNote(place) });
|
||||
@@ -131,8 +144,9 @@ export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[
|
||||
if (!place.popout) {
|
||||
if (kind === 'eml' && one) special.push({ id: 'open-eml' });
|
||||
if (kind === 'eml' && place.importFolder) special.push(serverOnly(place, { id: 'import-eml', folder: place.importFolder }));
|
||||
if (kind === 'calendar' && one && place.calendar) special.push({ id: 'calendar' });
|
||||
if (kind === 'contacts' && one && place.contacts) special.push({ id: 'contacts' });
|
||||
// Several at once go into one import list (pim-p2-10).
|
||||
if (kind === 'calendar' && place.calendar) special.push({ id: 'calendar', ...(one || files.count === null ? {} : { several: true }) });
|
||||
if (kind === 'contacts' && place.contacts) special.push({ id: 'contacts', ...(one || files.count === null ? {} : { several: true }) });
|
||||
if (kind === 'csv' && one && place.contacts && place.contactsScreen) special.push({ id: 'contacts' });
|
||||
}
|
||||
|
||||
@@ -143,6 +157,11 @@ export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[
|
||||
return [...attachHere, ...kept, ...rest];
|
||||
}
|
||||
|
||||
/** The Files screen takes a drop as it is: on view, a folder that takes files, no composer open, not a pop-out. */
|
||||
export function filesTakeDropsAsIs(place: Pick<DropPlace, 'onFiles' | 'composer' | 'popout' | 'files'>): boolean {
|
||||
return !!place.onFiles && place.files && !place.composer && !place.popout;
|
||||
}
|
||||
|
||||
/** What the files may still do, once they're in hand: those too big for the zone stay out. */
|
||||
export function filesWithinLimits(
|
||||
zone: FileDropZoneId,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* What the app itself does with a file kept in Files (pim-p2-7): a saved message goes into a
|
||||
* mail folder, a .csv or .vcf into contacts, an .ics into the calendar, through the same
|
||||
* import dialogs a dropped file reaches.
|
||||
*/
|
||||
|
||||
export type FileAppAction = 'import-mail' | 'import-contacts' | 'add-calendar';
|
||||
|
||||
function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot >= 0 ? name.slice(dot).toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function fileAppAction(name: string, type?: string | null): FileAppAction | null {
|
||||
const extension = extensionOf(name);
|
||||
const mime = (type ?? '').split(';')[0].trim().toLowerCase();
|
||||
if (extension === '.eml' || mime === 'message/rfc822') return 'import-mail';
|
||||
if (extension === '.ics' || extension === '.ical' || mime === 'text/calendar') return 'add-calendar';
|
||||
if (extension === '.vcf' || extension === '.vcard' || mime === 'text/vcard' || mime === 'text/x-vcard') return 'import-contacts';
|
||||
if (extension === '.csv' || mime === 'text/csv') return 'import-contacts';
|
||||
return null;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDroppedFilesAndFolders } from '../drop-utils';
|
||||
|
||||
// A drop whose items have no file system entry (files a page put into a
|
||||
// DataTransfer itself) still hands over its files instead of nothing.
|
||||
|
||||
describe('getDroppedFilesAndFolders', () => {
|
||||
it('falls back to the files when no item has an entry', async () => {
|
||||
const file = new File(['x'], 'a.txt');
|
||||
const transfer = {
|
||||
items: [{ kind: 'file', webkitGetAsEntry: () => null }],
|
||||
files: [file],
|
||||
} as unknown as DataTransfer;
|
||||
expect(await getDroppedFilesAndFolders(transfer)).toEqual({ files: [file], hasDirectories: false });
|
||||
});
|
||||
});
|
||||
@@ -88,12 +88,15 @@ export async function getDroppedFilesAndFolders(dataTransfer: DataTransfer): Pro
|
||||
const items = dataTransfer.items;
|
||||
|
||||
// Check if the browser supports webkitGetAsEntry
|
||||
const entries: FileSystemEntry[] = [];
|
||||
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
|
||||
const entries: FileSystemEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
// Files a page made itself have no entries: they come through `files` below.
|
||||
if (entries.length > 0) {
|
||||
|
||||
let hasDirectories = false;
|
||||
const filePromises: Promise<FileWithPath[]>[] = [];
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "شاركت {name} معك",
|
||||
"share_mail_body": "مرحبًا،\n\nشاركت «{name}» معك. يمكنك فتحه من هنا:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "إضافة الكل إلى التقويم",
|
||||
"zone_contacts_all": "إضافة الكل إلى جهات الاتصال",
|
||||
"save_to_files": "حفظ في الملفات",
|
||||
"saved": "تم حفظ {name} في الملفات.",
|
||||
"saved_copy": "تم الحفظ في الملفات باسم {name}.",
|
||||
"save_failed": "تعذّر حفظ {name} في الملفات.",
|
||||
"changed_on_server": "تم تغيير {name} في الملفات بعد أن فتحته.",
|
||||
"gone_on_server": "لم يعد {name} موجودًا في الملفات.",
|
||||
"keep_both": "الاحتفاظ بالاثنين",
|
||||
"replace": "استبدال",
|
||||
"cancel": "إلغاء",
|
||||
"save_as_new": "حفظ كملف جديد",
|
||||
"import_to_mail": "استيراد إلى مجلد بريد…",
|
||||
"import_to_mail_title": "استيراد إلى مجلد بريد",
|
||||
"imported_to_mail": "تم الاستيراد إلى {folder}.",
|
||||
"import_to_mail_failed": "تعذّر استيراد {name}.",
|
||||
"import_contacts": "استيراد إلى جهات الاتصال…",
|
||||
"add_to_calendar": "إضافة إلى التقويم…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "شهاداتك",
|
||||
"your_certificates_desc": "استيراد وإدارة شهادات S/MIME الخاصة بك لتوقيع الرسائل وتشفيرها",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "He compartit {name} amb tu",
|
||||
"share_mail_body": "Hola,\n\nHe compartit «{name}» amb tu. El pots obrir aquí:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Afegeix-ho tot al calendari",
|
||||
"zone_contacts_all": "Afegeix-ho tot als contactes",
|
||||
"save_to_files": "Desa a Fitxers",
|
||||
"saved": "{name} s'ha desat a Fitxers.",
|
||||
"saved_copy": "S'ha desat a Fitxers com a {name}.",
|
||||
"save_failed": "No s'ha pogut desar {name} a Fitxers.",
|
||||
"changed_on_server": "{name} ha canviat a Fitxers després que l'obrissis.",
|
||||
"gone_on_server": "{name} ja no és a Fitxers.",
|
||||
"keep_both": "Conserva’ls tots dos",
|
||||
"replace": "Substitueix",
|
||||
"cancel": "Cancel·la",
|
||||
"save_as_new": "Desa com a fitxer nou",
|
||||
"import_to_mail": "Importa a una carpeta de correu…",
|
||||
"import_to_mail_title": "Importa a una carpeta de correu",
|
||||
"imported_to_mail": "S'ha importat a {folder}.",
|
||||
"import_to_mail_failed": "No s'ha pogut importar {name}.",
|
||||
"import_contacts": "Importa als contactes…",
|
||||
"add_to_calendar": "Afegeix al calendari…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Els vostres certificats",
|
||||
"your_certificates_desc": "Importeu i gestioneu els certificats S/MIME per signar i xifrar correus",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "Sdílel jsem s vámi {name}",
|
||||
"share_mail_body": "Dobrý den,\n\nsdílel jsem s vámi „{name}“. Otevřete to zde:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Přidat vše do kalendáře",
|
||||
"zone_contacts_all": "Přidat vše do kontaktů",
|
||||
"save_to_files": "Uložit do Souborů",
|
||||
"saved": "{name} uloženo do Souborů.",
|
||||
"saved_copy": "Uloženo do Souborů jako {name}.",
|
||||
"save_failed": "{name} se nepodařilo uložit do Souborů.",
|
||||
"changed_on_server": "{name} se v Souborech změnil poté, co jste ho otevřeli.",
|
||||
"gone_on_server": "{name} už v Souborech není.",
|
||||
"keep_both": "Ponechat obojí",
|
||||
"replace": "Nahradit",
|
||||
"cancel": "Zrušit",
|
||||
"save_as_new": "Uložit jako nový soubor",
|
||||
"import_to_mail": "Importovat do složky pošty…",
|
||||
"import_to_mail_title": "Importovat do složky pošty",
|
||||
"imported_to_mail": "Importováno do {folder}.",
|
||||
"import_to_mail_failed": "{name} se nepodařilo importovat.",
|
||||
"import_contacts": "Importovat do kontaktů…",
|
||||
"add_to_calendar": "Přidat do kalendáře…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Vaše certifikáty",
|
||||
"your_certificates_desc": "Importujte a spravujte své certifikáty S/MIME pro podepisování a šifrování e-mailů",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "Jeg har delt {name} med dig",
|
||||
"share_mail_body": "Hej\n\nJeg har delt \"{name}\" med dig. Du kan åbne den her:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Føj alle til kalender",
|
||||
"zone_contacts_all": "Føj alle til kontakter",
|
||||
"save_to_files": "Gem i Filer",
|
||||
"saved": "{name} er gemt i Filer.",
|
||||
"saved_copy": "Gemt i Filer som {name}.",
|
||||
"save_failed": "{name} kunne ikke gemmes i Filer.",
|
||||
"changed_on_server": "{name} blev ændret i Filer, efter du åbnede den.",
|
||||
"gone_on_server": "{name} findes ikke længere i Filer.",
|
||||
"keep_both": "Behold begge",
|
||||
"replace": "Erstat",
|
||||
"cancel": "Annuller",
|
||||
"save_as_new": "Gem som ny fil",
|
||||
"import_to_mail": "Importér til en mailmappe…",
|
||||
"import_to_mail_title": "Importér til en mailmappe",
|
||||
"imported_to_mail": "Importeret til {folder}.",
|
||||
"import_to_mail_failed": "{name} kunne ikke importeres.",
|
||||
"import_contacts": "Importér til kontakter…",
|
||||
"add_to_calendar": "Føj til kalender…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Dine certifikater",
|
||||
"your_certificates_desc": "Importér og administrér dine S/MIME-certifikater til signering og kryptering af e-mails",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "Ich habe {name} für Sie freigegeben",
|
||||
"share_mail_body": "Hallo,\n\nich habe „{name}“ für Sie freigegeben. Sie können es hier öffnen:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Alle zum Kalender hinzufügen",
|
||||
"zone_contacts_all": "Alle zu Kontakten hinzufügen",
|
||||
"save_to_files": "In Dateien speichern",
|
||||
"saved": "{name} in Dateien gespeichert.",
|
||||
"saved_copy": "In Dateien gespeichert als {name}.",
|
||||
"save_failed": "{name} konnte nicht in Dateien gespeichert werden.",
|
||||
"changed_on_server": "{name} wurde in Dateien geändert, nachdem Sie die Datei geöffnet haben.",
|
||||
"gone_on_server": "{name} ist nicht mehr in Dateien.",
|
||||
"keep_both": "Beide behalten",
|
||||
"replace": "Ersetzen",
|
||||
"cancel": "Abbrechen",
|
||||
"save_as_new": "Als neue Datei speichern",
|
||||
"import_to_mail": "In einen Mailordner importieren…",
|
||||
"import_to_mail_title": "In einen Mailordner importieren",
|
||||
"imported_to_mail": "In {folder} importiert.",
|
||||
"import_to_mail_failed": "{name} konnte nicht importiert werden.",
|
||||
"import_contacts": "In Kontakte importieren…",
|
||||
"add_to_calendar": "Zum Kalender hinzufügen…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ihre Zertifikate",
|
||||
"your_certificates_desc": "Importieren und verwalten Sie Ihre S/MIME-Zertifikate zum Signieren und Verschlüsseln von E-Mails",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "I shared {name} with you",
|
||||
"share_mail_body": "Hi,\n\nI shared \"{name}\" with you. You can open it here:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Add all to calendar",
|
||||
"zone_contacts_all": "Add all to contacts",
|
||||
"save_to_files": "Save to Files",
|
||||
"saved": "{name} saved to Files.",
|
||||
"saved_copy": "Saved to Files as {name}.",
|
||||
"save_failed": "{name} could not be saved to Files.",
|
||||
"changed_on_server": "{name} was changed in Files after you opened it.",
|
||||
"gone_on_server": "{name} is no longer in Files.",
|
||||
"keep_both": "Keep both",
|
||||
"replace": "Replace",
|
||||
"cancel": "Cancel",
|
||||
"save_as_new": "Save as a new file",
|
||||
"import_to_mail": "Import to a mail folder…",
|
||||
"import_to_mail_title": "Import to a mail folder",
|
||||
"imported_to_mail": "Imported into {folder}.",
|
||||
"import_to_mail_failed": "{name} could not be imported.",
|
||||
"import_contacts": "Import into contacts…",
|
||||
"add_to_calendar": "Add to calendar…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Your Certificates",
|
||||
"your_certificates_desc": "Import and manage your S/MIME certificates for signing and encrypting emails",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "He compartido {name} contigo",
|
||||
"share_mail_body": "Hola:\n\nHe compartido «{name}» contigo. Puedes abrirlo aquí:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Añadir todo al calendario",
|
||||
"zone_contacts_all": "Añadir todo a contactos",
|
||||
"save_to_files": "Guardar en Archivos",
|
||||
"saved": "{name} se guardó en Archivos.",
|
||||
"saved_copy": "Guardado en Archivos como {name}.",
|
||||
"save_failed": "No se pudo guardar {name} en Archivos.",
|
||||
"changed_on_server": "{name} cambió en Archivos después de que lo abrieras.",
|
||||
"gone_on_server": "{name} ya no está en Archivos.",
|
||||
"keep_both": "Conservar ambos",
|
||||
"replace": "Reemplazar",
|
||||
"cancel": "Cancelar",
|
||||
"save_as_new": "Guardar como archivo nuevo",
|
||||
"import_to_mail": "Importar a una carpeta de correo…",
|
||||
"import_to_mail_title": "Importar a una carpeta de correo",
|
||||
"imported_to_mail": "Importado en {folder}.",
|
||||
"import_to_mail_failed": "No se pudo importar {name}.",
|
||||
"import_contacts": "Importar a contactos…",
|
||||
"add_to_calendar": "Añadir al calendario…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Tus certificados",
|
||||
"your_certificates_desc": "Importa y administra tus certificados S/MIME para firmar y cifrar correos",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "Jagasin sinuga faili {name}",
|
||||
"share_mail_body": "Tere\n\nJagasin sinuga „{name}“. Saad selle avada siin:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Lisa kõik kalendrisse",
|
||||
"zone_contacts_all": "Lisa kõik kontaktidesse",
|
||||
"save_to_files": "Salvesta kausta Failid",
|
||||
"saved": "{name} salvestati kausta Failid.",
|
||||
"saved_copy": "Salvestati kausta Failid nimega {name}.",
|
||||
"save_failed": "Faili {name} ei saanud kausta Failid salvestada.",
|
||||
"changed_on_server": "Faili {name} muudeti kaustas Failid pärast seda, kui selle avasid.",
|
||||
"gone_on_server": "Faili {name} pole enam kaustas Failid.",
|
||||
"keep_both": "Jäta mõlemad",
|
||||
"replace": "Asenda",
|
||||
"cancel": "Tühista",
|
||||
"save_as_new": "Salvesta uue failina",
|
||||
"import_to_mail": "Impordi meilikausta…",
|
||||
"import_to_mail_title": "Impordi meilikausta",
|
||||
"imported_to_mail": "Imporditi kausta {folder}.",
|
||||
"import_to_mail_failed": "Faili {name} ei saanud importida.",
|
||||
"import_contacts": "Impordi kontaktidesse…",
|
||||
"add_to_calendar": "Lisa kalendrisse…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Sinu sertifikaadid",
|
||||
"your_certificates_desc": "Impordi ja halda oma S/MIME sertifikaate e-kirjade allkirjastamiseks ja krüpteerimiseks",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "{name} را با شما به اشتراک گذاشتم",
|
||||
"share_mail_body": "سلام،\n\n«{name}» را با شما به اشتراک گذاشتم. میتوانید آن را اینجا باز کنید:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "افزودن همه به تقویم",
|
||||
"zone_contacts_all": "افزودن همه به مخاطبان",
|
||||
"save_to_files": "ذخیره در فایلها",
|
||||
"saved": "{name} در فایلها ذخیره شد.",
|
||||
"saved_copy": "با نام {name} در فایلها ذخیره شد.",
|
||||
"save_failed": "ذخیرهٔ {name} در فایلها ممکن نشد.",
|
||||
"changed_on_server": "{name} پس از اینکه آن را باز کردید در فایلها تغییر کرد.",
|
||||
"gone_on_server": "{name} دیگر در فایلها نیست.",
|
||||
"keep_both": "نگهداشتن هر دو",
|
||||
"replace": "جایگزینی",
|
||||
"cancel": "انصراف",
|
||||
"save_as_new": "ذخیره بهعنوان فایل جدید",
|
||||
"import_to_mail": "وارد کردن به یک پوشهٔ ایمیل…",
|
||||
"import_to_mail_title": "وارد کردن به یک پوشهٔ ایمیل",
|
||||
"imported_to_mail": "به {folder} وارد شد.",
|
||||
"import_to_mail_failed": "وارد کردن {name} ممکن نشد.",
|
||||
"import_contacts": "وارد کردن به مخاطبان…",
|
||||
"add_to_calendar": "افزودن به تقویم…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "گواهیهای شما",
|
||||
"your_certificates_desc": "مدیریت گواهیهای S/MIME",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "J’ai partagé {name} avec vous",
|
||||
"share_mail_body": "Bonjour,\n\nJ’ai partagé « {name} » avec vous. Vous pouvez l’ouvrir ici :\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Tout ajouter au calendrier",
|
||||
"zone_contacts_all": "Tout ajouter aux contacts",
|
||||
"save_to_files": "Enregistrer dans Fichiers",
|
||||
"saved": "{name} enregistré dans Fichiers.",
|
||||
"saved_copy": "Enregistré dans Fichiers sous {name}.",
|
||||
"save_failed": "Impossible d'enregistrer {name} dans Fichiers.",
|
||||
"changed_on_server": "{name} a été modifié dans Fichiers après que vous l'avez ouvert.",
|
||||
"gone_on_server": "{name} n'est plus dans Fichiers.",
|
||||
"keep_both": "Conserver les deux",
|
||||
"replace": "Remplacer",
|
||||
"cancel": "Annuler",
|
||||
"save_as_new": "Enregistrer comme nouveau fichier",
|
||||
"import_to_mail": "Importer dans un dossier de messagerie…",
|
||||
"import_to_mail_title": "Importer dans un dossier de messagerie",
|
||||
"imported_to_mail": "Importé dans {folder}.",
|
||||
"import_to_mail_failed": "Impossible d'importer {name}.",
|
||||
"import_contacts": "Importer dans les contacts…",
|
||||
"add_to_calendar": "Ajouter au calendrier…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Vos certificats",
|
||||
"your_certificates_desc": "Importez et gérez vos certificats S/MIME pour signer et chiffrer les e-mails",
|
||||
|
||||
@@ -5425,6 +5425,26 @@
|
||||
"share_mail_subject": "שיתפתי איתך את {name}",
|
||||
"share_mail_body": "שלום,\n\nשיתפתי איתך את \"{name}\". אפשר לפתוח אותו כאן:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "הוספת הכול ללוח השנה",
|
||||
"zone_contacts_all": "הוספת הכול לאנשי הקשר",
|
||||
"save_to_files": "שמירה בקבצים",
|
||||
"saved": "{name} נשמר בקבצים.",
|
||||
"saved_copy": "נשמר בקבצים בשם {name}.",
|
||||
"save_failed": "לא ניתן היה לשמור את {name} בקבצים.",
|
||||
"changed_on_server": "{name} השתנה בקבצים אחרי שפתחת אותו.",
|
||||
"gone_on_server": "{name} כבר לא נמצא בקבצים.",
|
||||
"keep_both": "שמירת שניהם",
|
||||
"replace": "החלפה",
|
||||
"cancel": "ביטול",
|
||||
"save_as_new": "שמירה כקובץ חדש",
|
||||
"import_to_mail": "ייבוא לתיקיית דואר…",
|
||||
"import_to_mail_title": "ייבוא לתיקיית דואר",
|
||||
"imported_to_mail": "יובא אל {folder}.",
|
||||
"import_to_mail_failed": "לא ניתן היה לייבא את {name}.",
|
||||
"import_contacts": "ייבוא לאנשי הקשר…",
|
||||
"add_to_calendar": "הוספה ללוח השנה…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "התעודות שלך",
|
||||
"your_certificates_desc": "ייבא ונהל את אישורי ה-S/MIME שלך עבור חתימה והצפנה של הודעות דוא\"ל",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "Megosztottam veled: {name}",
|
||||
"share_mail_body": "Szia!\n\nMegosztottam veled ezt: „{name}”. Itt tudod megnyitni:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Összes hozzáadása a naptárhoz",
|
||||
"zone_contacts_all": "Összes hozzáadása a névjegyekhez",
|
||||
"save_to_files": "Mentés a Fájlokba",
|
||||
"saved": "{name} mentve a Fájlokba.",
|
||||
"saved_copy": "Mentve a Fájlokba {name} néven.",
|
||||
"save_failed": "{name} nem menthető a Fájlokba.",
|
||||
"changed_on_server": "{name} megváltozott a Fájlokban, miután megnyitottad.",
|
||||
"gone_on_server": "{name} már nincs a Fájlokban.",
|
||||
"keep_both": "Mindkettő megtartása",
|
||||
"replace": "Csere",
|
||||
"cancel": "Mégse",
|
||||
"save_as_new": "Mentés új fájlként",
|
||||
"import_to_mail": "Importálás levélmappába…",
|
||||
"import_to_mail_title": "Importálás levélmappába",
|
||||
"imported_to_mail": "Importálva ide: {folder}.",
|
||||
"import_to_mail_failed": "{name} nem importálható.",
|
||||
"import_contacts": "Importálás a névjegyekbe…",
|
||||
"add_to_calendar": "Hozzáadás a naptárhoz…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Tanúsítványaid",
|
||||
"your_certificates_desc": "S/MIME tanúsítványok importálása és kezelése e-mailek aláírásához és titkosításához",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "Ho condiviso {name} con te",
|
||||
"share_mail_body": "Ciao,\n\nho condiviso «{name}» con te. Puoi aprirlo qui:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Aggiungi tutto al calendario",
|
||||
"zone_contacts_all": "Aggiungi tutto ai contatti",
|
||||
"save_to_files": "Salva in File",
|
||||
"saved": "{name} salvato in File.",
|
||||
"saved_copy": "Salvato in File come {name}.",
|
||||
"save_failed": "Impossibile salvare {name} in File.",
|
||||
"changed_on_server": "{name} è stato modificato in File dopo che lo hai aperto.",
|
||||
"gone_on_server": "{name} non è più in File.",
|
||||
"keep_both": "Mantieni entrambi",
|
||||
"replace": "Sostituisci",
|
||||
"cancel": "Annulla",
|
||||
"save_as_new": "Salva come nuovo file",
|
||||
"import_to_mail": "Importa in una cartella di posta…",
|
||||
"import_to_mail_title": "Importa in una cartella di posta",
|
||||
"imported_to_mail": "Importato in {folder}.",
|
||||
"import_to_mail_failed": "Impossibile importare {name}.",
|
||||
"import_contacts": "Importa nei contatti…",
|
||||
"add_to_calendar": "Aggiungi al calendario…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "I tuoi certificati",
|
||||
"your_certificates_desc": "Importa e gestisci i tuoi certificati S/MIME per firmare e cifrare le email",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "{name} を共有しました",
|
||||
"share_mail_body": "こんにちは。\n\n「{name}」を共有しました。こちらから開けます:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "すべてカレンダーに追加",
|
||||
"zone_contacts_all": "すべて連絡先に追加",
|
||||
"save_to_files": "ファイルに保存",
|
||||
"saved": "{name} をファイルに保存しました。",
|
||||
"saved_copy": "{name} としてファイルに保存しました。",
|
||||
"save_failed": "{name} をファイルに保存できませんでした。",
|
||||
"changed_on_server": "{name} は開いた後にファイルで変更されました。",
|
||||
"gone_on_server": "{name} はファイルにもうありません。",
|
||||
"keep_both": "両方残す",
|
||||
"replace": "置き換える",
|
||||
"cancel": "キャンセル",
|
||||
"save_as_new": "新しいファイルとして保存",
|
||||
"import_to_mail": "メールフォルダーにインポート…",
|
||||
"import_to_mail_title": "メールフォルダーにインポート",
|
||||
"imported_to_mail": "{folder} にインポートしました。",
|
||||
"import_to_mail_failed": "{name} をインポートできませんでした。",
|
||||
"import_contacts": "連絡先にインポート…",
|
||||
"add_to_calendar": "カレンダーに追加…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "あなたの証明書",
|
||||
"your_certificates_desc": "メールの署名と暗号化のために S/MIME 証明書をインポートして管理します",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "{name}을(를) 공유했습니다",
|
||||
"share_mail_body": "안녕하세요.\n\n\"{name}\"을(를) 공유했습니다. 여기에서 열 수 있습니다:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "모두 캘린더에 추가",
|
||||
"zone_contacts_all": "모두 연락처에 추가",
|
||||
"save_to_files": "파일에 저장",
|
||||
"saved": "{name}을(를) 파일에 저장했습니다.",
|
||||
"saved_copy": "파일에 {name}(으)로 저장했습니다.",
|
||||
"save_failed": "{name}을(를) 파일에 저장하지 못했습니다.",
|
||||
"changed_on_server": "{name}이(가) 연 뒤에 파일에서 변경되었습니다.",
|
||||
"gone_on_server": "{name}이(가) 더 이상 파일에 없습니다.",
|
||||
"keep_both": "둘 다 유지",
|
||||
"replace": "바꾸기",
|
||||
"cancel": "취소",
|
||||
"save_as_new": "새 파일로 저장",
|
||||
"import_to_mail": "메일 폴더로 가져오기…",
|
||||
"import_to_mail_title": "메일 폴더로 가져오기",
|
||||
"imported_to_mail": "{folder}(으)로 가져왔습니다.",
|
||||
"import_to_mail_failed": "{name}을(를) 가져오지 못했습니다.",
|
||||
"import_contacts": "연락처로 가져오기…",
|
||||
"add_to_calendar": "캘린더에 추가…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "내 인증서",
|
||||
"your_certificates_desc": "이메일 서명과 암호화를 위한 S/MIME 인증서를 가져오고 관리해요",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "Es koplietoju ar jums {name}",
|
||||
"share_mail_body": "Sveiki!\n\nEs koplietoju ar jums “{name}”. To var atvērt šeit:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Pievienot visus kalendāram",
|
||||
"zone_contacts_all": "Pievienot visus kontaktiem",
|
||||
"save_to_files": "Saglabāt failos",
|
||||
"saved": "{name} saglabāts failos.",
|
||||
"saved_copy": "Saglabāts failos kā {name}.",
|
||||
"save_failed": "{name} neizdevās saglabāt failos.",
|
||||
"changed_on_server": "{name} tika mainīts failos pēc tam, kad to atvērāt.",
|
||||
"gone_on_server": "{name} vairs nav failos.",
|
||||
"keep_both": "Paturēt abus",
|
||||
"replace": "Aizstāt",
|
||||
"cancel": "Atcelt",
|
||||
"save_as_new": "Saglabāt kā jaunu failu",
|
||||
"import_to_mail": "Importēt pasta mapē…",
|
||||
"import_to_mail_title": "Importēt pasta mapē",
|
||||
"imported_to_mail": "Importēts mapē {folder}.",
|
||||
"import_to_mail_failed": "{name} neizdevās importēt.",
|
||||
"import_contacts": "Importēt kontaktos…",
|
||||
"add_to_calendar": "Pievienot kalendāram…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Jūsu sertifikāti",
|
||||
"your_certificates_desc": "Importējiet un pārvaldiet savus S/MIME sertifikātus parakstīšanai un šifrēšanai",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "Би {name}-г тантай хуваалцлаа",
|
||||
"share_mail_body": "Сайн байна уу,\n\nБи \"{name}\"-г тантай хуваалцлаа. Эндээс нээж болно:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Бүгдийг хуанлид нэмэх",
|
||||
"zone_contacts_all": "Бүгдийг харилцагчид нэмэх",
|
||||
"save_to_files": "Файлд хадгалах",
|
||||
"saved": "{name}-г Файлд хадгаллаа.",
|
||||
"saved_copy": "Файлд {name} нэрээр хадгаллаа.",
|
||||
"save_failed": "{name}-г Файлд хадгалж чадсангүй.",
|
||||
"changed_on_server": "{name}-г нээсний дараа Файлд өөрчилсөн байна.",
|
||||
"gone_on_server": "{name} Файлд байхгүй болсон.",
|
||||
"keep_both": "Хоёуланг нь хадгалах",
|
||||
"replace": "Солих",
|
||||
"cancel": "Цуцлах",
|
||||
"save_as_new": "Шинэ файлаар хадгалах",
|
||||
"import_to_mail": "Шуудангийн хавтас руу импортлох…",
|
||||
"import_to_mail_title": "Шуудангийн хавтас руу импортлох",
|
||||
"imported_to_mail": "{folder} руу импортлолоо.",
|
||||
"import_to_mail_failed": "{name}-г импортлож чадсангүй.",
|
||||
"import_contacts": "Харилцагчид импортлох…",
|
||||
"add_to_calendar": "Хуанлид нэмэх…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Таны сертификатууд",
|
||||
"your_certificates_desc": "Имэйлд гарын үсэг зурах болон шифрлэхэд ашиглах S/MIME сертификатаа импортолж, удирдана",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "Jeg har delt {name} med deg",
|
||||
"share_mail_body": "Hei,\n\nJeg har delt «{name}» med deg. Du kan åpne den her:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Legg alle til i kalenderen",
|
||||
"zone_contacts_all": "Legg alle til i kontakter",
|
||||
"save_to_files": "Lagre i Filer",
|
||||
"saved": "{name} er lagret i Filer.",
|
||||
"saved_copy": "Lagret i Filer som {name}.",
|
||||
"save_failed": "Kunne ikke lagre {name} i Filer.",
|
||||
"changed_on_server": "{name} ble endret i Filer etter at du åpnet den.",
|
||||
"gone_on_server": "{name} finnes ikke lenger i Filer.",
|
||||
"keep_both": "Behold begge",
|
||||
"replace": "Erstatt",
|
||||
"cancel": "Avbryt",
|
||||
"save_as_new": "Lagre som ny fil",
|
||||
"import_to_mail": "Importer til en e-postmappe…",
|
||||
"import_to_mail_title": "Importer til en e-postmappe",
|
||||
"imported_to_mail": "Importert til {folder}.",
|
||||
"import_to_mail_failed": "Kunne ikke importere {name}.",
|
||||
"import_contacts": "Importer til kontakter…",
|
||||
"add_to_calendar": "Legg til i kalenderen…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Dine sertifikater",
|
||||
"your_certificates_desc": "Importer og administrer S/MIME-sertifikatene dine for signering og kryptering av e-post",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "Ik heb {name} met je gedeeld",
|
||||
"share_mail_body": "Hoi,\n\nIk heb \"{name}\" met je gedeeld. Je kunt het hier openen:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Alles aan agenda toevoegen",
|
||||
"zone_contacts_all": "Alles aan contacten toevoegen",
|
||||
"save_to_files": "Opslaan in Bestanden",
|
||||
"saved": "{name} opgeslagen in Bestanden.",
|
||||
"saved_copy": "Opgeslagen in Bestanden als {name}.",
|
||||
"save_failed": "{name} kon niet in Bestanden worden opgeslagen.",
|
||||
"changed_on_server": "{name} is in Bestanden gewijzigd nadat je het opende.",
|
||||
"gone_on_server": "{name} staat niet meer in Bestanden.",
|
||||
"keep_both": "Beide behouden",
|
||||
"replace": "Vervangen",
|
||||
"cancel": "Annuleren",
|
||||
"save_as_new": "Opslaan als nieuw bestand",
|
||||
"import_to_mail": "Importeren in een mailmap…",
|
||||
"import_to_mail_title": "Importeren in een mailmap",
|
||||
"imported_to_mail": "Geïmporteerd in {folder}.",
|
||||
"import_to_mail_failed": "{name} kon niet worden geïmporteerd.",
|
||||
"import_contacts": "Importeren in contacten…",
|
||||
"add_to_calendar": "Aan agenda toevoegen…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Uw certificaten",
|
||||
"your_certificates_desc": "Importeer en beheer uw S/MIME-certificaten voor het ondertekenen en versleutelen van e-mails",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "Udostępniłem Ci {name}",
|
||||
"share_mail_body": "Cześć,\n\nUdostępniłem Ci „{name}”. Możesz to otworzyć tutaj:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Dodaj wszystko do kalendarza",
|
||||
"zone_contacts_all": "Dodaj wszystko do kontaktów",
|
||||
"save_to_files": "Zapisz w Plikach",
|
||||
"saved": "Zapisano {name} w Plikach.",
|
||||
"saved_copy": "Zapisano w Plikach jako {name}.",
|
||||
"save_failed": "Nie udało się zapisać {name} w Plikach.",
|
||||
"changed_on_server": "{name} zmieniono w Plikach po tym, jak go otworzono.",
|
||||
"gone_on_server": "{name} nie ma już w Plikach.",
|
||||
"keep_both": "Zachowaj oba",
|
||||
"replace": "Zastąp",
|
||||
"cancel": "Anuluj",
|
||||
"save_as_new": "Zapisz jako nowy plik",
|
||||
"import_to_mail": "Importuj do folderu poczty…",
|
||||
"import_to_mail_title": "Importuj do folderu poczty",
|
||||
"imported_to_mail": "Zaimportowano do {folder}.",
|
||||
"import_to_mail_failed": "Nie udało się zaimportować {name}.",
|
||||
"import_contacts": "Importuj do kontaktów…",
|
||||
"add_to_calendar": "Dodaj do kalendarza…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Twoje certyfikaty",
|
||||
"your_certificates_desc": "Importuj i zarządzaj swoimi certyfikatami S/MIME do podpisywania i szyfrowania wiadomości e-mail",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "Compartilhei {name} com você",
|
||||
"share_mail_body": "Olá,\n\nCompartilhei \"{name}\" com você. Você pode abrir aqui:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Adicionar tudo ao calendário",
|
||||
"zone_contacts_all": "Adicionar tudo aos contactos",
|
||||
"save_to_files": "Guardar em Arquivos",
|
||||
"saved": "{name} guardado em Arquivos.",
|
||||
"saved_copy": "Guardado em Arquivos como {name}.",
|
||||
"save_failed": "Não foi possível guardar {name} em Arquivos.",
|
||||
"changed_on_server": "{name} foi alterado em Arquivos depois de o abrir.",
|
||||
"gone_on_server": "{name} já não está em Arquivos.",
|
||||
"keep_both": "Manter ambos",
|
||||
"replace": "Substituir",
|
||||
"cancel": "Cancelar",
|
||||
"save_as_new": "Guardar como novo ficheiro",
|
||||
"import_to_mail": "Importar para uma pasta de correio…",
|
||||
"import_to_mail_title": "Importar para uma pasta de correio",
|
||||
"imported_to_mail": "Importado para {folder}.",
|
||||
"import_to_mail_failed": "Não foi possível importar {name}.",
|
||||
"import_contacts": "Importar para os contactos…",
|
||||
"add_to_calendar": "Adicionar ao calendário…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Seus certificados",
|
||||
"your_certificates_desc": "Importe e gerencie seus certificados S/MIME para assinar e criptografar e-mails",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "Am partajat {name} cu tine",
|
||||
"share_mail_body": "Salut,\n\nAm partajat „{name}” cu tine. Îl poți deschide aici:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Adăugați tot în calendar",
|
||||
"zone_contacts_all": "Adăugați tot la contacte",
|
||||
"save_to_files": "Salvați în Fișiere",
|
||||
"saved": "{name} a fost salvat în Fișiere.",
|
||||
"saved_copy": "Salvat în Fișiere ca {name}.",
|
||||
"save_failed": "{name} nu a putut fi salvat în Fișiere.",
|
||||
"changed_on_server": "{name} a fost modificat în Fișiere după ce l-ați deschis.",
|
||||
"gone_on_server": "{name} nu mai este în Fișiere.",
|
||||
"keep_both": "Păstrează-le pe ambele",
|
||||
"replace": "Înlocuiește",
|
||||
"cancel": "Anulează",
|
||||
"save_as_new": "Salvați ca fișier nou",
|
||||
"import_to_mail": "Importați într-un dosar de e-mail…",
|
||||
"import_to_mail_title": "Importați într-un dosar de e-mail",
|
||||
"imported_to_mail": "Importat în {folder}.",
|
||||
"import_to_mail_failed": "{name} nu a putut fi importat.",
|
||||
"import_contacts": "Importați în contacte…",
|
||||
"add_to_calendar": "Adăugați în calendar…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Certificatele dvs.",
|
||||
"your_certificates_desc": "Importați și gestionați certificatele S/MIME pentru semnarea și criptarea e-mailurilor",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "Я открыл вам доступ к {name}",
|
||||
"share_mail_body": "Здравствуйте!\n\nЯ открыл вам доступ к «{name}». Открыть можно здесь:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Добавить все в календарь",
|
||||
"zone_contacts_all": "Добавить все в контакты",
|
||||
"save_to_files": "Сохранить в Файлы",
|
||||
"saved": "{name} сохранён в Файлы.",
|
||||
"saved_copy": "Сохранено в Файлы как {name}.",
|
||||
"save_failed": "Не удалось сохранить {name} в Файлы.",
|
||||
"changed_on_server": "{name} изменили в Файлах после того, как вы его открыли.",
|
||||
"gone_on_server": "{name} больше нет в Файлах.",
|
||||
"keep_both": "Оставить оба",
|
||||
"replace": "Заменить",
|
||||
"cancel": "Отмена",
|
||||
"save_as_new": "Сохранить как новый файл",
|
||||
"import_to_mail": "Импортировать в почтовую папку…",
|
||||
"import_to_mail_title": "Импортировать в почтовую папку",
|
||||
"imported_to_mail": "Импортировано в {folder}.",
|
||||
"import_to_mail_failed": "Не удалось импортировать {name}.",
|
||||
"import_contacts": "Импортировать в контакты…",
|
||||
"add_to_calendar": "Добавить в календарь…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ваши сертификаты",
|
||||
"your_certificates_desc": "Импортируйте и управляйте своими сертификатами S/MIME для подписи и шифрования писем",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "Zdieľal som s vami {name}",
|
||||
"share_mail_body": "Dobrý deň,\n\nzdieľal som s vami „{name}“. Otvoríte to tu:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Pridať všetko do kalendára",
|
||||
"zone_contacts_all": "Pridať všetko do kontaktov",
|
||||
"save_to_files": "Uložiť do Súborov",
|
||||
"saved": "{name} sa uložil do Súborov.",
|
||||
"saved_copy": "Uložené do Súborov ako {name}.",
|
||||
"save_failed": "{name} sa nepodarilo uložiť do Súborov.",
|
||||
"changed_on_server": "{name} sa v Súboroch zmenil potom, ako ste ho otvorili.",
|
||||
"gone_on_server": "{name} už v Súboroch nie je.",
|
||||
"keep_both": "Ponechať oboje",
|
||||
"replace": "Nahradiť",
|
||||
"cancel": "Zrušiť",
|
||||
"save_as_new": "Uložiť ako nový súbor",
|
||||
"import_to_mail": "Importovať do priečinka pošty…",
|
||||
"import_to_mail_title": "Importovať do priečinka pošty",
|
||||
"imported_to_mail": "Importované do {folder}.",
|
||||
"import_to_mail_failed": "{name} sa nepodarilo importovať.",
|
||||
"import_contacts": "Importovať do kontaktov…",
|
||||
"add_to_calendar": "Pridať do kalendára…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Vaše certifikáty",
|
||||
"your_certificates_desc": "Importujte a spravujte svoje certifikáty S/MIME na podpisovanie a šifrovanie e-mailov",
|
||||
|
||||
@@ -2694,6 +2694,26 @@
|
||||
"share_mail_subject": "ฉันแชร์ {name} กับคุณ",
|
||||
"share_mail_body": "สวัสดี\n\nฉันแชร์ \"{name}\" กับคุณแล้ว เปิดได้ที่นี่:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "เพิ่มทั้งหมดในปฏิทิน",
|
||||
"zone_contacts_all": "เพิ่มทั้งหมดในรายชื่อติดต่อ",
|
||||
"save_to_files": "บันทึกลงในไฟล์",
|
||||
"saved": "บันทึก {name} ลงในไฟล์แล้ว",
|
||||
"saved_copy": "บันทึกลงในไฟล์เป็น {name} แล้ว",
|
||||
"save_failed": "บันทึก {name} ลงในไฟล์ไม่ได้",
|
||||
"changed_on_server": "{name} ถูกเปลี่ยนในไฟล์หลังจากที่คุณเปิด",
|
||||
"gone_on_server": "{name} ไม่อยู่ในไฟล์แล้ว",
|
||||
"keep_both": "เก็บทั้งสอง",
|
||||
"replace": "แทนที่",
|
||||
"cancel": "ยกเลิก",
|
||||
"save_as_new": "บันทึกเป็นไฟล์ใหม่",
|
||||
"import_to_mail": "นำเข้าไปยังโฟลเดอร์อีเมล…",
|
||||
"import_to_mail_title": "นำเข้าไปยังโฟลเดอร์อีเมล",
|
||||
"imported_to_mail": "นำเข้าไปยัง {folder} แล้ว",
|
||||
"import_to_mail_failed": "นำเข้า {name} ไม่ได้",
|
||||
"import_contacts": "นำเข้าไปยังรายชื่อติดต่อ…",
|
||||
"add_to_calendar": "เพิ่มในปฏิทิน…"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "แอปพลิเคชันเริ่มต้น",
|
||||
"description": "เลือกว่าจะเปิดลิงก์อีเมลและปฏิทินใน {appName} หรือไม่",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "{name} dosyasını seninle paylaştım",
|
||||
"share_mail_body": "Merhaba,\n\n\"{name}\" dosyasını seninle paylaştım. Buradan açabilirsin:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Tümünü takvime ekle",
|
||||
"zone_contacts_all": "Tümünü kişilere ekle",
|
||||
"save_to_files": "Dosyalar'a kaydet",
|
||||
"saved": "{name}, Dosyalar'a kaydedildi.",
|
||||
"saved_copy": "Dosyalar'a {name} olarak kaydedildi.",
|
||||
"save_failed": "{name}, Dosyalar'a kaydedilemedi.",
|
||||
"changed_on_server": "{name}, siz açtıktan sonra Dosyalar'da değiştirildi.",
|
||||
"gone_on_server": "{name} artık Dosyalar'da yok.",
|
||||
"keep_both": "İkisini de tut",
|
||||
"replace": "Değiştir",
|
||||
"cancel": "İptal",
|
||||
"save_as_new": "Yeni dosya olarak kaydet",
|
||||
"import_to_mail": "Bir posta klasörüne aktar…",
|
||||
"import_to_mail_title": "Bir posta klasörüne aktar",
|
||||
"imported_to_mail": "{folder} klasörüne aktarıldı.",
|
||||
"import_to_mail_failed": "{name} aktarılamadı.",
|
||||
"import_contacts": "Kişilere aktar…",
|
||||
"add_to_calendar": "Takvime ekle…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Sertifikalarınız",
|
||||
"your_certificates_desc": "E-postaları imzalamak ve şifrelemek için S/MIME sertifikalarınızı içe aktarın ve yönetin",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "Я надав вам доступ до {name}",
|
||||
"share_mail_body": "Вітаю!\n\nЯ надав вам доступ до «{name}». Відкрити можна тут:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Додати все до календаря",
|
||||
"zone_contacts_all": "Додати все до контактів",
|
||||
"save_to_files": "Зберегти у Файли",
|
||||
"saved": "{name} збережено у Файли.",
|
||||
"saved_copy": "Збережено у Файли як {name}.",
|
||||
"save_failed": "Не вдалося зберегти {name} у Файли.",
|
||||
"changed_on_server": "{name} змінили у Файлах після того, як ви його відкрили.",
|
||||
"gone_on_server": "{name} більше немає у Файлах.",
|
||||
"keep_both": "Залишити обидва",
|
||||
"replace": "Замінити",
|
||||
"cancel": "Скасувати",
|
||||
"save_as_new": "Зберегти як новий файл",
|
||||
"import_to_mail": "Імпортувати в поштову теку…",
|
||||
"import_to_mail_title": "Імпортувати в поштову теку",
|
||||
"imported_to_mail": "Імпортовано в {folder}.",
|
||||
"import_to_mail_failed": "Не вдалося імпортувати {name}.",
|
||||
"import_contacts": "Імпортувати в контакти…",
|
||||
"add_to_calendar": "Додати до календаря…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ваші сертифікати",
|
||||
"your_certificates_desc": "Імпортуйте та керуйте своїми сертифікатами S/MIME для підпису та шифрування електронних листів",
|
||||
|
||||
@@ -5805,6 +5805,26 @@
|
||||
"share_mail_subject": "Tôi đã chia sẻ {name} với bạn",
|
||||
"share_mail_body": "Chào bạn,\n\nTôi đã chia sẻ \"{name}\" với bạn. Bạn có thể mở tại đây:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "Thêm tất cả vào lịch",
|
||||
"zone_contacts_all": "Thêm tất cả vào danh bạ",
|
||||
"save_to_files": "Lưu vào Tệp",
|
||||
"saved": "Đã lưu {name} vào Tệp.",
|
||||
"saved_copy": "Đã lưu vào Tệp với tên {name}.",
|
||||
"save_failed": "Không thể lưu {name} vào Tệp.",
|
||||
"changed_on_server": "{name} đã bị thay đổi trong Tệp sau khi bạn mở.",
|
||||
"gone_on_server": "{name} không còn trong Tệp.",
|
||||
"keep_both": "Giữ cả hai",
|
||||
"replace": "Thay thế",
|
||||
"cancel": "Hủy",
|
||||
"save_as_new": "Lưu thành tệp mới",
|
||||
"import_to_mail": "Nhập vào thư mục thư…",
|
||||
"import_to_mail_title": "Nhập vào thư mục thư",
|
||||
"imported_to_mail": "Đã nhập vào {folder}.",
|
||||
"import_to_mail_failed": "Không thể nhập {name}.",
|
||||
"import_contacts": "Nhập vào danh bạ…",
|
||||
"add_to_calendar": "Thêm vào lịch…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Chứng chỉ của bạn",
|
||||
"your_certificates_desc": "Nhập và quản lý chứng chỉ S/MIME của bạn để ký và mã hóa email",
|
||||
|
||||
@@ -5793,6 +5793,26 @@
|
||||
"share_mail_subject": "我與你共用了 {name}",
|
||||
"share_mail_body": "你好,\n\n我與你共用了「{name}」。可以在這裡開啟:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "全部加入行事曆",
|
||||
"zone_contacts_all": "全部加入聯絡人",
|
||||
"save_to_files": "儲存到檔案",
|
||||
"saved": "已將 {name} 儲存到檔案。",
|
||||
"saved_copy": "已以 {name} 儲存到檔案。",
|
||||
"save_failed": "無法將 {name} 儲存到檔案。",
|
||||
"changed_on_server": "你開啟 {name} 後,它在檔案中被變更了。",
|
||||
"gone_on_server": "{name} 已不在檔案中。",
|
||||
"keep_both": "保留兩者",
|
||||
"replace": "取代",
|
||||
"cancel": "取消",
|
||||
"save_as_new": "另存為新檔案",
|
||||
"import_to_mail": "匯入到郵件資料夾…",
|
||||
"import_to_mail_title": "匯入到郵件資料夾",
|
||||
"imported_to_mail": "已匯入到 {folder}。",
|
||||
"import_to_mail_failed": "無法匯入 {name}。",
|
||||
"import_contacts": "匯入到聯絡人…",
|
||||
"add_to_calendar": "加入行事曆…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "您的憑證",
|
||||
"your_certificates_desc": "匯入與管理用於郵件簽章與加密的 S/MIME 憑證",
|
||||
|
||||
@@ -5531,6 +5531,26 @@
|
||||
"share_mail_subject": "我与你共享了 {name}",
|
||||
"share_mail_body": "你好,\n\n我与你共享了“{name}”。可以在这里打开:\n{link}"
|
||||
},
|
||||
"ux_p2_pim_files": {
|
||||
"zone_calendar_all": "全部添加到日历",
|
||||
"zone_contacts_all": "全部添加到联系人",
|
||||
"save_to_files": "保存到文件",
|
||||
"saved": "已将 {name} 保存到文件。",
|
||||
"saved_copy": "已作为 {name} 保存到文件。",
|
||||
"save_failed": "无法将 {name} 保存到文件。",
|
||||
"changed_on_server": "你打开 {name} 后,它在文件中被更改了。",
|
||||
"gone_on_server": "{name} 已不在文件中。",
|
||||
"keep_both": "保留两者",
|
||||
"replace": "替换",
|
||||
"cancel": "取消",
|
||||
"save_as_new": "另存为新文件",
|
||||
"import_to_mail": "导入到邮件文件夹…",
|
||||
"import_to_mail_title": "导入到邮件文件夹",
|
||||
"imported_to_mail": "已导入到 {folder}。",
|
||||
"import_to_mail_failed": "无法导入 {name}。",
|
||||
"import_contacts": "导入到联系人…",
|
||||
"add_to_calendar": "添加到日历…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "您的证书",
|
||||
"your_certificates_desc": "导入和管理您的 S/MIME 证书以对邮件进行签名和加密",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useFileStore } from '../file-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import type { UploadConflict } from '@/lib/files-upload-conflicts';
|
||||
|
||||
// pim-p2-6: files dropped on a folder's row go into that folder, and a name
|
||||
// taken there (not in the folder in view) asks.
|
||||
|
||||
function makeClient(initial: FileNode[]) {
|
||||
const nodes: FileNode[] = initial.map(n => ({ ...n }));
|
||||
let seq = 0;
|
||||
const client = {
|
||||
getCapabilities: () => ({}),
|
||||
getFilesAccountId: () => 'acct',
|
||||
async listAllFileNodes() { return nodes.map(n => ({ ...n })); },
|
||||
async listFileNodes(parentId: string | null) { return nodes.filter(n => (n.parentId ?? null) === parentId).map(n => ({ ...n })); },
|
||||
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null) {
|
||||
const node: FileNode = { id: `n${++seq}`, parentId, name, type, blobId, size, created: '', modified: '' };
|
||||
nodes.push(node);
|
||||
return { ...node };
|
||||
},
|
||||
replaceFileNodeBlob: vi.fn(async () => {}),
|
||||
async uploadBlob() { return { blobId: `blob${++seq}`, type: 'text/plain' }; },
|
||||
_nodes: () => nodes,
|
||||
};
|
||||
return client as unknown as IJMAPClient & { _nodes: () => FileNode[] };
|
||||
}
|
||||
|
||||
const folder = (id: string, name: string, parentId: string | null): FileNode => ({
|
||||
id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '',
|
||||
});
|
||||
const file = (id: string, name: string, parentId: string | null): FileNode => ({
|
||||
id, parentId, name, type: 'text/plain', blobId: `old-${id}`, size: 3, created: '', modified: '',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useFileStore.setState({
|
||||
client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }],
|
||||
resources: [], selectedResources: new Set(), clipboard: null, lastAction: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploading into a folder of the one in view (pim-p2-6)', () => {
|
||||
it('creates the files in that folder and asks about names taken there', async () => {
|
||||
const client = makeClient([folder('d', 'Documents', null), file('r', 'report.txt', 'd'), file('t', 'top.txt', null)]);
|
||||
useFileStore.getState().initClient(client);
|
||||
await useFileStore.getState().navigate(null);
|
||||
const asked: UploadConflict[][] = [];
|
||||
const resolveConflicts = vi.fn(async (conflicts: UploadConflict[]) => {
|
||||
asked.push(conflicts);
|
||||
return new Map(conflicts.map(c => [c.name, 'keep' as const]));
|
||||
});
|
||||
await useFileStore.getState().uploadFiles(
|
||||
[new File(['x'], 'report.txt'), new File(['y'], 'top.txt')],
|
||||
{ resolveConflicts, into: { id: 'd', name: 'Documents' } },
|
||||
);
|
||||
// report.txt is taken in Documents, top.txt only in the folder in view.
|
||||
expect(asked).toEqual([[{ name: 'report.txt', kind: 'file' }]]);
|
||||
const added = client._nodes().filter(n => n.id.startsWith('n'));
|
||||
expect(added.map(n => [n.name, n.parentId]).sort()).toEqual([['report (1).txt', 'd'], ['top.txt', 'd']]);
|
||||
});
|
||||
});
|
||||
+19
-4
@@ -69,6 +69,19 @@ export interface UploadReport {
|
||||
export interface UploadOptions {
|
||||
/** Asked when a name is taken in the target folder; without it, new ones are numbered. */
|
||||
resolveConflicts?: UploadConflictResolver;
|
||||
/** A folder of the one in view, as when files are dropped on its row. Default: the folder in view. */
|
||||
into?: { id: string; name: string };
|
||||
}
|
||||
|
||||
/** Where an upload goes: the folder in view, or one of its folders (`into`), with what that folder holds. */
|
||||
async function uploadTarget(
|
||||
state: Pick<FileState, 'currentParentId' | 'currentPath' | 'resources' | 'listByParentId'>,
|
||||
into: UploadOptions['into'],
|
||||
): Promise<{ parentId: string | null; path: string; existing: FileResource[] }> {
|
||||
if (!into || into.id === state.currentParentId) {
|
||||
return { parentId: state.currentParentId, path: state.currentPath, existing: state.resources };
|
||||
}
|
||||
return { parentId: into.id, path: joinPath(state.currentPath, into.name), existing: await state.listByParentId(into.id) };
|
||||
}
|
||||
|
||||
interface ClipboardState {
|
||||
@@ -1120,10 +1133,11 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
},
|
||||
|
||||
uploadFiles: async (allFiles: File[], options?: UploadOptions) => {
|
||||
const { client, currentParentId, currentPath, resources } = get();
|
||||
const { client } = get();
|
||||
const report: UploadReport = { uploaded: 0, failed: [], canceled: false };
|
||||
if (!client) return report;
|
||||
assertCanAddTo(currentParentId);
|
||||
assertCanAddTo(options?.into?.id ?? get().currentParentId);
|
||||
const { parentId: currentParentId, path: currentPath, existing: resources } = await uploadTarget(get(), options?.into);
|
||||
|
||||
// Plugins may hold back single files; the rest go up.
|
||||
const files: File[] = [];
|
||||
@@ -1204,10 +1218,11 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
},
|
||||
|
||||
uploadFolder: async (allFiles: File[], options?: UploadOptions) => {
|
||||
const { client, currentParentId, currentPath, resources } = get();
|
||||
const { client } = get();
|
||||
const report: UploadReport = { uploaded: 0, failed: [], canceled: false };
|
||||
if (!client || allFiles.length === 0) return report;
|
||||
assertCanAddTo(currentParentId);
|
||||
assertCanAddTo(options?.into?.id ?? get().currentParentId);
|
||||
const { parentId: currentParentId, path: currentPath, existing: resources } = await uploadTarget(get(), options?.into);
|
||||
|
||||
const relativePathOf = (file: File) => (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
const dirOf = (relativePath: string) => {
|
||||
|
||||
Reference in New Issue
Block a user