mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-ux-pim-files' into feat/do-ux-pim-fixes
This commit is contained in:
@@ -198,20 +198,23 @@ describe('FileBrowser', () => {
|
||||
expect(within(tree()).getByText('Documents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens a folder from its name and selects it from the row', () => {
|
||||
// pim-3: the name is plain text, as in a file manager. A click selects, a
|
||||
// double-click opens.
|
||||
it('selects a folder from its name and opens it on a double-click', () => {
|
||||
const { onNavigate, onSetSelection } = renderBrowser();
|
||||
const row = within(list()).getByText('Documents').closest('[role=row]')!;
|
||||
fireEvent.click(row);
|
||||
fireEvent.click(within(list()).getByText('Documents'));
|
||||
expect(onSetSelection).toHaveBeenCalledWith(new Set(['Documents']));
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(list()).getByText('Documents'));
|
||||
fireEvent.doubleClick(within(list()).getByText('Documents'));
|
||||
expect(onNavigate).toHaveBeenCalledWith('/Documents', 'f-docs');
|
||||
});
|
||||
|
||||
it('previews a text file when its name is clicked', () => {
|
||||
it('previews a text file when its name is double-clicked', () => {
|
||||
const { onPreviewFile } = renderBrowser();
|
||||
fireEvent.click(within(list()).getByText('notes.txt'));
|
||||
expect(onPreviewFile).not.toHaveBeenCalled();
|
||||
fireEvent.doubleClick(within(list()).getByText('notes.txt'));
|
||||
expect(onPreviewFile).toHaveBeenCalledWith('notes.txt');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { FileGridView } from '../file-views';
|
||||
import { splitNameForMiddleCut } from '../file-item-parts';
|
||||
import type { FileResource } from '@/stores/file-store';
|
||||
|
||||
// pim-21: long names in the grid are cut in the middle, so the extension and
|
||||
// the part that tells similar names apart stay in view.
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('splitNameForMiddleCut', () => {
|
||||
it('keeps the end of the name and the extension', () => {
|
||||
expect(splitNameForMiddleCut('IMG_20250611_1003.png')).toEqual({ head: 'IMG_20250611_', tail: '1003.png' });
|
||||
expect(splitNameForMiddleCut('meeting-notes-2026-q3.md')).toEqual({ head: 'meeting-notes-202', tail: '6-q3.md' });
|
||||
expect(splitNameForMiddleCut('Großes Angebot – final (v3).txt')).toEqual({ head: 'Großes Angebot – final ', tail: '(v3).txt' });
|
||||
});
|
||||
|
||||
it('leaves short names whole', () => {
|
||||
expect(splitNameForMiddleCut('a.txt')).toEqual({ head: 'a.txt', tail: '' });
|
||||
expect(splitNameForMiddleCut('readme.txt')).toEqual({ head: 'readme.txt', tail: '' });
|
||||
});
|
||||
|
||||
it('cuts names without an extension near the end too', () => {
|
||||
expect(splitNameForMiddleCut('Quarterly reports archive')).toEqual({ head: 'Quarterly reports arc', tail: 'hive' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the grid', () => {
|
||||
it('cuts the start of a long name, keeps its end, and has the whole name as title', () => {
|
||||
const resource = {
|
||||
id: 'i', name: 'IMG_20250611_1003.png', serverName: 'IMG_20250611_1003.png', isDirectory: false, contentType: 'image/png',
|
||||
contentLength: 10, lastModified: '2026-10-01T00:00:00Z', blobId: 'b', parentId: null,
|
||||
} as FileResource;
|
||||
render(
|
||||
<FileGridView
|
||||
resources={[resource]}
|
||||
selected={new Set()}
|
||||
focusedName={null}
|
||||
tabStopName={null}
|
||||
dropTarget={null}
|
||||
cutNames={new Set()}
|
||||
starredPaths={new Set()}
|
||||
pathOf={() => '/'}
|
||||
touch={false}
|
||||
iconOptions={{ showIcons: true, coloredIcons: true, showThumbnails: false }}
|
||||
getImageUrl={async () => ''}
|
||||
handlers={{
|
||||
onSelectClick: vi.fn(), onToggleSelect: vi.fn(), onOpen: vi.fn(), onContextMenu: vi.fn(), onShare: vi.fn(),
|
||||
canShare: () => false, dragProps: () => ({}), onFocusItem: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const name = screen.getByTitle('IMG_20250611_1003.png');
|
||||
const [head, tail] = Array.from(name.children) as HTMLElement[];
|
||||
expect(head.textContent).toBe('IMG_20250611_');
|
||||
expect(head.className).toContain('truncate');
|
||||
expect(tail.textContent).toBe('1003.png');
|
||||
expect(tail.className).toContain('shrink-0');
|
||||
expect(name.textContent).toBe('IMG_20250611_1003.png');
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,11 @@ function tap(el: Element, x = 10, y = 10) {
|
||||
describe('ImagePreviewModal', () => {
|
||||
it('closes when the area around the image is clicked', async () => {
|
||||
const { onClose, box } = await renderViewer();
|
||||
// Not in the first 400 ms: that is still the click that opened it (pim-3).
|
||||
const later = Date.now() + 1000;
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(later);
|
||||
tap(box);
|
||||
now.mockRestore();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { UploadConflictDialog } from '../upload-conflict-dialog';
|
||||
import { FileNoPreview } from '../file-no-preview';
|
||||
import { EmptyFolder } from '../file-empty-state';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { sharedNodeLink } from '../share-message';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
|
||||
// The share dialog itself isn't ours: a stand-in that adds and changes a share.
|
||||
vi.mock('@/components/settings/share-collection-dialog', () => ({
|
||||
ShareCollectionDialog: ({ onShare }: { onShare: (principalId: string, rights: unknown) => Promise<void> }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => void onShare('p-new', { mayRead: true })}>add new</button>
|
||||
<button type="button" onClick={() => void onShare('p-old', { mayRead: true, mayWrite: true })}>change old</button>
|
||||
<button type="button" onClick={() => void onShare('p-new', null)}>remove</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('the upload conflict question (pim-10)', () => {
|
||||
const conflict = { name: 'report.txt', kind: 'file' as const };
|
||||
|
||||
it('offers Replace, Keep both, Skip and Cancel, and "for all" only with more conflicts left', () => {
|
||||
const answer = vi.fn();
|
||||
const { rerender } = render(<UploadConflictDialog question={{ conflict, remaining: 1, answer }} />);
|
||||
expect(screen.getByRole('alertdialog', { name: 'conflict_title' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('checkbox')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'keep_both' }));
|
||||
expect(answer).toHaveBeenLastCalledWith({ choice: 'keep', applyToAll: false });
|
||||
|
||||
rerender(<UploadConflictDialog question={{ conflict, remaining: 3, answer }} />);
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'apply_to_all' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'replace' }));
|
||||
expect(answer).toHaveBeenLastCalledWith({ choice: 'replace', applyToAll: true });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'skip' }));
|
||||
expect(answer).toHaveBeenLastCalledWith({ choice: 'skip', applyToAll: true });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'cancel' }));
|
||||
expect(answer).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('explains Replace for a folder as a merge', () => {
|
||||
render(<UploadConflictDialog question={{ conflict: { name: 'Docs', kind: 'folder' }, remaining: 1, answer: vi.fn() }} />);
|
||||
expect(screen.getByText('conflict_folder_hint')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the pane for a file with no preview (pim-16)', () => {
|
||||
const budget = {
|
||||
id: 'x', name: 'budget-2026.xlsx', serverName: 'budget-2026.xlsx', isDirectory: false, contentType: 'application/octet-stream',
|
||||
contentLength: 1600, lastModified: '', blobId: 'b', parentId: null,
|
||||
} as FileResource;
|
||||
|
||||
it('shows the name, size and Download, and Open in default app only when the desktop app can', () => {
|
||||
const onDownload = vi.fn();
|
||||
const onOpenInApp = vi.fn();
|
||||
const { rerender } = render(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={onDownload} />);
|
||||
const pane = screen.getByTestId('file-no-preview');
|
||||
expect(pane).toHaveTextContent('budget-2026.xlsx');
|
||||
expect(pane).toHaveTextContent('1.6 KB');
|
||||
expect(screen.queryByRole('button', { name: 'open_in_app' })).toBeNull();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'download' }).at(-1)!);
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
|
||||
rerender(<FileNoPreview resource={budget} onClose={vi.fn()} onDownload={onDownload} onOpenInApp={onOpenInApp} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open_in_app' }));
|
||||
expect(onOpenInApp).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('"Upload files…" ends in "…" (pim-26)', () => {
|
||||
it('in an empty folder too', () => {
|
||||
render(<EmptyFolder isRoot canCreate onUpload={vi.fn()} onCreateFolder={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: 'files_upload_files' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'files_new_folder' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('after sharing with someone new (pim-26)', () => {
|
||||
it('reports who was added, not a change or a removal', async () => {
|
||||
const report = {
|
||||
id: 'r1', name: 'report.pdf', serverName: 'report.pdf', isDirectory: false, contentType: 'application/pdf', contentLength: 5,
|
||||
lastModified: '', blobId: 'b', parentId: null, shareWith: { 'p-old': { mayRead: true } },
|
||||
} as unknown as FileResource;
|
||||
const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
|
||||
act(() => {
|
||||
useFileStore.setState({ client, sharedRoots: [], allFolders: [], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true });
|
||||
});
|
||||
const onShare = vi.fn(async () => {});
|
||||
const onShared = vi.fn();
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/" resources={[report]} isLoading={false} error={null} selectedResources={new Set()} uploadProgress={null} clipboard={null}
|
||||
onNavigate={vi.fn()} onSetSelection={vi.fn()} onToggleSelect={vi.fn()} onPreviewFile={vi.fn()} onPreviewImage={vi.fn()} onDownload={noop}
|
||||
onCreateFolder={noop} onUploadFiles={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" sharingEnabled onShare={onShare} onShared={onShared}
|
||||
/>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByTitle('report.pdf'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'files_share' }));
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'change old' })); });
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'remove' })); });
|
||||
expect(onShared).not.toHaveBeenCalled();
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'add new' })); });
|
||||
expect(onShare).toHaveBeenLastCalledWith('r1', 'p-new', { mayRead: true });
|
||||
expect(onShared).toHaveBeenCalledWith(expect.objectContaining({ id: 'r1' }), 'p-new');
|
||||
});
|
||||
|
||||
it('links the person to the node as it is listed for them, a file with its preview', () => {
|
||||
expect(sharedNodeLink('https://mail.example', 'acc1', { id: 'n7', name: 'report.pdf', isDirectory: false }))
|
||||
.toBe('https://mail.example/files?shared=acc1%3An7&preview=report.pdf');
|
||||
expect(sharedNodeLink('https://mail.example', 'acc1', { id: 'd2', name: 'Team', isDirectory: true }))
|
||||
.toBe('https://mail.example/files?shared=acc1%3Ad2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { FileGridView, FileListView, type FileItemHandlers } from '../file-views';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { FilePreviewModal } from '../file-preview-modal';
|
||||
import { BACKDROP_GRACE_MS } from '../preview-shell';
|
||||
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';
|
||||
|
||||
// pim-3: a double-click opens a file and the preview stays open; the name is
|
||||
// plain text in both views (a click selects). pim-16: a file with no preview
|
||||
// and no editor opens a pane with Download instead of downloading at once.
|
||||
|
||||
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 });
|
||||
|
||||
type Spy = ReturnType<typeof vi.fn>;
|
||||
function handlers(): FileItemHandlers & { onOpen: Spy; onSelectClick: Spy } {
|
||||
return {
|
||||
onSelectClick: vi.fn(),
|
||||
onToggleSelect: vi.fn(),
|
||||
onOpen: vi.fn(),
|
||||
onContextMenu: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
onShare: vi.fn(),
|
||||
canShare: () => false,
|
||||
dragProps: () => ({}),
|
||||
onFocusItem: vi.fn(),
|
||||
} as never;
|
||||
}
|
||||
|
||||
const viewProps = (h: FileItemHandlers) => ({
|
||||
resources: [docs, notes],
|
||||
selected: new Set<string>(),
|
||||
focusedName: null,
|
||||
tabStopName: null,
|
||||
dropTarget: null,
|
||||
cutNames: new Set<string>(),
|
||||
starredPaths: new Set<string>(),
|
||||
pathOf: (r: FileResource) => `/${r.name}`,
|
||||
touch: false,
|
||||
iconOptions: { showIcons: true, coloredIcons: true, showThumbnails: false },
|
||||
getImageUrl: async () => '',
|
||||
handlers: h,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('a click on a name selects, a double-click opens (pim-3)', () => {
|
||||
it('in the list', () => {
|
||||
const h = handlers();
|
||||
render(<FileListView {...viewProps(h)} sortKey="name" sortDir="asc" onSort={vi.fn()} allSelected={false} someSelected={false} onToggleAll={vi.fn()} />);
|
||||
for (const name of ['notes.txt', 'Documents']) {
|
||||
const row = screen.getByTitle(name).closest('[role="row"]')!;
|
||||
expect(within(row as HTMLElement).queryByRole('button', { name })).toBeNull();
|
||||
fireEvent.click(screen.getByTitle(name));
|
||||
expect(h.onOpen).not.toHaveBeenCalled();
|
||||
expect(h.onSelectClick).toHaveBeenLastCalledWith(expect.objectContaining({ name }), expect.anything());
|
||||
fireEvent.doubleClick(screen.getByTitle(name));
|
||||
expect(h.onOpen).toHaveBeenLastCalledWith(expect.objectContaining({ name }));
|
||||
h.onOpen.mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it('in the grid', () => {
|
||||
const h = handlers();
|
||||
render(<FileGridView {...viewProps(h)} />);
|
||||
for (const name of ['notes.txt', 'Documents']) {
|
||||
fireEvent.click(screen.getByTitle(name));
|
||||
expect(h.onOpen).not.toHaveBeenCalled();
|
||||
expect(h.onSelectClick).toHaveBeenLastCalledWith(expect.objectContaining({ name }), expect.anything());
|
||||
fireEvent.doubleClick(screen.getByTitle(name));
|
||||
expect(h.onOpen).toHaveBeenLastCalledWith(expect.objectContaining({ name }));
|
||||
h.onOpen.mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it('on touch a tap on the name still opens', () => {
|
||||
const h = handlers();
|
||||
render(<FileListView {...viewProps(h)} touch sortKey="name" sortDir="asc" onSort={vi.fn()} allSelected={false} someSelected={false} onToggleAll={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('notes.txt'));
|
||||
expect(h.onOpen).toHaveBeenCalledWith(expect.objectContaining({ name: 'notes.txt' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the preview stays open after the click that opened it (pim-3)', () => {
|
||||
it('ignores a backdrop click in the first 400 ms', () => {
|
||||
let now = 1_000_000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
const onClose = vi.fn();
|
||||
render(<FilePreviewModal name="notes.txt" onClose={onClose} onDownload={vi.fn()} getFileContent={() => new Promise(() => {})} />);
|
||||
const backdrop = screen.getByRole('dialog');
|
||||
now += BACKDROP_GRACE_MS - 50;
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
now += 100;
|
||||
fireEvent.click(backdrop);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function renderBrowser(props: Partial<React.ComponentProps<typeof FileBrowser>> = {}) {
|
||||
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(),
|
||||
};
|
||||
const noop = vi.fn(async () => {});
|
||||
render(
|
||||
<FileBrowser
|
||||
currentPath="/"
|
||||
resources={[docs, notes, budget]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
selectedResources={new Set()}
|
||||
uploadProgress={null}
|
||||
clipboard={null}
|
||||
onNavigate={vi.fn()}
|
||||
onToggleSelect={vi.fn()}
|
||||
onCreateFolder={noop}
|
||||
onUploadFiles={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;
|
||||
}
|
||||
|
||||
describe('a file with no preview and no editor (pim-16)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); });
|
||||
});
|
||||
|
||||
it('opens the pane instead of downloading', () => {
|
||||
const h = renderBrowser();
|
||||
fireEvent.doubleClick(screen.getByTitle('budget.xlsx'));
|
||||
expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx');
|
||||
expect(h.onDownload).not.toHaveBeenCalled();
|
||||
fireEvent.doubleClick(screen.getByTitle('notes.txt'));
|
||||
expect(h.onPreviewFile).toHaveBeenCalledWith('notes.txt');
|
||||
});
|
||||
|
||||
it('has Open in its menu, and in the desktop app Open in default app', () => {
|
||||
const onOpenInApp = vi.fn();
|
||||
const h = renderBrowser({ canOpenInApp: (r) => r.name === 'budget.xlsx', onOpenInApp });
|
||||
fireEvent.contextMenu(screen.getByTitle('budget.xlsx'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'open' }));
|
||||
expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx');
|
||||
fireEvent.contextMenu(screen.getByTitle('budget.xlsx'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'open_in_app' }));
|
||||
expect(onOpenInApp).toHaveBeenCalledWith(expect.objectContaining({ name: 'budget.xlsx' }));
|
||||
// A text file previews: no extra Open there.
|
||||
fireEvent.contextMenu(screen.getByTitle('notes.txt'));
|
||||
expect(screen.queryByRole('menuitem', { name: 'open' })).toBeNull();
|
||||
});
|
||||
|
||||
it('Enter opens the row Tab put the focus on', () => {
|
||||
const h = renderBrowser();
|
||||
const row = document.querySelector<HTMLElement>('[data-resource="budget.xlsx"]')!;
|
||||
fireEvent.focus(row);
|
||||
fireEvent.keyDown(row, { key: 'Enter' });
|
||||
expect(h.onShowFileInfo).toHaveBeenCalledWith('budget.xlsx');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, ExternalLink,
|
||||
} from "@/components/icons";
|
||||
import { useIsFullDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
@@ -138,6 +138,13 @@ interface FileBrowserProps {
|
||||
sharingEnabled?: boolean;
|
||||
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
||||
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
||||
/** A file with no preview and no editor: shows its icon, size and Download (pim-16). Left out: it downloads. */
|
||||
onShowFileInfo?: (name: string) => void;
|
||||
/** The desktop app can open this file in its program. */
|
||||
canOpenInApp?: (resource: FileResource) => boolean;
|
||||
onOpenInApp?: (resource: FileResource) => void;
|
||||
/** A principal was given access to a node they couldn't see before. */
|
||||
onShared?: (resource: FileResource, principalId: string) => void;
|
||||
}
|
||||
|
||||
function isPreviewable(name: string): boolean {
|
||||
@@ -220,9 +227,14 @@ export function FileBrowser({
|
||||
ownAccountId,
|
||||
sharingEnabled,
|
||||
onShare,
|
||||
onShowFileInfo,
|
||||
canOpenInApp,
|
||||
onOpenInApp,
|
||||
onShared,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const tMenu = useTranslations("menuAudit");
|
||||
const tUx = useTranslations("ux_pim_files");
|
||||
// Menu entries that open a dialog end in "…".
|
||||
const newFileLabel = newFileFormats.length > 0 ? tMenu("files_new_file") : tMenu("files_new_text_file");
|
||||
const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]);
|
||||
@@ -517,10 +529,12 @@ export function FileBrowser({
|
||||
onPreviewImage(resource.name);
|
||||
} else if (isPreviewable(resource.name)) {
|
||||
onPreviewFile(resource.name);
|
||||
} else if (onShowFileInfo) {
|
||||
onShowFileInfo(resource.name);
|
||||
} else {
|
||||
void onDownload(resource.name);
|
||||
}
|
||||
}, [onNavigate, pathOf, isOfficeEditable, onEditFile, onPreviewImage, onPreviewFile, onDownload]);
|
||||
}, [onNavigate, pathOf, isOfficeEditable, onEditFile, onPreviewImage, onPreviewFile, onShowFileInfo, onDownload]);
|
||||
|
||||
const previewResource = useCallback((resource: FileResource) => {
|
||||
if (isImageFile(resource.name)) onPreviewImage(resource.name);
|
||||
@@ -810,9 +824,14 @@ export function FileBrowser({
|
||||
setRenameTarget(singleSelected.name);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && singleSelected) {
|
||||
// Enter opens the selected item, or the one Tab put the focus on.
|
||||
const enterTarget = singleSelected
|
||||
?? (selected.length === 0 && focusedName && (e.target as HTMLElement | null)?.dataset?.resource === focusedName
|
||||
? displayResources.find((r) => r.name === focusedName) ?? null
|
||||
: null);
|
||||
if (e.key === "Enter" && enterTarget) {
|
||||
e.preventDefault();
|
||||
openResource(singleSelected);
|
||||
openResource(enterTarget);
|
||||
return;
|
||||
}
|
||||
if (e.key === " " && focusedName) {
|
||||
@@ -852,7 +871,7 @@ export function FileBrowser({
|
||||
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash,
|
||||
showDetails, isNarrow, toggleDetails,
|
||||
selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource,
|
||||
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus,
|
||||
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus, displayResources,
|
||||
]);
|
||||
|
||||
// ---- Context menu ----------------------------------------------------------
|
||||
@@ -943,6 +962,13 @@ export function FileBrowser({
|
||||
{isOfficeEditable?.(resource.name) && onEditFile && (
|
||||
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
|
||||
)}
|
||||
{/* Neither previewed nor edited here: Open shows what there is (pim-16). */}
|
||||
{!isPreviewable(resource.name) && !(isOfficeEditable?.(resource.name) && onEditFile) && onShowFileInfo && (
|
||||
<ContextMenuItem icon={File} label={t("open")} onClick={() => { closeMenu(); onShowFileInfo(resource.name); }} />
|
||||
)}
|
||||
{canOpenInApp?.(resource) && onOpenInApp && (
|
||||
<ContextMenuItem icon={ExternalLink} label={tUx("open_in_app")} onClick={() => { closeMenu(); onOpenInApp(resource); }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canKeepOffline(resource) && (
|
||||
@@ -1501,7 +1527,10 @@ export function FileBrowser({
|
||||
shareWith={shareTarget.shareWith}
|
||||
ownAccountId={ownAccountId || ""}
|
||||
onShare={async (principalId, rights) => {
|
||||
const added = !!rights && !shareTarget.shareWith?.[principalId];
|
||||
await onShare(shareTarget.id, principalId, rights as FileNodeRights | null);
|
||||
// Someone new can see it now: offer to tell them (pim-26).
|
||||
if (added) onShared?.(shareTarget, principalId);
|
||||
}}
|
||||
onClose={() => setShareTargetId(null)}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,8 @@ interface EmptyFolderProps {
|
||||
/** What an empty folder shows: what to do next, and that dropping files works. */
|
||||
export function EmptyFolder({ isRoot, canCreate, onUpload, onCreateFolder }: EmptyFolderProps) {
|
||||
const t = useTranslations("files");
|
||||
// Both open a picker or a dialog, so they end in "…" as the menus' entries do.
|
||||
const tMenu = useTranslations("menuAudit");
|
||||
return (
|
||||
<EmptyState
|
||||
className="h-full"
|
||||
@@ -25,11 +27,11 @@ export function EmptyFolder({ isRoot, canCreate, onUpload, onCreateFolder }: Emp
|
||||
<>
|
||||
<Button size="sm" onClick={onUpload} className="gap-1.5">
|
||||
<Upload className="w-4 h-4" />
|
||||
{t("upload_files")}
|
||||
{tMenu("files_upload_files")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCreateFolder} className="gap-1.5">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
{t("new_folder")}
|
||||
{tMenu("files_new_folder")}
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
@@ -203,6 +203,34 @@ export function readDraggedNames(e: React.DragEvent): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A file name split for cutting in the middle: the start gives way, the end
|
||||
* stays, so "IMG_20250611_1003.png" reads "IMG_20250…1003.png" and the
|
||||
* extension and the part that tells similar names apart stay in view. Short
|
||||
* names and folders' names (no extension) keep a shorter tail.
|
||||
*/
|
||||
export function splitNameForMiddleCut(name: string): { head: string; tail: string } {
|
||||
const dot = name.lastIndexOf(".");
|
||||
const ext = dot > 0 && name.length - dot <= 10 ? name.slice(dot) : "";
|
||||
const tailLength = Math.min(ext.length + 4, Math.floor(name.length / 2));
|
||||
if (name.length < 12 || tailLength <= 0) return { head: name, tail: "" };
|
||||
return { head: name.slice(0, name.length - tailLength), tail: name.slice(name.length - tailLength) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A name cut in the middle when there is no room (pim-21). The whole name is
|
||||
* the title; screen readers read it as one text.
|
||||
*/
|
||||
export function MiddleCutName({ name, title, className }: { name: string; title?: string; className?: string }) {
|
||||
const { head, tail } = splitNameForMiddleCut(name);
|
||||
return (
|
||||
<span className={cn("flex min-w-0", className)} title={title ?? name} dir="auto">
|
||||
<span className="truncate whitespace-pre">{head}</span>
|
||||
{tail && <span className="shrink-0 whitespace-pre">{tail}</span>}
|
||||
</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();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Download, ExternalLink } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { FileSize, ResourceIcon } from "@/components/files/file-item-parts";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
|
||||
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.
|
||||
*/
|
||||
export function FileNoPreview({ resource, onClose, onDownload, onOpenInApp, 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;
|
||||
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}>
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-4">
|
||||
<div
|
||||
data-testid="file-no-preview"
|
||||
className="bg-background rounded-lg p-8 max-w-sm w-full flex flex-col items-center gap-3 text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ResourceIcon resource={resource} size="xl" options={ICON_OPTIONS} />
|
||||
<p className="text-sm font-medium break-all" dir="auto">{resource.name}</p>
|
||||
<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">
|
||||
{onOpenInApp && (
|
||||
<Button size="sm" 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">
|
||||
<Download className="w-4 h-4" />
|
||||
{t("download")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PreviewShell>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ExternalLink } from "@/components/icons";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { PreviewShell, useBackdropClose, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ZoomableImage } from "@/components/files/zoomable-image";
|
||||
import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview";
|
||||
@@ -233,6 +233,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
|
||||
// Back closes the preview instead of leaving Files.
|
||||
useCloseOnBack({ onBack: onClose });
|
||||
const closeFromBackdrop = useBackdropClose(onClose);
|
||||
|
||||
const fileType = resolvedFileType;
|
||||
// The browser's own PDF viewer has Download and Print buttons of its own.
|
||||
@@ -412,7 +413,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
|
||||
{!loading && !error && fileType === "image" && objectUrl && (
|
||||
<div className="absolute inset-0" {...noMediaSavingProps(downloadsOff)}>
|
||||
<ZoomableImage src={objectUrl} alt={name} onBackgroundClick={onClose} />
|
||||
<ZoomableImage src={objectUrl} alt={name} onBackgroundClick={closeFromBackdrop} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import {
|
||||
FileSize, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
FileSize, MiddleCutName, ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
type IconOptions,
|
||||
} from "@/components/files/file-item-parts";
|
||||
import type { SortDir, SortKey } from "@/components/files/files-header";
|
||||
@@ -262,21 +262,16 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
|
||||
) : icon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
data-item-action
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (touch && selecting) handlers.onToggleSelect(resource);
|
||||
else if (e.ctrlKey || e.metaKey || e.shiftKey) handlers.onSelectClick(resource, e);
|
||||
else handlers.onOpen(resource);
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn("truncate text-start hover:underline underline-offset-2 decoration-muted-foreground/50", resource.isDirectory && "font-medium")}
|
||||
{/* Plain text, as in grid view and a file manager: a click selects
|
||||
the row, a double-click or Enter opens it, a tap on touch opens
|
||||
it (useTouchPress). A link here opened on the first click of a
|
||||
double-click, and the second closed the preview again (pim-3). */}
|
||||
<span
|
||||
className={cn("truncate text-start", resource.isDirectory && "font-medium")}
|
||||
title={note ? `${resource.name}\n${note}` : resource.name}
|
||||
>
|
||||
{resource.name}
|
||||
</button>
|
||||
</span>
|
||||
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
</div>
|
||||
@@ -427,16 +422,9 @@ function FolderTile({ resource, isSelected, isFocused, isTabStop, isDropTarget,
|
||||
</span>
|
||||
)}
|
||||
<ResourceIcon resource={resource} size="sm" options={iconOptions} />
|
||||
<button
|
||||
type="button"
|
||||
data-item-action
|
||||
onClick={(e) => { e.stopPropagation(); if (touch && selecting) handlers.onToggleSelect(resource); else handlers.onOpen(resource); }}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 min-w-0 truncate text-start text-sm font-medium"
|
||||
title={resource.name}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate text-start text-sm font-medium" title={resource.name}>
|
||||
{resource.name}
|
||||
</button>
|
||||
</span>
|
||||
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
<IconButton
|
||||
@@ -493,16 +481,7 @@ function FileTile({ resource, isSelected, isFocused, isTabStop, isCut, selecting
|
||||
)}
|
||||
<div className="flex items-center gap-1 ps-2.5 pe-1 py-1.5 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
data-item-action
|
||||
onClick={(e) => { e.stopPropagation(); if (touch && selecting) handlers.onToggleSelect(resource); else handlers.onOpen(resource); }}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="block w-full truncate text-start text-sm"
|
||||
title={resource.name}
|
||||
>
|
||||
{resource.name}
|
||||
</button>
|
||||
<MiddleCutName name={resource.name} className="w-full text-start text-sm" />
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground min-w-0">
|
||||
<span className="truncate">
|
||||
{!resource.isSymlink && <><FileSize bytes={resource.contentLength} /> · </>}{formatShortDate(resource.lastModified)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-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 { isTrashLocation } from "@/lib/files-trash";
|
||||
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
|
||||
@@ -29,6 +29,15 @@ import type { FileNodeRights } from "@/lib/jmap/types";
|
||||
import { ImagePreviewModal } from "@/components/files/image-preview-modal";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { WopiEditor } from "@/components/files/wopi-editor";
|
||||
import { UploadConflictDialog, type UploadConflictQuestion } from "@/components/files/upload-conflict-dialog";
|
||||
import { FileNoPreview } from "@/components/files/file-no-preview";
|
||||
import { sharedNodeLink } from "@/components/files/share-message";
|
||||
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
|
||||
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 { 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";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
@@ -141,6 +150,8 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewFile, setPreviewFile] = useState<string | null>(null);
|
||||
// A file with no preview and no editor: its icon, size and Download (pim-16).
|
||||
const [infoFile, setInfoFile] = useState<string | null>(null);
|
||||
// WOPI document editing (#425): name of the file open in the editor overlay.
|
||||
const [editFile, setEditFile] = useState<string | null>(null);
|
||||
const wopiStatus = useWopiStatus(filesEnabled);
|
||||
@@ -380,6 +391,22 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
const maxSizeUpload = client?.getMaxSizeUpload() || 0;
|
||||
|
||||
// A name that is taken in the target folder: asked one at a time (pim-10).
|
||||
const [conflictQuestion, setConflictQuestion] = useState<UploadConflictQuestion | null>(null);
|
||||
const resolveConflicts = useCallback<UploadConflictResolver>((conflicts) => resolveConflictsInTurn(
|
||||
conflicts,
|
||||
(conflict, index, total) => new Promise<ConflictAnswer>((resolve) => {
|
||||
setConflictQuestion({
|
||||
conflict,
|
||||
remaining: total - index,
|
||||
answer: (answer) => {
|
||||
setConflictQuestion(null);
|
||||
resolve(answer);
|
||||
},
|
||||
});
|
||||
}),
|
||||
), []);
|
||||
|
||||
const handleUploadFiles = useCallback(async (files: File[]) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
const oversized = files.filter(f => f.size > maxSizeUpload);
|
||||
@@ -390,12 +417,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
showUploadReport(await uploadFiles(files));
|
||||
showUploadReport(await uploadFiles(files, { resolveConflicts }));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload files:", err);
|
||||
toast.error(t("upload_error"), failureReason(err));
|
||||
}
|
||||
}, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason]);
|
||||
}, [uploadFiles, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]);
|
||||
|
||||
const handleUploadFolder = useCallback(async (files: File[]) => {
|
||||
if (maxSizeUpload > 0) {
|
||||
@@ -407,12 +434,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
if (files.length === 0) return;
|
||||
try {
|
||||
showUploadReport(await uploadFolder(files));
|
||||
showUploadReport(await uploadFolder(files, { resolveConflicts }));
|
||||
} catch (err) {
|
||||
console.error("Failed to upload folder:", err);
|
||||
toast.error(t("upload_error"), failureReason(err));
|
||||
}
|
||||
}, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]);
|
||||
}, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason, resolveConflicts]);
|
||||
|
||||
const handleUndo = useCallback(async () => {
|
||||
try {
|
||||
@@ -648,6 +675,27 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
addRecentFile(name, findResourceId(name));
|
||||
}, [addRecentFile, findResourceId]);
|
||||
|
||||
const handleShowFileInfo = useCallback((name: string) => {
|
||||
setInfoFile(name);
|
||||
addRecentFile(name, findResourceId(name));
|
||||
}, [addRecentFile, findResourceId]);
|
||||
|
||||
// The desktop app opens a file in the program the computer uses for it,
|
||||
// the way Edit opens an attachment (pim-16). Nothing in a browser.
|
||||
const filesLocalAccount = useFileStore((s) => s.currentAccountId) ?? activeAccountId ?? "";
|
||||
const { canEdit: canOpenNameInApp, edit: openInDesktopApp } = useEditAttachment(filesLocalAccount);
|
||||
const canOpenInApp = useCallback((r: FileResource) => !r.isDirectory && !r.isSymlink && canOpenNameInApp(r.name), [canOpenNameInApp]);
|
||||
const handleOpenInApp = useCallback((r: FileResource) => {
|
||||
void openInDesktopApp({
|
||||
key: `files:${filesLocalAccount}:${r.id}`,
|
||||
name: r.name,
|
||||
type: r.contentType || "application/octet-stream",
|
||||
account: filesLocalAccount,
|
||||
source: { kind: "file" },
|
||||
load: async () => (await getFileContent(r.name)).blob,
|
||||
});
|
||||
}, [openInDesktopApp, filesLocalAccount, getFileContent]);
|
||||
|
||||
const handleRenameNode = useCallback(async (id: string, newName: string) => {
|
||||
try {
|
||||
if (!await renameNode(id, newName)) return;
|
||||
@@ -719,6 +767,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
if (isOfficeEditable(name)) setEditFile(name);
|
||||
else if (isImageFile(name)) setPreviewImage(name);
|
||||
else if (isFilePreviewable(name)) setPreviewFile(name);
|
||||
else setInfoFile(name);
|
||||
}, [revealNode, addRecentFile, isOfficeEditable, tDeepLink]);
|
||||
|
||||
// The permalink for the folder on screen. In the Pro shell only the focused
|
||||
@@ -776,6 +825,36 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
await shareResource(id, principalId, rights);
|
||||
}, [shareResource]);
|
||||
|
||||
// After sharing with someone new: "Send a message" opens the composer to
|
||||
// them with a link to it (pim-26). The server doesn't tell them.
|
||||
const tUx = useTranslations("ux_pim_files");
|
||||
const router = useRouter();
|
||||
const handleShared = useCallback((resource: FileResource, principalId: string) => {
|
||||
if (!storeClient || !filesAccountId) return;
|
||||
void loadPrincipalDirectory(storeClient).then(({ principals }) => {
|
||||
const person = principals.find((p) => p.id === principalId);
|
||||
const email = person?.email?.trim();
|
||||
if (!person || !email) return;
|
||||
const link = sharedNodeLink(window.location.origin, filesAccountId, resource);
|
||||
toast.success(tUx("shared_with", { name: resource.name, person: person.name || email }), {
|
||||
action: {
|
||||
label: tUx("send_message"),
|
||||
onClick: () => {
|
||||
savePendingMailto({
|
||||
to: [formatRecipient(person.name && person.name !== email ? person.name : "", email)],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: tUx("share_mail_subject", { name: resource.name }),
|
||||
body: tUx("share_mail_body", { name: resource.name, link }),
|
||||
inApp: true,
|
||||
});
|
||||
router.push("/");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}, [storeClient, filesAccountId, tUx, router]);
|
||||
|
||||
// Pro shell only: all connected accounts are equal top-level entries at
|
||||
// the root. The root path "/" itself is a cross-account picker - no
|
||||
// account's files are shown until the user enters one.
|
||||
@@ -949,6 +1028,10 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
ownAccountId={filesAccountId}
|
||||
sharingEnabled={sharingEnabled}
|
||||
onShare={handleShare}
|
||||
onShowFileInfo={handleShowFileInfo}
|
||||
canOpenInApp={canOpenInApp}
|
||||
onOpenInApp={handleOpenInApp}
|
||||
onShared={handleShared}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -990,6 +1073,20 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* A file with no preview (pim-16) */}
|
||||
{infoFile && (() => {
|
||||
const resource = resources.find((r) => r.name === infoFile);
|
||||
if (!resource) return null;
|
||||
return (
|
||||
<FileNoPreview
|
||||
resource={resource}
|
||||
onClose={() => setInfoFile(null)}
|
||||
onDownload={filesDownloadsOff ? undefined : () => void handleDownload(infoFile)}
|
||||
onOpenInApp={canOpenInApp(resource) ? () => handleOpenInApp(resource) : undefined}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* WOPI document editor overlay (#425) */}
|
||||
{editFile && (() => {
|
||||
const editResource = resources.find(r => r.name === editFile);
|
||||
@@ -1028,6 +1125,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<UploadConflictDialog question={conflictQuestion} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { PreviewShell, useBackdropClose, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ZoomableImage } from "@/components/files/zoomable-image";
|
||||
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
|
||||
@@ -35,6 +35,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
|
||||
|
||||
// Back closes the preview instead of leaving Files, as for every other preview.
|
||||
useCloseOnBack({ onBack: onClose });
|
||||
const closeFromBackdrop = useBackdropClose(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
let revoke: string | null = null;
|
||||
@@ -76,7 +77,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
|
||||
</div>
|
||||
)}
|
||||
{imageUrl && !error && (
|
||||
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={onClose} />
|
||||
<ZoomableImage src={imageUrl} alt={name} onBackgroundClick={closeFromBackdrop} />
|
||||
)}
|
||||
{imageUrl && !error && <Watermark text={watermark} />}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "@/components/icons";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
@@ -14,6 +14,25 @@ export interface PreviewNeighbours {
|
||||
onGo: (name: string) => void;
|
||||
}
|
||||
|
||||
/** A backdrop click this soon after the preview opened is part of the click that opened it. */
|
||||
export const BACKDROP_GRACE_MS = 400;
|
||||
|
||||
/**
|
||||
* `onClose` for a click on the area around the content. The rest of the
|
||||
* click that opened the preview (a double-click's second click, a
|
||||
* triple-click) is ignored, so it can't close the preview again (pim-3).
|
||||
*/
|
||||
export function useBackdropClose(onClose: () => void): () => void {
|
||||
const openedAt = useRef(0);
|
||||
useEffect(() => {
|
||||
openedAt.current = Date.now();
|
||||
}, []);
|
||||
return useCallback(() => {
|
||||
if (Date.now() - openedAt.current < BACKDROP_GRACE_MS) return;
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
}
|
||||
|
||||
interface PreviewShellProps {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
@@ -34,6 +53,7 @@ interface PreviewShellProps {
|
||||
*/
|
||||
export function PreviewShell({ name, onClose, onDownload, actions, neighbours, closeOnBackdrop = true, children }: PreviewShellProps) {
|
||||
const t = useTranslations("files");
|
||||
const closeFromBackdrop = useBackdropClose(onClose);
|
||||
// Tab stays in the preview and the focus goes back to the file that was
|
||||
// opened when it closes. Escape is the listener below.
|
||||
const shellRef = useFocusTrap({ isActive: true, restoreFocus: true });
|
||||
@@ -86,7 +106,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/80"
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
onClick={closeOnBackdrop ? closeFromBackdrop : undefined}
|
||||
>
|
||||
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
|
||||
the iOS status bar (#936). The insets are physical, so pl/pr. */}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { appPath, buildSharedFilesPath } from "@/lib/deep-links";
|
||||
|
||||
/**
|
||||
* The link a person a file or folder was shared with opens it by. The node
|
||||
* is theirs to see under "<owner account>:<node id>" (the id a drive shared
|
||||
* with them is listed by); a file opens in its preview there.
|
||||
*/
|
||||
export function sharedNodeLink(
|
||||
origin: string,
|
||||
ownerAccountId: string,
|
||||
node: { id: string; name: string; isDirectory: boolean },
|
||||
): string {
|
||||
const shared = `${ownerAccountId}:${node.id}`;
|
||||
return `${origin}${appPath(buildSharedFilesPath(shared, node.isDirectory ? null : node.name))}`;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import type { ConflictAnswer, UploadConflict, UploadConflictChoice } from "@/lib/files-upload-conflicts";
|
||||
|
||||
export interface UploadConflictQuestion {
|
||||
conflict: UploadConflict;
|
||||
/** How many conflicts are left, this one included. */
|
||||
remaining: number;
|
||||
answer: (answer: ConflictAnswer) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "<name> already exists." Replace, Keep both or Skip, and with more than
|
||||
* one conflict left, "Do this for all n conflicts" (pim-10). Closing it
|
||||
* (Escape, Cancel) calls off the whole upload.
|
||||
*/
|
||||
export function UploadConflictDialog({ question }: { question: UploadConflictQuestion | null }) {
|
||||
const t = useTranslations("ux_pim_files");
|
||||
const tConfirm = useTranslations("confirm_dialog");
|
||||
const [applyToAll, setApplyToAll] = useState(false);
|
||||
const checkboxId = useId();
|
||||
const replaceRef = useRef<HTMLButtonElement>(null);
|
||||
// Each question starts unticked.
|
||||
useEffect(() => setApplyToAll(false), [question]);
|
||||
if (!question) return null;
|
||||
const { conflict, remaining, answer } = question;
|
||||
const choose = (choice: UploadConflictChoice) => answer({ choice, applyToAll: applyToAll && remaining > 1 });
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={() => answer(null)}
|
||||
role="alertdialog"
|
||||
title={t("conflict_title", { name: conflict.name })}
|
||||
description={conflict.kind === "folder" ? t("conflict_folder_hint") : t("conflict_file_hint")}
|
||||
showClose={false}
|
||||
initialFocus={replaceRef}
|
||||
testId="upload-conflict-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => answer(null)} className="sm:me-auto">{tConfirm("cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => choose("skip")}>{t("skip")}</Button>
|
||||
<Button variant="outline" onClick={() => choose("keep")}>{t("keep_both")}</Button>
|
||||
<Button ref={replaceRef} onClick={() => choose("replace")}>{t("replace")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{remaining > 1 && (
|
||||
<label htmlFor={checkboxId} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
id={checkboxId}
|
||||
type="checkbox"
|
||||
checked={applyToAll}
|
||||
onChange={(e) => setApplyToAll(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{t("apply_to_all", { count: remaining })}
|
||||
</label>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability fixes in Files (pim-3, pim-10, pim-16, pim-26) against a VM 106
|
||||
* Stalwart, 1.0 and 0.16. FileNode differs between them (1.0: draft 18 with
|
||||
* nodeType and `@type`; 0.16: blobId == null marks a folder), so the server
|
||||
* state is read back over JMAP after every step.
|
||||
*
|
||||
* - pim-10: uploading a name that exists asks Replace / Keep both / Skip.
|
||||
* Replace keeps the node id and gives it a new blob, Keep both makes the
|
||||
* "(1)" copy, Skip changes nothing; "Do this for all" answers the rest.
|
||||
* - pim-3: a double-click opens the preview and it stays open.
|
||||
* - pim-16: a file with no preview opens a pane with Download.
|
||||
* - pim-26: delete has Undo, and sharing offers "Send a message" with a link.
|
||||
*
|
||||
* Account dso-ux-pim (and dso-ux-pim-b to share with), password
|
||||
* IT_DSO_PASSWORD. Every node made here is named "UXFIX-files …" and removed.
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const B = 'dso-ux-pim-b@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const FILENODE = 'urn:ietf:params:jmap:filenode';
|
||||
const RUN = Date.now().toString(36);
|
||||
const PREFIX = `UXFIX-files ${RUN}`;
|
||||
const SHOTS = process.env.IT_UXPIM_SHOTS;
|
||||
|
||||
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; shareWith?: Record<string, unknown> | 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> {
|
||||
// Our nodes wherever they are, the trash's batch folders included.
|
||||
const all = await nodes(c);
|
||||
const ids = all.filter((n) => n.name.startsWith(PREFIX)).map((n) => n.id);
|
||||
if (ids.length) await c.request([['FileNode/set', { accountId: c.filesAccountId, destroy: ids }, '0']], [CORE, FILENODE]);
|
||||
// The draft "Send a message" started.
|
||||
const q = await c.request([['Email/query', { accountId: c.accountId, filter: { subject: PREFIX } }, '0']]);
|
||||
const drafts = q.methodResponses[0][1].ids as string[];
|
||||
if (drafts?.length) await c.request([['Email/set', { accountId: c.accountId, destroy: drafts }, '0']]);
|
||||
}
|
||||
|
||||
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 }[]): Promise<void> {
|
||||
await page.locator('input[type="file"][multiple]:not([webkitdirectory])').first().setInputFiles(
|
||||
files.map((f) => ({ name: f.name, mimeType: 'text/plain', buffer: Buffer.from(f.text) })),
|
||||
);
|
||||
}
|
||||
|
||||
async function shot(page: Page, name: string): Promise<void> {
|
||||
if (SHOTS) await page.screenshot({ path: path.join(SHOTS, name) });
|
||||
}
|
||||
|
||||
const row = (page: Page, name: string) => page.locator(`[data-resource="${name}"]`).first();
|
||||
|
||||
test.describe('Files (pim-3, pim-10, pim-16, pim-26)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.afterAll(async () => {
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
await cleanUp(a);
|
||||
});
|
||||
|
||||
test('an upload over an existing name asks, and each answer shows on the server', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const name = `${PREFIX}.txt`;
|
||||
const copy = `${PREFIX} (1).txt`;
|
||||
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, text: 'one' }]);
|
||||
await expect.poll(async () => (await ours(a)).map((n) => n.name), { timeout: 30000 }).toEqual([name]);
|
||||
const [first] = await ours(a);
|
||||
await expect(row(page, name)).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Replace: the same node, a new blob, no copy.
|
||||
await upload(page, [{ name, text: 'two, longer' }]);
|
||||
const dialog = page.getByRole('alertdialog', { name: `${name} already exists.` });
|
||||
await expect(dialog).toBeVisible({ timeout: 30000 });
|
||||
await expect(dialog.getByRole('checkbox')).toHaveCount(0);
|
||||
await shot(page, 'pim-fix-10-after.png');
|
||||
await dialog.getByRole('button', { name: 'Replace', exact: true }).click();
|
||||
await expect.poll(async () => {
|
||||
const list = await ours(a);
|
||||
return list.map((n) => ({ id: n.id, name: n.name, newBlob: n.blobId !== first.blobId }));
|
||||
}, { timeout: 30000 }).toEqual([{ id: first.id, name, newBlob: true }]);
|
||||
const replaced = (await ours(a))[0];
|
||||
|
||||
// Keep both: the "(1)" copy, the original untouched.
|
||||
await upload(page, [{ name, text: 'three' }]);
|
||||
await dialog.getByRole('button', { name: 'Keep both' }).click();
|
||||
await expect.poll(async () => (await ours(a)).map((n) => n.name).sort(), { timeout: 30000 }).toEqual([copy, name].sort());
|
||||
expect((await ours(a)).find((n) => n.name === name)).toMatchObject({ id: first.id, blobId: replaced.blobId });
|
||||
const before = JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort());
|
||||
|
||||
// Skip: nothing changes.
|
||||
await expect(row(page, copy)).toBeVisible({ timeout: 30000 });
|
||||
await upload(page, [{ name, text: 'four' }]);
|
||||
await dialog.getByRole('button', { name: 'Skip' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
// Two conflicts: "Do this for all 2 conflicts", and Skip answers both.
|
||||
await upload(page, [{ name, text: 'five' }, { name: copy, text: 'six' }]);
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('checkbox', { name: 'Do this for all 2 conflicts' }).check();
|
||||
await dialog.getByRole('button', { name: 'Skip' }).click();
|
||||
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||||
await page.waitForTimeout(3000);
|
||||
expect(JSON.stringify((await ours(a)).map((n) => [n.id, n.name, n.blobId]).sort())).toBe(before);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('double-click opens a preview that stays, a file with no preview gets a pane, delete has Undo', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const name = `${PREFIX}.txt`;
|
||||
const zip = `${PREFIX}.bin`;
|
||||
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: zip, text: 'binary-ish' }]);
|
||||
await expect(row(page, zip)).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// pim-3: one click selects, a double-click opens, and it stays open.
|
||||
await row(page, name).getByTitle(name).click();
|
||||
await expect(row(page, name)).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.getByRole('dialog', { name })).toHaveCount(0);
|
||||
await row(page, name).getByTitle(name).dblclick();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.getByRole('dialog', { name })).toBeVisible();
|
||||
await shot(page, 'pim-fix-3-after.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('dialog', { name })).toHaveCount(0);
|
||||
|
||||
// pim-16: no preview, no editor: the pane, not a download.
|
||||
let downloaded = false;
|
||||
page.on('download', () => { downloaded = true; });
|
||||
await row(page, zip).getByTitle(zip).dblclick();
|
||||
const pane = page.getByTestId('file-no-preview');
|
||||
await expect(pane).toBeVisible();
|
||||
await expect(pane).toContainText('There is no preview for this type of file.');
|
||||
await expect(pane.getByRole('button', { name: 'Download' })).toBeVisible();
|
||||
await shot(page, 'pim-fix-16-after.png');
|
||||
expect(downloaded).toBe(false);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// pim-26: delete goes to the trash with Undo, and Undo puts it back.
|
||||
await row(page, zip).click();
|
||||
await page.keyboard.press('Delete');
|
||||
const undo = page.getByRole('button', { name: 'Undo' }).last();
|
||||
await expect(undo).toBeVisible({ timeout: 30000 });
|
||||
await shot(page, 'pim-fix-26-undo-after.png');
|
||||
const zipId = (await ours(a)).find((n) => n.name === zip)!.id;
|
||||
await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).not.toBeNull();
|
||||
await undo.click();
|
||||
await expect.poll(async () => (await nodes(a)).find((n) => n.id === zipId)?.parentId ?? null, { timeout: 30000 }).toBeNull();
|
||||
await expect(row(page, zip)).toBeVisible({ timeout: 30000 });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('sharing with someone offers a message with a link to the file', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const b = await JmapClient.connect(B, PASSWORD);
|
||||
const name = `${PREFIX}.txt`;
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await openFilesRoot(page);
|
||||
// Run on its own (-g), it makes the file the first test made.
|
||||
if (!(await ours(a)).some((n) => n.name === name)) await upload(page, [{ name, text: 'one' }]);
|
||||
await expect.poll(async () => (await ours(a)).some((n) => n.name === name), { timeout: 30000 }).toBe(true);
|
||||
const node = (await ours(a)).find((n) => n.name === name)!;
|
||||
await expect(row(page, name)).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await row(page, name).getByTitle(name).click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: /^Share/ }).click();
|
||||
const shareDialog = page.getByRole('dialog').last();
|
||||
await shareDialog.getByRole('button', { name: 'Add person or group' }).click();
|
||||
await shareDialog.getByRole('textbox', { name: 'Search by name or email…' }).fill('dso-ux-pim-b');
|
||||
await shareDialog.getByRole('button', { name: /dso-ux-pim-b/ }).first().click();
|
||||
const send = page.getByRole('button', { name: 'Send a message' });
|
||||
await expect(send).toBeVisible({ timeout: 30000 });
|
||||
await expect.poll(async () => Object.keys((await nodes(a)).find((n) => n.id === node.id)?.shareWith ?? {}).length, { timeout: 30000 }).toBe(1);
|
||||
await shot(page, 'pim-fix-26-share-after.png');
|
||||
await send.click();
|
||||
|
||||
// The composer, to b, with a link the person opens it by.
|
||||
const link = `?shared=${encodeURIComponent(`${a.filesAccountId}:${node.id}`)}`;
|
||||
await expect(page.getByText(B).first()).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.locator('[contenteditable="true"], textarea').filter({ hasText: link }).first()).toBeVisible({ timeout: 30000 });
|
||||
await shot(page, 'pim-fix-26-compose-after.png');
|
||||
|
||||
// b sees the node under that id.
|
||||
const shared = await b.request([['FileNode/get', { accountId: a.filesAccountId, ids: [node.id], properties: ['id', 'name'] }, '0']], [CORE, FILENODE]);
|
||||
expect(shared.methodResponses[0][1].list?.[0]?.name).toBe(name);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { findUploadConflicts, resolveConflictsInTurn, type UploadConflict } from '@/lib/files-upload-conflicts';
|
||||
|
||||
describe('findUploadConflicts', () => {
|
||||
it('lists same-kind clashes once, in upload order', () => {
|
||||
const incoming: UploadConflict[] = [
|
||||
{ name: 'b.txt', kind: 'file' },
|
||||
{ name: 'Docs', kind: 'folder' },
|
||||
{ name: 'Docs', kind: 'folder' },
|
||||
{ name: 'Photos', kind: 'folder' },
|
||||
{ name: 'new.txt', kind: 'file' },
|
||||
];
|
||||
const existing = [
|
||||
{ name: 'Docs', isDirectory: true },
|
||||
{ name: 'b.txt', isDirectory: false },
|
||||
{ name: 'Photos', isDirectory: false },
|
||||
];
|
||||
expect(findUploadConflicts(incoming, existing)).toEqual([
|
||||
{ name: 'b.txt', kind: 'file' },
|
||||
{ name: 'Docs', kind: 'folder' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConflictsInTurn', () => {
|
||||
const conflicts: UploadConflict[] = [
|
||||
{ name: 'a.txt', kind: 'file' },
|
||||
{ name: 'b.txt', kind: 'file' },
|
||||
{ name: 'c.txt', kind: 'file' },
|
||||
];
|
||||
|
||||
it('asks one at a time, and "Do this for all" answers the rest', async () => {
|
||||
const ask = vi.fn()
|
||||
.mockResolvedValueOnce({ choice: 'skip', applyToAll: false })
|
||||
.mockResolvedValueOnce({ choice: 'replace', applyToAll: true });
|
||||
const choices = await resolveConflictsInTurn(conflicts, ask);
|
||||
expect(ask).toHaveBeenCalledTimes(2);
|
||||
expect(ask.mock.calls.map((c) => [c[0].name, c[1], c[2]])).toEqual([['a.txt', 0, 3], ['b.txt', 1, 3]]);
|
||||
expect([...choices!]).toEqual([['a.txt', 'skip'], ['b.txt', 'replace'], ['c.txt', 'replace']]);
|
||||
});
|
||||
|
||||
it('stops when a question is called off', async () => {
|
||||
const ask = vi.fn().mockResolvedValueOnce({ choice: 'keep', applyToAll: false }).mockResolvedValueOnce(null);
|
||||
expect(await resolveConflictsInTurn(conflicts, ask)).toBeNull();
|
||||
expect(ask).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Uploading something whose name is already taken in the target folder
|
||||
* (pim-10). The upload asks first, per name: Replace, Keep both or Skip.
|
||||
*
|
||||
* - A file over a file: Replace gives the existing node the new content, so
|
||||
* it keeps its id, its shares and its links. Keep both adds "name (1).ext".
|
||||
* - A folder over a folder (folder upload): Replace puts the folder's content
|
||||
* into the existing folder and replaces files of the same name in it, as a
|
||||
* file manager's "merge" does. Keep both makes "Folder (1)".
|
||||
* - A file and a folder of the same name can't replace each other: the new
|
||||
* one is kept beside it with a number, without asking, as before.
|
||||
*/
|
||||
|
||||
export type UploadConflictKind = 'file' | 'folder';
|
||||
|
||||
export interface UploadConflict {
|
||||
/** The name in the target folder: a file's name, or a folder upload's top folder. */
|
||||
name: string;
|
||||
kind: UploadConflictKind;
|
||||
}
|
||||
|
||||
export type UploadConflictChoice = 'replace' | 'keep' | 'skip';
|
||||
|
||||
/**
|
||||
* Asks about the conflicts, in order. Resolves to a choice per name, or to
|
||||
* null when the user called the whole upload off.
|
||||
*/
|
||||
export type UploadConflictResolver = (conflicts: UploadConflict[]) => Promise<Map<string, UploadConflictChoice> | null>;
|
||||
|
||||
export interface ExistingEntry {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What of `incoming` clashes with `existing`, in the order of `incoming`.
|
||||
* Only same-kind clashes are conflicts; each name is listed once.
|
||||
*/
|
||||
export function findUploadConflicts(incoming: UploadConflict[], existing: ExistingEntry[]): UploadConflict[] {
|
||||
const files = new Set(existing.filter((e) => !e.isDirectory).map((e) => e.name));
|
||||
const folders = new Set(existing.filter((e) => e.isDirectory).map((e) => e.name));
|
||||
const seen = new Set<string>();
|
||||
const conflicts: UploadConflict[] = [];
|
||||
for (const entry of incoming) {
|
||||
const key = `${entry.kind}:${entry.name}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if ((entry.kind === 'file' ? files : folders).has(entry.name)) conflicts.push(entry);
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/** A conflict's answer: the choice, whether it covers the rest, or stop. */
|
||||
export type ConflictAnswer = { choice: UploadConflictChoice; applyToAll: boolean } | null;
|
||||
|
||||
/**
|
||||
* Goes through the conflicts one question at a time; "Do this for all"
|
||||
* answers the rest with the same choice. Null when a question was called off.
|
||||
*/
|
||||
export async function resolveConflictsInTurn(
|
||||
conflicts: UploadConflict[],
|
||||
ask: (conflict: UploadConflict, index: number, total: number) => Promise<ConflictAnswer>,
|
||||
): Promise<Map<string, UploadConflictChoice> | null> {
|
||||
const choices = new Map<string, UploadConflictChoice>();
|
||||
for (let i = 0; i < conflicts.length; i++) {
|
||||
const answer = await ask(conflicts[i], i, conflicts.length);
|
||||
if (!answer) return null;
|
||||
if (answer.applyToAll) {
|
||||
for (const rest of conflicts.slice(i)) choices.set(rest.name, answer.choice);
|
||||
break;
|
||||
}
|
||||
choices.set(conflicts[i].name, answer.choice);
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "الملف التالي",
|
||||
"loading": "جارٍ التحميل…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} موجود بالفعل.",
|
||||
"conflict_file_hint": "الاستبدال يضع المحتوى الجديد في الملف الموجود، فتبقى مشاركاته. الاحتفاظ بالاثنين يضيف نسخة برقم.",
|
||||
"conflict_folder_hint": "الاستبدال يضيف الملفات إلى المجلد الموجود ويستبدل الملفات التي تحمل الاسم نفسه. الاحتفاظ بالاثنين ينشئ مجلدًا ثانيًا برقم.",
|
||||
"replace": "استبدال",
|
||||
"keep_both": "الاحتفاظ بالاثنين",
|
||||
"skip": "تخطي",
|
||||
"apply_to_all": "{count, plural, other {تطبيق ذلك على كل التعارضات ({count})}}",
|
||||
"no_preview": "لا توجد معاينة لهذا النوع من الملفات.",
|
||||
"open_in_app": "فتح في التطبيق الافتراضي",
|
||||
"shared_with": "أصبح {name} مشتركًا مع {person}.",
|
||||
"send_message": "إرسال رسالة",
|
||||
"share_mail_subject": "شاركت {name} معك",
|
||||
"share_mail_body": "مرحبًا،\n\nشاركت «{name}» معك. يمكنك فتحه من هنا:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "شهاداتك",
|
||||
"your_certificates_desc": "استيراد وإدارة شهادات S/MIME الخاصة بك لتوقيع الرسائل وتشفيرها",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Fitxer següent",
|
||||
"loading": "S'està carregant…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} ja existeix.",
|
||||
"conflict_file_hint": "Substitueix posa el contingut nou al fitxer existent, i se’n mantenen les comparticions. Conserva’ls tots dos afegeix una còpia amb un número.",
|
||||
"conflict_folder_hint": "Substitueix afegeix els fitxers a la carpeta existent i substitueix els que tenen el mateix nom. Conserva’ls tots dos crea una segona carpeta amb un número.",
|
||||
"replace": "Substitueix",
|
||||
"keep_both": "Conserva’ls tots dos",
|
||||
"skip": "Omet",
|
||||
"apply_to_all": "{count, plural, one {Fes el mateix amb el conflicte} other {Fes el mateix amb els # conflictes}}",
|
||||
"no_preview": "No hi ha previsualització per a aquest tipus de fitxer.",
|
||||
"open_in_app": "Obre amb l’aplicació predeterminada",
|
||||
"shared_with": "{name} ara està compartit amb {person}.",
|
||||
"send_message": "Envia un missatge",
|
||||
"share_mail_subject": "He compartit {name} amb tu",
|
||||
"share_mail_body": "Hola,\n\nHe compartit «{name}» amb tu. El pots obrir aquí:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Els vostres certificats",
|
||||
"your_certificates_desc": "Importeu i gestioneu els certificats S/MIME per signar i xifrar correus",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Další soubor",
|
||||
"loading": "Načítání…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} už existuje.",
|
||||
"conflict_file_hint": "Nahradit vloží nový obsah do existujícího souboru, takže jeho sdílení zůstane. Ponechat obojí přidá kopii s číslem.",
|
||||
"conflict_folder_hint": "Nahradit přidá soubory do existující složky a nahradí soubory se stejným názvem. Ponechat obojí vytvoří druhou složku s číslem.",
|
||||
"replace": "Nahradit",
|
||||
"keep_both": "Ponechat obojí",
|
||||
"skip": "Přeskočit",
|
||||
"apply_to_all": "{count, plural, other {Udělat totéž u všech konfliktů ({count})}}",
|
||||
"no_preview": "Pro tento typ souboru není náhled.",
|
||||
"open_in_app": "Otevřít ve výchozí aplikaci",
|
||||
"shared_with": "{name} je teď sdílen s {person}.",
|
||||
"send_message": "Poslat zprávu",
|
||||
"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}"
|
||||
},
|
||||
"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ů",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Næste fil",
|
||||
"loading": "Indlæser…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} findes allerede.",
|
||||
"conflict_file_hint": "Erstat lægger det nye indhold i den eksisterende fil, så dens delinger bliver. Behold begge tilføjer en kopi med et nummer.",
|
||||
"conflict_folder_hint": "Erstat lægger filerne i den eksisterende mappe og erstatter filer med samme navn. Behold begge opretter en ekstra mappe med et nummer.",
|
||||
"replace": "Erstat",
|
||||
"keep_both": "Behold begge",
|
||||
"skip": "Spring over",
|
||||
"apply_to_all": "{count, plural, one {Gør det samme for konflikten} other {Gør det samme for alle # konflikter}}",
|
||||
"no_preview": "Der er ingen forhåndsvisning af denne filtype.",
|
||||
"open_in_app": "Åbn i standardprogram",
|
||||
"shared_with": "{name} er nu delt med {person}.",
|
||||
"send_message": "Send en besked",
|
||||
"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}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Dine certifikater",
|
||||
"your_certificates_desc": "Importér og administrér dine S/MIME-certifikater til signering og kryptering af e-mails",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Nächste Datei",
|
||||
"loading": "Wird geladen…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} ist schon vorhanden.",
|
||||
"conflict_file_hint": "Ersetzen legt den neuen Inhalt in die vorhandene Datei, ihre Freigaben bleiben. Beide behalten legt eine Kopie mit einer Nummer an.",
|
||||
"conflict_folder_hint": "Ersetzen legt die Dateien in den vorhandenen Ordner und ersetzt Dateien mit gleichem Namen. Beide behalten legt einen zweiten Ordner mit einer Nummer an.",
|
||||
"replace": "Ersetzen",
|
||||
"keep_both": "Beide behalten",
|
||||
"skip": "Überspringen",
|
||||
"apply_to_all": "{count, plural, other {Für alle # Konflikte so machen}}",
|
||||
"no_preview": "Für diese Art von Datei gibt es keine Vorschau.",
|
||||
"open_in_app": "Im Standardprogramm öffnen",
|
||||
"shared_with": "{name} ist jetzt für {person} freigegeben.",
|
||||
"send_message": "Nachricht senden",
|
||||
"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}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ihre Zertifikate",
|
||||
"your_certificates_desc": "Importieren und verwalten Sie Ihre S/MIME-Zertifikate zum Signieren und Verschlüsseln von E-Mails",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Next file",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} already exists.",
|
||||
"conflict_file_hint": "Replace puts the new content into the existing file, so its shares stay. Keep both adds a copy with a number.",
|
||||
"conflict_folder_hint": "Replace adds the files to the existing folder and replaces files with the same name. Keep both makes a second folder with a number.",
|
||||
"replace": "Replace",
|
||||
"keep_both": "Keep both",
|
||||
"skip": "Skip",
|
||||
"apply_to_all": "{count, plural, other {Do this for all # conflicts}}",
|
||||
"no_preview": "There is no preview for this type of file.",
|
||||
"open_in_app": "Open in default app",
|
||||
"shared_with": "{name} is now shared with {person}.",
|
||||
"send_message": "Send a message",
|
||||
"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}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Your Certificates",
|
||||
"your_certificates_desc": "Import and manage your S/MIME certificates for signing and encrypting emails",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Archivo siguiente",
|
||||
"loading": "Cargando…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} ya existe.",
|
||||
"conflict_file_hint": "Reemplazar pone el contenido nuevo en el archivo existente, así se mantienen sus permisos. Conservar ambos añade una copia con un número.",
|
||||
"conflict_folder_hint": "Reemplazar añade los archivos a la carpeta existente y reemplaza los que tienen el mismo nombre. Conservar ambos crea una segunda carpeta con un número.",
|
||||
"replace": "Reemplazar",
|
||||
"keep_both": "Conservar ambos",
|
||||
"skip": "Omitir",
|
||||
"apply_to_all": "{count, plural, one {Hacer lo mismo con el conflicto} other {Hacer lo mismo con los # conflictos}}",
|
||||
"no_preview": "No hay vista previa para este tipo de archivo.",
|
||||
"open_in_app": "Abrir en la aplicación predeterminada",
|
||||
"shared_with": "{name} ahora está compartido con {person}.",
|
||||
"send_message": "Enviar un mensaje",
|
||||
"share_mail_subject": "He compartido {name} contigo",
|
||||
"share_mail_body": "Hola:\n\nHe compartido «{name}» contigo. Puedes abrirlo aquí:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Tus certificados",
|
||||
"your_certificates_desc": "Importa y administra tus certificados S/MIME para firmar y cifrar correos",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Järgmine fail",
|
||||
"loading": "Laadimine…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} on juba olemas.",
|
||||
"conflict_file_hint": "Asenda paneb uue sisu olemasolevasse faili, nii jäävad selle jagamised alles. Jäta mõlemad lisab nummerdatud koopia.",
|
||||
"conflict_folder_hint": "Asenda lisab failid olemasolevasse kausta ja asendab sama nimega failid. Jäta mõlemad loob teise, nummerdatud kausta.",
|
||||
"replace": "Asenda",
|
||||
"keep_both": "Jäta mõlemad",
|
||||
"skip": "Jäta vahele",
|
||||
"apply_to_all": "{count, plural, one {Tee sama konfliktiga} other {Tee sama kõigi # konfliktiga}}",
|
||||
"no_preview": "Seda tüüpi failil pole eelvaadet.",
|
||||
"open_in_app": "Ava vaikerakenduses",
|
||||
"shared_with": "{name} on nüüd jagatud kasutajaga {person}.",
|
||||
"send_message": "Saada sõnum",
|
||||
"share_mail_subject": "Jagasin sinuga faili {name}",
|
||||
"share_mail_body": "Tere\n\nJagasin sinuga „{name}“. Saad selle avada siin:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Sinu sertifikaadid",
|
||||
"your_certificates_desc": "Impordi ja halda oma S/MIME sertifikaate e-kirjade allkirjastamiseks ja krüpteerimiseks",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "فایل بعدی",
|
||||
"loading": "در حال بارگیری…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} از قبل وجود دارد.",
|
||||
"conflict_file_hint": "جایگزینی محتوای تازه را در همان فایل موجود میگذارد و اشتراکهایش میمانند. نگهداشتن هر دو یک رونوشت شمارهدار میسازد.",
|
||||
"conflict_folder_hint": "جایگزینی فایلها را به پوشهٔ موجود اضافه میکند و فایلهای همنام را جایگزین میکند. نگهداشتن هر دو پوشهٔ دومی با شماره میسازد.",
|
||||
"replace": "جایگزینی",
|
||||
"keep_both": "نگهداشتن هر دو",
|
||||
"skip": "رد شدن",
|
||||
"apply_to_all": "{count, plural, other {همین کار برای همهٔ # تداخل}}",
|
||||
"no_preview": "برای این نوع فایل پیشنمایشی وجود ندارد.",
|
||||
"open_in_app": "باز کردن در برنامهٔ پیشفرض",
|
||||
"shared_with": "{name} اکنون با {person} به اشتراک گذاشته شد.",
|
||||
"send_message": "ارسال پیام",
|
||||
"share_mail_subject": "{name} را با شما به اشتراک گذاشتم",
|
||||
"share_mail_body": "سلام،\n\n«{name}» را با شما به اشتراک گذاشتم. میتوانید آن را اینجا باز کنید:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "گواهیهای شما",
|
||||
"your_certificates_desc": "مدیریت گواهیهای S/MIME",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Fichier suivant",
|
||||
"loading": "Chargement…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} existe déjà.",
|
||||
"conflict_file_hint": "Remplacer met le nouveau contenu dans le fichier existant, ses partages restent. Conserver les deux ajoute une copie numérotée.",
|
||||
"conflict_folder_hint": "Remplacer ajoute les fichiers au dossier existant et remplace ceux qui portent le même nom. Conserver les deux crée un second dossier numéroté.",
|
||||
"replace": "Remplacer",
|
||||
"keep_both": "Conserver les deux",
|
||||
"skip": "Ignorer",
|
||||
"apply_to_all": "{count, plural, one {Faire de même pour le conflit} other {Faire de même pour les # conflits}}",
|
||||
"no_preview": "Aucun aperçu pour ce type de fichier.",
|
||||
"open_in_app": "Ouvrir dans l’application par défaut",
|
||||
"shared_with": "{name} est maintenant partagé avec {person}.",
|
||||
"send_message": "Envoyer un message",
|
||||
"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}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Vos certificats",
|
||||
"your_certificates_desc": "Importez et gérez vos certificats S/MIME pour signer et chiffrer les e-mails",
|
||||
|
||||
@@ -5308,6 +5308,21 @@
|
||||
"next_file": "הקובץ הבא",
|
||||
"loading": "טוען…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} כבר קיים.",
|
||||
"conflict_file_hint": "החלפה שמה את התוכן החדש בקובץ הקיים, כך שהשיתופים שלו נשמרים. שמירת שניהם מוסיפה עותק עם מספר.",
|
||||
"conflict_folder_hint": "החלפה מוסיפה את הקבצים לתיקייה הקיימת ומחליפה קבצים עם אותו שם. שמירת שניהם יוצרת תיקייה שנייה עם מספר.",
|
||||
"replace": "החלפה",
|
||||
"keep_both": "שמירת שניהם",
|
||||
"skip": "דילוג",
|
||||
"apply_to_all": "{count, plural, other {לעשות כך לכל # ההתנגשויות}}",
|
||||
"no_preview": "אין תצוגה מקדימה לסוג קובץ זה.",
|
||||
"open_in_app": "פתיחה ביישום ברירת המחדל",
|
||||
"shared_with": "{name} משותף עכשיו עם {person}.",
|
||||
"send_message": "שליחת הודעה",
|
||||
"share_mail_subject": "שיתפתי איתך את {name}",
|
||||
"share_mail_body": "שלום,\n\nשיתפתי איתך את \"{name}\". אפשר לפתוח אותו כאן:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "התעודות שלך",
|
||||
"your_certificates_desc": "ייבא ונהל את אישורי ה-S/MIME שלך עבור חתימה והצפנה של הודעות דוא\"ל",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Következő fájl",
|
||||
"loading": "Betöltés…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} már létezik.",
|
||||
"conflict_file_hint": "A Csere az új tartalmat a meglévő fájlba teszi, így a megosztásai megmaradnak. A Mindkettő megtartása számozott másolatot ad hozzá.",
|
||||
"conflict_folder_hint": "A Csere a fájlokat a meglévő mappába teszi, és lecseréli az azonos nevű fájlokat. A Mindkettő megtartása egy második, számozott mappát hoz létre.",
|
||||
"replace": "Csere",
|
||||
"keep_both": "Mindkettő megtartása",
|
||||
"skip": "Kihagyás",
|
||||
"apply_to_all": "{count, plural, other {Ugyanez mind a(z) # ütközésnél}}",
|
||||
"no_preview": "Ehhez a fájltípushoz nincs előnézet.",
|
||||
"open_in_app": "Megnyitás az alapértelmezett alkalmazásban",
|
||||
"shared_with": "{name} most már meg van osztva vele: {person}.",
|
||||
"send_message": "Üzenet küldése",
|
||||
"share_mail_subject": "Megosztottam veled: {name}",
|
||||
"share_mail_body": "Szia!\n\nMegosztottam veled ezt: „{name}”. Itt tudod megnyitni:\n{link}"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "File successivo",
|
||||
"loading": "Caricamento…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} esiste già.",
|
||||
"conflict_file_hint": "Sostituisci mette il nuovo contenuto nel file esistente, così le sue condivisioni restano. Mantieni entrambi aggiunge una copia numerata.",
|
||||
"conflict_folder_hint": "Sostituisci aggiunge i file alla cartella esistente e sostituisce quelli con lo stesso nome. Mantieni entrambi crea una seconda cartella numerata.",
|
||||
"replace": "Sostituisci",
|
||||
"keep_both": "Mantieni entrambi",
|
||||
"skip": "Salta",
|
||||
"apply_to_all": "{count, plural, one {Fai lo stesso per il conflitto} other {Fai lo stesso per tutti i # conflitti}}",
|
||||
"no_preview": "Nessuna anteprima per questo tipo di file.",
|
||||
"open_in_app": "Apri con l’app predefinita",
|
||||
"shared_with": "{name} ora è condiviso con {person}.",
|
||||
"send_message": "Invia un messaggio",
|
||||
"share_mail_subject": "Ho condiviso {name} con te",
|
||||
"share_mail_body": "Ciao,\n\nho condiviso «{name}» con te. Puoi aprirlo qui:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "I tuoi certificati",
|
||||
"your_certificates_desc": "Importa e gestisci i tuoi certificati S/MIME per firmare e cifrare le email",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "次のファイル",
|
||||
"loading": "読み込み中…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} は既に存在します。",
|
||||
"conflict_file_hint": "置き換えると、既存のファイルに新しい内容が入り、共有設定はそのまま残ります。両方残すと、番号付きのコピーが追加されます。",
|
||||
"conflict_folder_hint": "置き換えると、既存のフォルダーにファイルが追加され、同じ名前のファイルは置き換えられます。両方残すと、番号付きの別フォルダーが作られます。",
|
||||
"replace": "置き換える",
|
||||
"keep_both": "両方残す",
|
||||
"skip": "スキップ",
|
||||
"apply_to_all": "{count, plural, other {# 件すべての競合に適用}}",
|
||||
"no_preview": "この種類のファイルはプレビューできません。",
|
||||
"open_in_app": "既定のアプリで開く",
|
||||
"shared_with": "{name} を {person} と共有しました。",
|
||||
"send_message": "メッセージを送信",
|
||||
"share_mail_subject": "{name} を共有しました",
|
||||
"share_mail_body": "こんにちは。\n\n「{name}」を共有しました。こちらから開けます:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "あなたの証明書",
|
||||
"your_certificates_desc": "メールの署名と暗号化のために S/MIME 証明書をインポートして管理します",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "다음 파일",
|
||||
"loading": "불러오는 중…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name}이(가) 이미 있습니다.",
|
||||
"conflict_file_hint": "바꾸기를 하면 기존 파일에 새 내용이 들어가고 공유 설정은 그대로 유지됩니다. 둘 다 유지하면 번호가 붙은 사본이 추가됩니다.",
|
||||
"conflict_folder_hint": "바꾸기를 하면 기존 폴더에 파일이 추가되고 이름이 같은 파일은 바뀝니다. 둘 다 유지하면 번호가 붙은 두 번째 폴더가 만들어집니다.",
|
||||
"replace": "바꾸기",
|
||||
"keep_both": "둘 다 유지",
|
||||
"skip": "건너뛰기",
|
||||
"apply_to_all": "{count, plural, other {충돌 #개 모두에 적용}}",
|
||||
"no_preview": "이 유형의 파일은 미리 볼 수 없습니다.",
|
||||
"open_in_app": "기본 앱에서 열기",
|
||||
"shared_with": "{name}을(를) 이제 {person}님과 공유합니다.",
|
||||
"send_message": "메시지 보내기",
|
||||
"share_mail_subject": "{name}을(를) 공유했습니다",
|
||||
"share_mail_body": "안녕하세요.\n\n\"{name}\"을(를) 공유했습니다. 여기에서 열 수 있습니다:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "내 인증서",
|
||||
"your_certificates_desc": "이메일 서명과 암호화를 위한 S/MIME 인증서를 가져오고 관리해요",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Nākamais fails",
|
||||
"loading": "Ielādē…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} jau pastāv.",
|
||||
"conflict_file_hint": "Aizstāt ieliek jauno saturu esošajā failā, tāpēc tā koplietošana paliek. Paturēt abus pievieno numurētu kopiju.",
|
||||
"conflict_folder_hint": "Aizstāt pievieno failus esošajai mapei un aizstāj failus ar tādu pašu nosaukumu. Paturēt abus izveido otru, numurētu mapi.",
|
||||
"replace": "Aizstāt",
|
||||
"keep_both": "Paturēt abus",
|
||||
"skip": "Izlaist",
|
||||
"apply_to_all": "{count, plural, other {Darīt tāpat visiem # konfliktiem}}",
|
||||
"no_preview": "Šim faila tipam nav priekšskatījuma.",
|
||||
"open_in_app": "Atvērt noklusējuma lietotnē",
|
||||
"shared_with": "{name} tagad ir koplietots ar {person}.",
|
||||
"send_message": "Sūtīt ziņu",
|
||||
"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}"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Дараах файл",
|
||||
"loading": "Ачаалж байна…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} аль хэдийн байна.",
|
||||
"conflict_file_hint": "Солих нь шинэ агуулгыг байгаа файлд хийх тул хуваалцсан тохиргоо нь хэвээр үлдэнэ. Хоёуланг нь хадгалах нь дугаартай хуулбар нэмнэ.",
|
||||
"conflict_folder_hint": "Солих нь файлуудыг байгаа хавтсанд нэмж, ижил нэртэй файлуудыг солино. Хоёуланг нь хадгалах нь дугаартай хоёр дахь хавтас үүсгэнэ.",
|
||||
"replace": "Солих",
|
||||
"keep_both": "Хоёуланг нь хадгалах",
|
||||
"skip": "Алгасах",
|
||||
"apply_to_all": "{count, plural, other {Бүх # зөрчилд ингэх}}",
|
||||
"no_preview": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
|
||||
"open_in_app": "Үндсэн програмаар нээх",
|
||||
"shared_with": "{name}-г одоо {person}-тэй хуваалцаж байна.",
|
||||
"send_message": "Мессеж илгээх",
|
||||
"share_mail_subject": "Би {name}-г тантай хуваалцлаа",
|
||||
"share_mail_body": "Сайн байна уу,\n\nБи \"{name}\"-г тантай хуваалцлаа. Эндээс нээж болно:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Таны сертификатууд",
|
||||
"your_certificates_desc": "Имэйлд гарын үсэг зурах болон шифрлэхэд ашиглах S/MIME сертификатаа импортолж, удирдана",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Neste fil",
|
||||
"loading": "Laster inn…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} finnes allerede.",
|
||||
"conflict_file_hint": "Erstatt legger det nye innholdet i den eksisterende filen, så delingene blir. Behold begge legger til en kopi med et nummer.",
|
||||
"conflict_folder_hint": "Erstatt legger filene i den eksisterende mappen og erstatter filer med samme navn. Behold begge lager en ny mappe med et nummer.",
|
||||
"replace": "Erstatt",
|
||||
"keep_both": "Behold begge",
|
||||
"skip": "Hopp over",
|
||||
"apply_to_all": "{count, plural, one {Gjør det samme for konflikten} other {Gjør det samme for alle # konfliktene}}",
|
||||
"no_preview": "Det finnes ingen forhåndsvisning for denne filtypen.",
|
||||
"open_in_app": "Åpne i standardprogram",
|
||||
"shared_with": "{name} er nå delt med {person}.",
|
||||
"send_message": "Send en melding",
|
||||
"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}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Dine sertifikater",
|
||||
"your_certificates_desc": "Importer og administrer S/MIME-sertifikatene dine for signering og kryptering av e-post",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Volgend bestand",
|
||||
"loading": "Laden…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} bestaat al.",
|
||||
"conflict_file_hint": "Vervangen zet de nieuwe inhoud in het bestaande bestand, zodat het gedeeld blijft. Beide behouden voegt een kopie met een nummer toe.",
|
||||
"conflict_folder_hint": "Vervangen voegt de bestanden toe aan de bestaande map en vervangt bestanden met dezelfde naam. Beide behouden maakt een tweede map met een nummer.",
|
||||
"replace": "Vervangen",
|
||||
"keep_both": "Beide behouden",
|
||||
"skip": "Overslaan",
|
||||
"apply_to_all": "{count, plural, one {Dit doen voor het conflict} other {Dit doen voor alle # conflicten}}",
|
||||
"no_preview": "Er is geen voorbeeld voor dit type bestand.",
|
||||
"open_in_app": "Openen in standaard-app",
|
||||
"shared_with": "{name} is nu gedeeld met {person}.",
|
||||
"send_message": "Bericht sturen",
|
||||
"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}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Uw certificaten",
|
||||
"your_certificates_desc": "Importeer en beheer uw S/MIME-certificaten voor het ondertekenen en versleutelen van e-mails",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Następny plik",
|
||||
"loading": "Wczytywanie…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} już istnieje.",
|
||||
"conflict_file_hint": "Zastąp wkłada nową treść do istniejącego pliku, więc jego udostępnienia zostają. Zachowaj oba dodaje kopię z numerem.",
|
||||
"conflict_folder_hint": "Zastąp dodaje pliki do istniejącego folderu i zastępuje pliki o tej samej nazwie. Zachowaj oba tworzy drugi folder z numerem.",
|
||||
"replace": "Zastąp",
|
||||
"keep_both": "Zachowaj oba",
|
||||
"skip": "Pomiń",
|
||||
"apply_to_all": "{count, plural, other {Zrób tak dla wszystkich konfliktów ({count})}}",
|
||||
"no_preview": "Dla tego typu pliku nie ma podglądu.",
|
||||
"open_in_app": "Otwórz w domyślnej aplikacji",
|
||||
"shared_with": "{name} jest teraz udostępniony dla {person}.",
|
||||
"send_message": "Wyślij wiadomość",
|
||||
"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}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Twoje certyfikaty",
|
||||
"your_certificates_desc": "Importuj i zarządzaj swoimi certyfikatami S/MIME do podpisywania i szyfrowania wiadomości e-mail",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Próximo arquivo",
|
||||
"loading": "Carregando…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} já existe.",
|
||||
"conflict_file_hint": "Substituir coloca o novo conteúdo no arquivo existente, e os compartilhamentos continuam. Manter ambos adiciona uma cópia com um número.",
|
||||
"conflict_folder_hint": "Substituir adiciona os arquivos à pasta existente e substitui os que têm o mesmo nome. Manter ambos cria uma segunda pasta com um número.",
|
||||
"replace": "Substituir",
|
||||
"keep_both": "Manter ambos",
|
||||
"skip": "Ignorar",
|
||||
"apply_to_all": "{count, plural, one {Fazer o mesmo para o conflito} other {Fazer o mesmo para os # conflitos}}",
|
||||
"no_preview": "Não há pré-visualização para este tipo de arquivo.",
|
||||
"open_in_app": "Abrir no aplicativo padrão",
|
||||
"shared_with": "{name} agora está compartilhado com {person}.",
|
||||
"send_message": "Enviar uma mensagem",
|
||||
"share_mail_subject": "Compartilhei {name} com você",
|
||||
"share_mail_body": "Olá,\n\nCompartilhei \"{name}\" com você. Você pode abrir aqui:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Seus certificados",
|
||||
"your_certificates_desc": "Importe e gerencie seus certificados S/MIME para assinar e criptografar e-mails",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Fișierul următor",
|
||||
"loading": "Se încarcă…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} există deja.",
|
||||
"conflict_file_hint": "Înlocuiește pune conținutul nou în fișierul existent, iar partajările lui rămân. Păstrează-le pe ambele adaugă o copie numerotată.",
|
||||
"conflict_folder_hint": "Înlocuiește adaugă fișierele în dosarul existent și înlocuiește fișierele cu același nume. Păstrează-le pe ambele creează un al doilea dosar numerotat.",
|
||||
"replace": "Înlocuiește",
|
||||
"keep_both": "Păstrează-le pe ambele",
|
||||
"skip": "Omite",
|
||||
"apply_to_all": "{count, plural, other {Fă la fel pentru toate cele # conflicte}}",
|
||||
"no_preview": "Nu există previzualizare pentru acest tip de fișier.",
|
||||
"open_in_app": "Deschide în aplicația implicită",
|
||||
"shared_with": "{name} este acum partajat cu {person}.",
|
||||
"send_message": "Trimite un mesaj",
|
||||
"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}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Certificatele dvs.",
|
||||
"your_certificates_desc": "Importați și gestionați certificatele S/MIME pentru semnarea și criptarea e-mailurilor",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Следующий файл",
|
||||
"loading": "Загрузка…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} уже существует.",
|
||||
"conflict_file_hint": "«Заменить» кладёт новое содержимое в существующий файл, поэтому общий доступ к нему сохраняется. «Оставить оба» добавляет копию с номером.",
|
||||
"conflict_folder_hint": "«Заменить» добавляет файлы в существующую папку и заменяет файлы с теми же именами. «Оставить оба» создаёт вторую папку с номером.",
|
||||
"replace": "Заменить",
|
||||
"keep_both": "Оставить оба",
|
||||
"skip": "Пропустить",
|
||||
"apply_to_all": "{count, plural, other {Так же для всех конфликтов ({count})}}",
|
||||
"no_preview": "Для этого типа файлов нет предпросмотра.",
|
||||
"open_in_app": "Открыть в приложении по умолчанию",
|
||||
"shared_with": "{name}: теперь есть доступ у {person}.",
|
||||
"send_message": "Отправить сообщение",
|
||||
"share_mail_subject": "Я открыл вам доступ к {name}",
|
||||
"share_mail_body": "Здравствуйте!\n\nЯ открыл вам доступ к «{name}». Открыть можно здесь:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ваши сертификаты",
|
||||
"your_certificates_desc": "Импортируйте и управляйте своими сертификатами S/MIME для подписи и шифрования писем",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Nasledujúci súbor",
|
||||
"loading": "Načítava sa…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} už existuje.",
|
||||
"conflict_file_hint": "Nahradiť vloží nový obsah do existujúceho súboru, takže jeho zdieľanie zostane. Ponechať oboje pridá kópiu s číslom.",
|
||||
"conflict_folder_hint": "Nahradiť pridá súbory do existujúceho priečinka a nahradí súbory s rovnakým názvom. Ponechať oboje vytvorí druhý priečinok s číslom.",
|
||||
"replace": "Nahradiť",
|
||||
"keep_both": "Ponechať oboje",
|
||||
"skip": "Preskočiť",
|
||||
"apply_to_all": "{count, plural, other {Urobiť to isté pri všetkých konfliktoch ({count})}}",
|
||||
"no_preview": "Pre tento typ súboru nie je náhľad.",
|
||||
"open_in_app": "Otvoriť v predvolenej aplikácii",
|
||||
"shared_with": "{name} je teraz zdieľaný s {person}.",
|
||||
"send_message": "Poslať správu",
|
||||
"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}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Vaše certifikáty",
|
||||
"your_certificates_desc": "Importujte a spravujte svoje certifikáty S/MIME na podpisovanie a šifrovanie e-mailov",
|
||||
|
||||
@@ -2636,6 +2636,21 @@
|
||||
"zoom_out": "ซูมออก",
|
||||
"zoom_reset": "พอดีหน้าจอ"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} มีอยู่แล้ว",
|
||||
"conflict_file_hint": "แทนที่จะใส่เนื้อหาใหม่ลงในไฟล์เดิม การแชร์ของไฟล์จึงยังคงอยู่ เก็บทั้งสองจะเพิ่มสำเนาที่มีหมายเลข",
|
||||
"conflict_folder_hint": "แทนที่จะเพิ่มไฟล์ลงในโฟลเดอร์เดิมและแทนที่ไฟล์ที่ชื่อซ้ำกัน เก็บทั้งสองจะสร้างโฟลเดอร์ที่สองที่มีหมายเลข",
|
||||
"replace": "แทนที่",
|
||||
"keep_both": "เก็บทั้งสอง",
|
||||
"skip": "ข้าม",
|
||||
"apply_to_all": "{count, plural, other {ทำแบบนี้กับข้อขัดแย้งทั้ง # รายการ}}",
|
||||
"no_preview": "ไฟล์ประเภทนี้ไม่มีตัวอย่าง",
|
||||
"open_in_app": "เปิดในแอปเริ่มต้น",
|
||||
"shared_with": "ตอนนี้แชร์ {name} กับ {person} แล้ว",
|
||||
"send_message": "ส่งข้อความ",
|
||||
"share_mail_subject": "ฉันแชร์ {name} กับคุณ",
|
||||
"share_mail_body": "สวัสดี\n\nฉันแชร์ \"{name}\" กับคุณแล้ว เปิดได้ที่นี่:\n{link}"
|
||||
},
|
||||
"protocol_handlers": {
|
||||
"title": "แอปพลิเคชันเริ่มต้น",
|
||||
"description": "เลือกว่าจะเปิดลิงก์อีเมลและปฏิทินใน {appName} หรือไม่",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "Sonraki dosya",
|
||||
"loading": "Yükleniyor…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} zaten var.",
|
||||
"conflict_file_hint": "Değiştir, yeni içeriği mevcut dosyaya koyar; paylaşımları korunur. İkisini de tut, numaralı bir kopya ekler.",
|
||||
"conflict_folder_hint": "Değiştir, dosyaları mevcut klasöre ekler ve aynı adlı dosyaları değiştirir. İkisini de tut, numaralı ikinci bir klasör oluşturur.",
|
||||
"replace": "Değiştir",
|
||||
"keep_both": "İkisini de tut",
|
||||
"skip": "Atla",
|
||||
"apply_to_all": "{count, plural, other {Tüm # çakışma için bunu yap}}",
|
||||
"no_preview": "Bu dosya türü için önizleme yok.",
|
||||
"open_in_app": "Varsayılan uygulamada aç",
|
||||
"shared_with": "{name} artık {person} ile paylaşılıyor.",
|
||||
"send_message": "Mesaj gönder",
|
||||
"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}"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "Наступний файл",
|
||||
"loading": "Завантаження…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} уже існує.",
|
||||
"conflict_file_hint": "«Замінити» кладе новий вміст в наявний файл, тому спільний доступ до нього лишається. «Залишити обидва» додає копію з номером.",
|
||||
"conflict_folder_hint": "«Замінити» додає файли в наявну папку й замінює файли з тими самими назвами. «Залишити обидва» створює другу папку з номером.",
|
||||
"replace": "Замінити",
|
||||
"keep_both": "Залишити обидва",
|
||||
"skip": "Пропустити",
|
||||
"apply_to_all": "{count, plural, other {Так само для всіх конфліктів ({count})}}",
|
||||
"no_preview": "Для цього типу файлів немає попереднього перегляду.",
|
||||
"open_in_app": "Відкрити в програмі за замовчуванням",
|
||||
"shared_with": "{name}: тепер є доступ у {person}.",
|
||||
"send_message": "Надіслати повідомлення",
|
||||
"share_mail_subject": "Я надав вам доступ до {name}",
|
||||
"share_mail_body": "Вітаю!\n\nЯ надав вам доступ до «{name}». Відкрити можна тут:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ваші сертифікати",
|
||||
"your_certificates_desc": "Імпортуйте та керуйте своїми сертифікатами S/MIME для підпису та шифрування електронних листів",
|
||||
|
||||
@@ -5689,6 +5689,21 @@
|
||||
"next_file": "Tệp tiếp theo",
|
||||
"loading": "Đang tải…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} đã tồn tại.",
|
||||
"conflict_file_hint": "Thay thế đưa nội dung mới vào tệp hiện có, nên các chia sẻ của tệp vẫn giữ nguyên. Giữ cả hai thêm một bản sao có đánh số.",
|
||||
"conflict_folder_hint": "Thay thế thêm các tệp vào thư mục hiện có và thay các tệp trùng tên. Giữ cả hai tạo thêm một thư mục có đánh số.",
|
||||
"replace": "Thay thế",
|
||||
"keep_both": "Giữ cả hai",
|
||||
"skip": "Bỏ qua",
|
||||
"apply_to_all": "{count, plural, other {Làm vậy cho cả # xung đột}}",
|
||||
"no_preview": "Không có bản xem trước cho loại tệp này.",
|
||||
"open_in_app": "Mở bằng ứng dụng mặc định",
|
||||
"shared_with": "{name} giờ đã được chia sẻ với {person}.",
|
||||
"send_message": "Gửi tin nhắn",
|
||||
"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}"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -5677,6 +5677,21 @@
|
||||
"next_file": "下一個檔案",
|
||||
"loading": "載入中…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} 已存在。",
|
||||
"conflict_file_hint": "取代會把新內容放進現有檔案,共用設定維持不變。保留兩者會新增一個帶編號的副本。",
|
||||
"conflict_folder_hint": "取代會把檔案加入現有資料夾,並取代同名檔案。保留兩者會建立一個帶編號的資料夾。",
|
||||
"replace": "取代",
|
||||
"keep_both": "保留兩者",
|
||||
"skip": "略過",
|
||||
"apply_to_all": "{count, plural, other {對全部 # 個衝突執行此動作}}",
|
||||
"no_preview": "此類檔案無法預覽。",
|
||||
"open_in_app": "用預設應用程式開啟",
|
||||
"shared_with": "{name} 已與 {person} 共用。",
|
||||
"send_message": "傳送訊息",
|
||||
"share_mail_subject": "我與你共用了 {name}",
|
||||
"share_mail_body": "你好,\n\n我與你共用了「{name}」。可以在這裡開啟:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "您的憑證",
|
||||
"your_certificates_desc": "匯入與管理用於郵件簽章與加密的 S/MIME 憑證",
|
||||
|
||||
@@ -5415,6 +5415,21 @@
|
||||
"next_file": "下一个文件",
|
||||
"loading": "正在加载…"
|
||||
},
|
||||
"ux_pim_files": {
|
||||
"conflict_title": "{name} 已存在。",
|
||||
"conflict_file_hint": "替换会把新内容放进现有文件,共享设置保持不变。保留两者会添加一个带编号的副本。",
|
||||
"conflict_folder_hint": "替换会把文件添加到现有文件夹,并替换同名文件。保留两者会新建一个带编号的文件夹。",
|
||||
"replace": "替换",
|
||||
"keep_both": "保留两者",
|
||||
"skip": "跳过",
|
||||
"apply_to_all": "{count, plural, other {对全部 # 个冲突执行此操作}}",
|
||||
"no_preview": "此类文件无法预览。",
|
||||
"open_in_app": "用默认应用打开",
|
||||
"shared_with": "{name} 已与 {person} 共享。",
|
||||
"send_message": "发送消息",
|
||||
"share_mail_subject": "我与你共享了 {name}",
|
||||
"share_mail_body": "你好,\n\n我与你共享了“{name}”。可以在这里打开:\n{link}"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "您的证书",
|
||||
"your_certificates_desc": "导入和管理您的 S/MIME 证书以对邮件进行签名和加密",
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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, UploadConflictChoice } from '@/lib/files-upload-conflicts';
|
||||
|
||||
// pim-10: an upload whose name is taken asks Replace / Keep both / Skip.
|
||||
// Replace gives the existing node new content (same id), Keep both adds
|
||||
// "name (1)", Skip leaves it alone.
|
||||
|
||||
function makeClient(initial: FileNode[]) {
|
||||
let nodes: FileNode[] = initial.map(n => ({ ...n }));
|
||||
let seq = 0;
|
||||
const client = {
|
||||
getCapabilities: () => ({}),
|
||||
getFilesAccountId: () => 'acct',
|
||||
async listAllFileNodes() { return nodes.map(n => ({ ...n })); },
|
||||
async createFileDirectory(name: string, parentId: string | null) {
|
||||
const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '' };
|
||||
nodes.push(node);
|
||||
return { ...node };
|
||||
},
|
||||
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 (id: string, blobId: string) => {
|
||||
const node = nodes.find(n => n.id === id);
|
||||
if (!node) throw new Error('not found');
|
||||
node.blobId = blobId;
|
||||
}),
|
||||
async uploadBlob() { return { blobId: `blob${++seq}`, type: 'text/plain' }; },
|
||||
_nodes: () => nodes,
|
||||
_reset: (next: FileNode[]) => { nodes = next; },
|
||||
};
|
||||
return client as unknown as IJMAPClient & { _nodes: () => FileNode[]; replaceFileNodeBlob: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
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: '',
|
||||
});
|
||||
const upload = (name: string, path?: string) => {
|
||||
const f = new File(['new'], name, { type: 'text/plain' });
|
||||
if (path) Object.defineProperty(f, 'webkitRelativePath', { value: path });
|
||||
return f;
|
||||
};
|
||||
|
||||
/** Answers every conflict with `choice`, and records what was asked. */
|
||||
function answering(choice: UploadConflictChoice | null) {
|
||||
const asked: UploadConflict[][] = [];
|
||||
const resolveConflicts = vi.fn(async (conflicts: UploadConflict[]) => {
|
||||
asked.push(conflicts);
|
||||
return choice ? new Map(conflicts.map(c => [c.name, choice])) : null;
|
||||
});
|
||||
return { asked, resolveConflicts };
|
||||
}
|
||||
|
||||
async function start(nodes: FileNode[]) {
|
||||
const client = makeClient(nodes);
|
||||
useFileStore.getState().initClient(client);
|
||||
await useFileStore.getState().navigate(null);
|
||||
return client;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useFileStore.setState({
|
||||
client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }],
|
||||
resources: [], selectedResources: new Set(), clipboard: null, lastAction: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploading files whose name is taken (pim-10)', () => {
|
||||
it('asks only about the taken names', async () => {
|
||||
await start([file('r', 'report.txt', null), folder('d', 'new.txt', null)]);
|
||||
const { asked, resolveConflicts } = answering('keep');
|
||||
await useFileStore.getState().uploadFiles([upload('report.txt'), upload('other.txt'), upload('new.txt')], { resolveConflicts });
|
||||
// A folder named like the file is no conflict: the file is numbered beside it.
|
||||
expect(asked).toEqual([[{ name: 'report.txt', kind: 'file' }]]);
|
||||
});
|
||||
|
||||
it('Replace gives the existing node the new content and keeps its id', async () => {
|
||||
const client = await start([file('r', 'report.txt', null)]);
|
||||
const { resolveConflicts } = answering('replace');
|
||||
const report = await useFileStore.getState().uploadFiles([upload('report.txt')], { resolveConflicts });
|
||||
expect(report.uploaded).toBe(1);
|
||||
expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('r', expect.stringMatching(/^blob/));
|
||||
const named = client._nodes().filter(n => n.name.startsWith('report'));
|
||||
expect(named.map(n => [n.id, n.name])).toEqual([['r', 'report.txt']]);
|
||||
expect(named[0].blobId).not.toBe('old-r');
|
||||
});
|
||||
|
||||
it('Keep both adds a numbered copy', async () => {
|
||||
const client = await start([file('r', 'report.txt', null)]);
|
||||
const { resolveConflicts } = answering('keep');
|
||||
await useFileStore.getState().uploadFiles([upload('report.txt')], { resolveConflicts });
|
||||
expect(client._nodes().map(n => n.name).sort()).toEqual(['report (1).txt', 'report.txt']);
|
||||
expect(client.replaceFileNodeBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Skip leaves the file alone and uploads the rest', async () => {
|
||||
const client = await start([file('r', 'report.txt', null)]);
|
||||
const { resolveConflicts } = answering('skip');
|
||||
const report = await useFileStore.getState().uploadFiles([upload('report.txt'), upload('other.txt')], { resolveConflicts });
|
||||
expect(report).toMatchObject({ uploaded: 1, skipped: 1 });
|
||||
expect(client._nodes().map(n => [n.name, n.blobId]).sort()).toEqual([['other.txt', expect.stringMatching(/^blob/)], ['report.txt', 'old-r']]);
|
||||
});
|
||||
|
||||
it('calling it off uploads nothing', async () => {
|
||||
const client = await start([file('r', 'report.txt', null)]);
|
||||
const { resolveConflicts } = answering(null);
|
||||
const report = await useFileStore.getState().uploadFiles([upload('report.txt'), upload('other.txt')], { resolveConflicts });
|
||||
expect(report).toMatchObject({ uploaded: 0, canceled: true });
|
||||
expect(client._nodes().map(n => n.name)).toEqual(['report.txt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploading a folder whose name is taken (pim-10)', () => {
|
||||
const tree = () => [
|
||||
folder('docs', 'Docs', null),
|
||||
file('a', 'a.txt', 'docs'),
|
||||
folder('sub', 'Sub', 'docs'),
|
||||
file('b', 'b.txt', 'sub'),
|
||||
];
|
||||
const files = () => [upload('a.txt', 'Docs/a.txt'), upload('b.txt', 'Docs/Sub/b.txt'), upload('c.txt', 'Docs/Sub/c.txt')];
|
||||
|
||||
it('asks about the folder, and Replace merges into it', async () => {
|
||||
const client = await start(tree());
|
||||
const { asked, resolveConflicts } = answering('replace');
|
||||
const report = await useFileStore.getState().uploadFolder(files(), { resolveConflicts });
|
||||
expect(asked).toEqual([[{ name: 'Docs', kind: 'folder' }]]);
|
||||
expect(report.uploaded).toBe(3);
|
||||
const nodes = client._nodes();
|
||||
// No second Docs or Sub; a.txt and b.txt keep their ids with new content; c.txt is new in Sub.
|
||||
expect(nodes.filter(n => n.blobId === null).map(n => n.id)).toEqual(['docs', 'sub']);
|
||||
expect(nodes.find(n => n.id === 'a')!.blobId).toMatch(/^blob/);
|
||||
expect(nodes.find(n => n.id === 'b')!.blobId).toMatch(/^blob/);
|
||||
expect(nodes.filter(n => n.parentId === 'sub').map(n => n.name).sort()).toEqual(['b.txt', 'c.txt']);
|
||||
});
|
||||
|
||||
it('Keep both makes a second folder', async () => {
|
||||
const client = await start(tree());
|
||||
const { resolveConflicts } = answering('keep');
|
||||
await useFileStore.getState().uploadFolder(files(), { resolveConflicts });
|
||||
expect(client._nodes().filter(n => n.parentId === null).length).toBe(2);
|
||||
expect(client.replaceFileNodeBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Skip leaves the folder out', async () => {
|
||||
const client = await start(tree());
|
||||
const { resolveConflicts } = answering('skip');
|
||||
const report = await useFileStore.getState().uploadFolder(files(), { resolveConflicts });
|
||||
expect(report).toMatchObject({ uploaded: 0, skipped: 3 });
|
||||
expect(client._nodes()).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
+116
-6
@@ -16,6 +16,9 @@ import {
|
||||
} from '@/lib/files-trash';
|
||||
import { buildZip, planZip, saveBlob, type ZipProgress } from '@/lib/files-zip';
|
||||
import { downloadAllowed } from '@/lib/download-guard';
|
||||
import {
|
||||
findUploadConflicts, type UploadConflict, type UploadConflictChoice, type UploadConflictResolver,
|
||||
} from '@/lib/files-upload-conflicts';
|
||||
|
||||
export interface FileResource {
|
||||
id: string;
|
||||
@@ -59,6 +62,13 @@ export interface UploadReport {
|
||||
uploaded: number;
|
||||
failed: { name: string; error: unknown }[];
|
||||
canceled: boolean;
|
||||
/** Left out because the name was taken and the user chose Skip. */
|
||||
skipped?: number;
|
||||
}
|
||||
|
||||
export interface UploadOptions {
|
||||
/** Asked when a name is taken in the target folder; without it, new ones are numbered. */
|
||||
resolveConflicts?: UploadConflictResolver;
|
||||
}
|
||||
|
||||
interface ClipboardState {
|
||||
@@ -137,8 +147,8 @@ interface FileState {
|
||||
refresh: () => Promise<void>;
|
||||
createDirectory: (name: string) => Promise<void>;
|
||||
uploadFile: (file: File) => Promise<void>;
|
||||
uploadFiles: (files: File[]) => Promise<UploadReport>;
|
||||
uploadFolder: (files: File[]) => Promise<UploadReport>;
|
||||
uploadFiles: (files: File[], options?: UploadOptions) => Promise<UploadReport>;
|
||||
uploadFolder: (files: File[], options?: UploadOptions) => Promise<UploadReport>;
|
||||
cancelUpload: () => void;
|
||||
// Deletes, renames and moves resolve to false when a plugin stopped them
|
||||
// (onBeforeFileDelete / Rename / Move); nothing changed then.
|
||||
@@ -471,6 +481,32 @@ function resolvePathToId(nodes: FileNode[], path: string): string | null | undef
|
||||
return parentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to every name of an upload that is taken in the target folder
|
||||
* (pim-10): an empty map when nothing clashes or nobody can be asked (the
|
||||
* new ones are then numbered, as before), null when the user called it off.
|
||||
*/
|
||||
async function askUploadConflicts(
|
||||
incoming: UploadConflict[],
|
||||
existing: Pick<FileResource, 'name' | 'serverName' | 'isDirectory' | 'isSymlink'>[],
|
||||
resolve: UploadConflictResolver | undefined,
|
||||
): Promise<Map<string, UploadConflictChoice> | null> {
|
||||
if (!resolve) return new Map();
|
||||
// A symlink's name is taken but it can't be replaced: kept beside it.
|
||||
const entries = existing.filter(r => !r.isSymlink).map(r => ({ name: r.serverName ?? r.name, isDirectory: r.isDirectory }));
|
||||
const conflicts = findUploadConflicts(incoming, entries);
|
||||
if (conflicts.length === 0) return new Map();
|
||||
return resolve(conflicts);
|
||||
}
|
||||
|
||||
/** A file whose content an upload replaced, as plugins see it (onAfterFileUpload). */
|
||||
function replacedView(id: string, file: File, type: string | undefined, folderPath: string): FileResourceView {
|
||||
return {
|
||||
id, name: file.name, type: 'file', size: file.size, mimeType: type || file.type || 'application/octet-stream',
|
||||
path: folderPath, modified: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function getUniqueName(name: string, existingNames: Set<string>): string {
|
||||
if (!existingNames.has(name)) return name;
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
@@ -1083,7 +1119,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
uploadFiles: async (allFiles: File[]) => {
|
||||
uploadFiles: async (allFiles: File[], options?: UploadOptions) => {
|
||||
const { client, currentParentId, currentPath, resources } = get();
|
||||
const report: UploadReport = { uploaded: 0, failed: [], canceled: false };
|
||||
if (!client) return report;
|
||||
@@ -1098,6 +1134,22 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
}
|
||||
if (files.length === 0) return report;
|
||||
|
||||
// A name that is taken: ask Replace, Keep both or Skip (pim-10).
|
||||
const choices = await askUploadConflicts(
|
||||
files.map(f => ({ name: f.name, kind: 'file' as const })),
|
||||
resources,
|
||||
options?.resolveConflicts,
|
||||
);
|
||||
if (!choices) {
|
||||
report.canceled = true;
|
||||
return report;
|
||||
}
|
||||
const replaceIds = new Map(resources.filter(r => !r.isDirectory && !r.isSymlink).map(r => [r.serverName ?? r.name, r.id]));
|
||||
const kept = files.filter(f => choices.get(f.name) !== 'skip');
|
||||
if (kept.length < files.length) report.skipped = files.length - kept.length;
|
||||
files.splice(0, files.length, ...kept);
|
||||
if (files.length === 0) return report;
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
const totalFiles = files.length;
|
||||
@@ -1106,7 +1158,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (abortController.signal.aborted) break;
|
||||
const file = files[i];
|
||||
const uniqueName = getUniqueName(file.name, existingNames);
|
||||
const replaceId = choices.get(file.name) === 'replace' ? replaceIds.get(file.name) : undefined;
|
||||
const uniqueName = replaceId ? file.name : getUniqueName(file.name, existingNames);
|
||||
existingNames.add(uniqueName);
|
||||
set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } });
|
||||
|
||||
@@ -1120,6 +1173,13 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
});
|
||||
if (abortController.signal.aborted) break;
|
||||
set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||||
if (replaceId) {
|
||||
// New content for the node that is there: same id, same shares.
|
||||
await client.replaceFileNodeBlob(replaceId, blobId);
|
||||
report.uploaded++;
|
||||
void fileHooks.onAfterFileUpload.emit(replacedView(replaceId, file, type, currentPath));
|
||||
continue;
|
||||
}
|
||||
const node = await client.createFileNode(uniqueName, blobId, type || file.type || 'application/octet-stream', file.size, currentParentId);
|
||||
report.uploaded++;
|
||||
void fileHooks.onAfterFileUpload.emit(fileToView(node, currentPath));
|
||||
@@ -1143,8 +1203,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
uploadFolder: async (allFiles: File[]) => {
|
||||
const { client, currentParentId, currentPath } = get();
|
||||
uploadFolder: async (allFiles: File[], options?: UploadOptions) => {
|
||||
const { client, currentParentId, currentPath, resources } = get();
|
||||
const report: UploadReport = { uploaded: 0, failed: [], canceled: false };
|
||||
if (!client || allFiles.length === 0) return report;
|
||||
assertCanAddTo(currentParentId);
|
||||
@@ -1167,6 +1227,39 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
}
|
||||
if (files.length === 0) return report;
|
||||
|
||||
// What lands right in the target folder: a dropped file, or the top
|
||||
// folder of each path. A name that is taken there asks first (pim-10).
|
||||
const topOf = (file: File): UploadConflict => {
|
||||
const relativePath = relativePathOf(file);
|
||||
const slash = relativePath.indexOf('/');
|
||||
return slash >= 0 ? { name: relativePath.slice(0, slash), kind: 'folder' } : { name: relativePath, kind: 'file' };
|
||||
};
|
||||
const choices = await askUploadConflicts(files.map(topOf), resources, options?.resolveConflicts);
|
||||
if (!choices) {
|
||||
report.canceled = true;
|
||||
return report;
|
||||
}
|
||||
const kept = files.filter(f => choices.get(topOf(f).name) !== 'skip');
|
||||
if (kept.length < files.length) report.skipped = files.length - kept.length;
|
||||
files.splice(0, files.length, ...kept);
|
||||
if (files.length === 0) return report;
|
||||
// Replace: a file gets new content; a folder takes the new content in,
|
||||
// with its subfolders reused and files of the same name replaced.
|
||||
const replacing = new Map(resources
|
||||
.filter(r => !r.isSymlink && choices.get(r.serverName ?? r.name) === 'replace')
|
||||
.map(r => [`${r.isDirectory ? 'folder' : 'file'}:${r.serverName ?? r.name}`, r.id]));
|
||||
let tree: FileNode[] | null = null;
|
||||
const loadTree = async () => {
|
||||
tree ??= isCrossAccountId(currentParentId)
|
||||
? await client.listAllFileNodesAcrossAccounts()
|
||||
: await client.listAllFileNodes();
|
||||
return tree;
|
||||
};
|
||||
const childNamed = async (parentId: string | null, name: string, kind: 'file' | 'directory') =>
|
||||
(await loadTree()).find(n => (n.parentId ?? null) === parentId && n.name === name && fileNodeKind(n) === kind)?.id;
|
||||
// Folder paths of the upload that are existing folders (merged into).
|
||||
const mergedDirs = new Set<string>();
|
||||
|
||||
const abortController = new AbortController();
|
||||
set({ uploadAbortController: abortController });
|
||||
const totalFiles = files.length;
|
||||
@@ -1220,6 +1313,14 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const existingId = parentPath === ''
|
||||
? replacing.get(`folder:${dirName}`)
|
||||
: mergedDirs.has(parentPath) ? await childNamed(parentId, dirName, 'directory') : undefined;
|
||||
if (existingId) {
|
||||
dirIds.set(dir, existingId);
|
||||
mergedDirs.add(dir);
|
||||
continue;
|
||||
}
|
||||
const created = await client.createFileDirectory(dirName, parentId);
|
||||
dirIds.set(dir, created.id);
|
||||
void fileHooks.onDirectoryCreate.emit(fileToView(created, joinPath(currentPath, parentPath)));
|
||||
@@ -1256,6 +1357,15 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
});
|
||||
if (abortController.signal.aborted) break;
|
||||
set({ uploadProgress: { name: relativePath, loaded: file.size, total: file.size, current: i + 1, totalFiles } });
|
||||
const replaceId = dirPath === ''
|
||||
? replacing.get(`file:${file.name}`)
|
||||
: mergedDirs.has(dirPath) ? await childNamed(parentId, file.name, 'file') : undefined;
|
||||
if (replaceId) {
|
||||
await client.replaceFileNodeBlob(replaceId, blobId);
|
||||
report.uploaded++;
|
||||
void fileHooks.onAfterFileUpload.emit(replacedView(replaceId, file, type, joinPath(currentPath, dirPath)));
|
||||
continue;
|
||||
}
|
||||
const node = await client.createFileNode(file.name, blobId, type || file.type || 'application/octet-stream', file.size, parentId);
|
||||
report.uploaded++;
|
||||
void fileHooks.onAfterFileUpload.emit(fileToView(node, joinPath(currentPath, dirPath)));
|
||||
|
||||
Reference in New Issue
Block a user