Repository: cozy/cozy-drive Branch: master Commit: bba1ddfdfaef Files: 674 Total size: 1.8 MB Directory structure: gitextract_5cx5gfwh/ ├── .bundlemonrc ├── .editorconfig ├── .github/ │ └── workflows/ │ ├── ci-cd.yml │ ├── codeql-analysis.yml │ └── create-bump-pr.yml ├── .gitignore ├── .nvmrc ├── .transifexrc.tpl ├── .tx/ │ └── config ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── docs/ │ └── nextcloud.md ├── eslint.config.mjs ├── jest.config.js ├── jestHelpers/ │ ├── ConsoleUsageReporter.js │ ├── mocks/ │ │ ├── fileMock.js │ │ ├── iconMock.js │ │ ├── pdfjsWorkerMock.js │ │ └── svgRawMock.js │ ├── setup.js │ └── setupFilesAfterEnv.js ├── manifest.webapp ├── package.json ├── public/ │ ├── browserconfig.xml │ └── manifest.json ├── renovate.json ├── rsbuild.config.mjs ├── src/ │ ├── assets/ │ │ └── onlyOffice/ │ │ ├── slide.pptx │ │ ├── spreadsheet.xlsx │ │ └── text.docx │ ├── components/ │ │ ├── App/ │ │ │ └── App.jsx │ │ ├── Bar.jsx │ │ ├── Button/ │ │ │ ├── BackButton.jsx │ │ │ ├── MoreButton.jsx │ │ │ ├── OpenFolderButton.tsx │ │ │ └── index.jsx │ │ ├── ColorPicker/ │ │ │ ├── ColorPicker.jsx │ │ │ ├── constants.js │ │ │ └── index.jsx │ │ ├── Error/ │ │ │ ├── Empty.jsx │ │ │ ├── ErrorShare.jsx │ │ │ ├── NotFound.jsx │ │ │ ├── Oops.jsx │ │ │ ├── empty.styl │ │ │ └── oops.styl │ │ ├── FileHistory/ │ │ │ ├── HistoryModal.jsx │ │ │ ├── index.jsx │ │ │ └── styles.styl │ │ ├── FilesRealTimeQueries.jsx │ │ ├── FilesViewerLoading.jsx │ │ ├── FolderPicker/ │ │ │ ├── FolderPicker.spec.jsx │ │ │ ├── FolderPicker.tsx │ │ │ ├── FolderPickerAddFolderItem.tsx │ │ │ ├── FolderPickerBody.spec.jsx │ │ │ ├── FolderPickerBody.tsx │ │ │ ├── FolderPickerContentCozy.tsx │ │ │ ├── FolderPickerContentLoadMore.tsx │ │ │ ├── FolderPickerContentLoader.tsx │ │ │ ├── FolderPickerContentNextcloud.tsx │ │ │ ├── FolderPickerContentPublic.tsx │ │ │ ├── FolderPickerContentSharedDrive.tsx │ │ │ ├── FolderPickerContentSharedDriveRoot.tsx │ │ │ ├── FolderPickerFooter.tsx │ │ │ ├── FolderPickerHeader.spec.js │ │ │ ├── FolderPickerHeader.tsx │ │ │ ├── FolderPickerHeaderIllustration.tsx │ │ │ ├── FolderPickerListItem.tsx │ │ │ ├── FolderPickerTopbar.spec.jsx │ │ │ ├── FolderPickerTopbar.tsx │ │ │ ├── helpers.spec.js │ │ │ ├── helpers.ts │ │ │ └── types.ts │ │ ├── IconPicker/ │ │ │ ├── IconColorPicker.jsx │ │ │ ├── IconIndex.js │ │ │ ├── IconPicker.jsx │ │ │ ├── NoneIcon.jsx │ │ │ ├── constants.js │ │ │ └── index.jsx │ │ ├── IconStack/ │ │ │ ├── index.jsx │ │ │ └── styles.styl │ │ ├── Icons/ │ │ │ ├── Drive.jsx │ │ │ └── DriveText.jsx │ │ ├── LoaderModal.jsx │ │ ├── Migration/ │ │ │ ├── MigrationProgressBanner.jsx │ │ │ └── MigrationProgressBanner.spec.jsx │ │ ├── MoreMenu.tsx │ │ ├── MoveValidationModals/ │ │ │ └── index.tsx │ │ ├── PushBanner/ │ │ │ ├── PushBanner.spec.jsx │ │ │ ├── PushBannerProvider.jsx │ │ │ ├── QuotaBanner.jsx │ │ │ ├── QuotaBanner.spec.jsx │ │ │ └── index.jsx │ │ ├── RightClick/ │ │ │ ├── RightClickAddMenu.jsx │ │ │ ├── RightClickFileMenu.jsx │ │ │ └── RightClickProvider.jsx │ │ ├── SideBarAccordion.jsx │ │ ├── TrashedBanner.jsx │ │ ├── pushClient/ │ │ │ ├── Banner.jsx │ │ │ ├── Button.jsx │ │ │ ├── __mocks__/ │ │ │ │ └── index.js │ │ │ ├── index.d.ts │ │ │ ├── index.js │ │ │ └── index.spec.js │ │ ├── useDocument.jsx │ │ └── useHead.jsx │ ├── config/ │ │ ├── config.json │ │ └── sort.js │ ├── constants/ │ │ └── config.js │ ├── contexts/ │ │ ├── ClipboardProvider.spec.tsx │ │ └── ClipboardProvider.tsx │ ├── declarations.d.ts │ ├── hooks/ │ │ ├── helpers.d.ts │ │ ├── helpers.js │ │ ├── index.js │ │ ├── useCurrentFileId.jsx │ │ ├── useCurrentFileId.spec.jsx │ │ ├── useCurrentFolderId.jsx │ │ ├── useCurrentFolderId.spec.jsx │ │ ├── useDebounce.jsx │ │ ├── useDisplayedFolder.spec.jsx │ │ ├── useDisplayedFolder.tsx │ │ ├── useFolderSort/ │ │ │ ├── index.spec.jsx │ │ │ └── index.ts │ │ ├── useKeyboardShortcuts.spec.jsx │ │ ├── useKeyboardShortcuts.tsx │ │ ├── useMoreMenuActions.jsx │ │ ├── useOnLongPress/ │ │ │ ├── helpers.js │ │ │ ├── helpers.spec.jsx │ │ │ └── index.js │ │ ├── useParentFolder.jsx │ │ ├── useParentFolder.spec.jsx │ │ ├── useRecentFiles.jsx │ │ ├── useRecentFiles.spec.jsx │ │ ├── useRecentIcons.jsx │ │ ├── useRecentIcons.spec.jsx │ │ ├── useRedirectLink.jsx │ │ ├── useRedirectLink.spec.jsx │ │ ├── useShiftSelection/ │ │ │ ├── helpers.spec.ts │ │ │ ├── helpers.ts │ │ │ ├── index.spec.tsx │ │ │ └── index.tsx │ │ ├── useTransformFolderListHasSharedDriveShortcuts/ │ │ │ ├── index.spec.jsx │ │ │ └── index.tsx │ │ └── useUpdateFavicon/ │ │ ├── constants.ts │ │ ├── helpers.spec.js │ │ ├── helpers.ts │ │ ├── index.spec.jsx │ │ └── index.tsx │ ├── lib/ │ │ ├── AcceptingSharingContext.jsx │ │ ├── DriveProvider.jsx │ │ ├── FabProvider.jsx │ │ ├── FuzzyPathSearch.js │ │ ├── FuzzyPathSearch.spec.js │ │ ├── ModalContext.tsx │ │ ├── ThumbnailSizeContext.tsx │ │ ├── ViewSwitcherContext.tsx │ │ ├── appMetadata.js │ │ ├── dacc/ │ │ │ ├── dacc-run.js │ │ │ ├── dacc-run.spec.js │ │ │ ├── dacc.js │ │ │ ├── dacc.spec.js │ │ │ └── query.js │ │ ├── doctypes.js │ │ ├── entries.js │ │ ├── entries.spec.js │ │ ├── extraDoctypes.js │ │ ├── flags.js │ │ ├── getFileMimetype.js │ │ ├── getMimeTypeIcon.js │ │ ├── konnectors.js │ │ ├── logger.js │ │ ├── migration/ │ │ │ ├── qualification.js │ │ │ └── qualification.spec.js │ │ ├── path.js │ │ ├── path.spec.js │ │ ├── queries.js │ │ ├── react-cozy-helpers/ │ │ │ ├── ModalManager.jsx │ │ │ ├── QueryParameter.js │ │ │ ├── QueryParameter.spec.js │ │ │ └── index.js │ │ ├── registerClientPlugins.js │ │ └── sentry.js │ ├── locales/ │ │ ├── ar.json │ │ ├── de.json │ │ ├── en.json │ │ ├── es.json │ │ ├── fr.json │ │ ├── index.js │ │ ├── it.json │ │ ├── ja.json │ │ ├── ko.json │ │ ├── nl.json │ │ ├── nl_NL.json │ │ ├── pl.json │ │ ├── ru.json │ │ ├── vi.json │ │ ├── zh_CN.json │ │ └── zh_TW.json │ ├── models/ │ │ ├── Contact.js │ │ ├── Contact.spec.js │ │ └── index.js │ ├── modules/ │ │ ├── actionmenu/ │ │ │ └── ActionMenuWithHeader.jsx │ │ ├── actions/ │ │ │ ├── addItems.jsx │ │ │ ├── components/ │ │ │ │ ├── addToFavorites.tsx │ │ │ │ ├── duplicateTo.tsx │ │ │ │ ├── moveTo.jsx │ │ │ │ ├── personalizeFolder.js │ │ │ │ ├── removeFromFavorites.tsx │ │ │ │ └── selectable.tsx │ │ │ ├── details.jsx │ │ │ ├── divider.jsx │ │ │ ├── download.jsx │ │ │ ├── helpers.js │ │ │ ├── helpers.spec.js │ │ │ ├── index.js │ │ │ ├── index.spec.js │ │ │ ├── infos.jsx │ │ │ ├── policies.spec.ts │ │ │ ├── policies.ts │ │ │ ├── qualify.jsx │ │ │ ├── rename.jsx │ │ │ ├── restore.jsx │ │ │ ├── select.jsx │ │ │ ├── selectAll.jsx │ │ │ ├── share.jsx │ │ │ ├── summariseByAI.jsx │ │ │ ├── trash.jsx │ │ │ ├── types.ts │ │ │ ├── utils.js │ │ │ ├── utils.spec.js │ │ │ └── versions.jsx │ │ ├── breadcrumb/ │ │ │ ├── components/ │ │ │ │ ├── Breadcrumb.jsx │ │ │ │ ├── Breadcrumb.spec.jsx │ │ │ │ ├── DesktopBreadcrumb.jsx │ │ │ │ ├── DesktopBreadcrumb.spec.jsx │ │ │ │ ├── DesktopBreadcrumbItem.jsx │ │ │ │ ├── MobileAwareBreadcrumb.jsx │ │ │ │ ├── MobileAwareBreadcrumb.spec.jsx │ │ │ │ ├── MobileBreadcrumb.jsx │ │ │ │ ├── MobileBreadcrumb.spec.jsx │ │ │ │ └── __snapshots__/ │ │ │ │ └── Breadcrumb.spec.jsx.snap │ │ │ ├── hooks/ │ │ │ │ ├── useBreadcrumbPath.jsx │ │ │ │ └── useBreadcrumbPath.spec.jsx │ │ │ ├── styles/ │ │ │ │ └── breadcrumb.styl │ │ │ └── utils/ │ │ │ ├── fetchFolder.js │ │ │ └── fetchFolder.spec.js │ │ ├── certifications/ │ │ │ ├── CertificationTooltip.jsx │ │ │ ├── index.jsx │ │ │ ├── useExtraColumns.jsx │ │ │ └── useExtraColumns.spec.jsx │ │ ├── drive/ │ │ │ ├── AddMenu/ │ │ │ │ ├── AddMenu.jsx │ │ │ │ ├── AddMenuContent.jsx │ │ │ │ ├── AddMenuContent.spec.jsx │ │ │ │ ├── AddMenuProvider.jsx │ │ │ │ └── AddMenuProvider.spec.jsx │ │ │ ├── DeleteConfirm.jsx │ │ │ ├── DeleteConfirm.spec.jsx │ │ │ ├── FabWithAddMenuContext.jsx │ │ │ ├── RenameInput.jsx │ │ │ ├── RenameInput.spec.jsx │ │ │ ├── Toolbar/ │ │ │ │ ├── components/ │ │ │ │ │ ├── AddButton.jsx │ │ │ │ │ ├── AddFolderItem.jsx │ │ │ │ │ ├── AddMenuItem.jsx │ │ │ │ │ ├── CreateDocsItem.jsx │ │ │ │ │ ├── CreateNoteItem.jsx │ │ │ │ │ ├── CreateOnlyOfficeItem.jsx │ │ │ │ │ ├── CreateShortcut.jsx │ │ │ │ │ ├── DownloadButtonItem.jsx │ │ │ │ │ ├── FavoritesItem.jsx │ │ │ │ │ ├── InsideRegularFolder.jsx │ │ │ │ │ ├── InsideRegularFolder.spec.jsx │ │ │ │ │ ├── LeaveSharedDriveButtonItem.jsx │ │ │ │ │ ├── MoreMenu.jsx │ │ │ │ │ ├── MoreMenu.spec.jsx │ │ │ │ │ ├── Scanner/ │ │ │ │ │ │ ├── Scanner.spec.tsx │ │ │ │ │ │ ├── ScannerMenuItem.tsx │ │ │ │ │ │ ├── ScannerProvider.tsx │ │ │ │ │ │ └── useScannerService.ts │ │ │ │ │ ├── SearchButton.jsx │ │ │ │ │ ├── ShortcutCreationModal.jsx │ │ │ │ │ ├── ShortcutCreationModal.spec.jsx │ │ │ │ │ ├── UploadItem.jsx │ │ │ │ │ └── ViewSwitcher.jsx │ │ │ │ ├── delete/ │ │ │ │ │ ├── DeleteItem.jsx │ │ │ │ │ ├── DeleteItem.spec.jsx │ │ │ │ │ ├── delete.jsx │ │ │ │ │ └── delete.spec.jsx │ │ │ │ ├── index.jsx │ │ │ │ ├── move/ │ │ │ │ │ └── MoveItem.jsx │ │ │ │ ├── personalizeFolder/ │ │ │ │ │ └── PersonalizeFolderItem.jsx │ │ │ │ ├── selectable/ │ │ │ │ │ └── SelectableItem.jsx │ │ │ │ └── share/ │ │ │ │ ├── ShareButton.jsx │ │ │ │ ├── ShareItem.jsx │ │ │ │ ├── SharedRecipients.jsx │ │ │ │ ├── helpers.js │ │ │ │ └── helpers.spec.js │ │ │ ├── helpers.ts │ │ │ └── rename.js │ │ ├── duplicate/ │ │ │ └── components/ │ │ │ └── DuplicateModal.tsx │ │ ├── filelist/ │ │ │ ├── AddFolder.jsx │ │ │ ├── AddFolder.spec.jsx │ │ │ ├── AddFolderCard.jsx │ │ │ ├── AddFolderRow.jsx │ │ │ ├── File.jsx │ │ │ ├── File.spec.jsx │ │ │ ├── FileList.jsx │ │ │ ├── FileListBody.jsx │ │ │ ├── FileListHeader.jsx │ │ │ ├── FileListHeaderDesktop.jsx │ │ │ ├── FileListHeaderMobile.jsx │ │ │ ├── FileListRowsPlaceholder.jsx │ │ │ ├── FileOpener.jsx │ │ │ ├── FileOpener.spec.jsx │ │ │ ├── FilePlaceholder.jsx │ │ │ ├── FilenameInput.jsx │ │ │ ├── FilenameInput.spec.jsx │ │ │ ├── HeaderCell.jsx │ │ │ ├── LoadMore.jsx │ │ │ ├── LoadMoreV2.jsx │ │ │ ├── MobileSortMenu.jsx │ │ │ ├── cells/ │ │ │ │ ├── CarbonCopy.jsx │ │ │ │ ├── CertificationsIcons.jsx │ │ │ │ ├── CertificationsIcons.spec.js │ │ │ │ ├── ElectronicSafe.jsx │ │ │ │ ├── Empty.jsx │ │ │ │ ├── FileAction.jsx │ │ │ │ ├── FileName.jsx │ │ │ │ ├── LastUpdate.jsx │ │ │ │ ├── SelectBox.jsx │ │ │ │ ├── ShareContent.jsx │ │ │ │ ├── SharingShortcutBadge.jsx │ │ │ │ ├── Size.jsx │ │ │ │ ├── Status.jsx │ │ │ │ └── index.jsx │ │ │ ├── duck.js │ │ │ ├── fileopener.styl │ │ │ ├── getCaretPositionFromPoint.js │ │ │ ├── headers/ │ │ │ │ ├── CarbonCopy.jsx │ │ │ │ ├── ElectronicSafe.jsx │ │ │ │ └── index.jsx │ │ │ ├── helpers.ts │ │ │ ├── icons/ │ │ │ │ ├── BadgeKonnector.jsx │ │ │ │ ├── FileIcon.jsx │ │ │ │ ├── FileIcon.spec.jsx │ │ │ │ ├── FileIconMime.jsx │ │ │ │ ├── FileIconShortcut.jsx │ │ │ │ ├── FileThumbnail.tsx │ │ │ │ └── SharingShortcutIcon.jsx │ │ │ ├── useFormattedUpdatedAt.js │ │ │ └── virtualized/ │ │ │ ├── AddFolderRow.jsx │ │ │ ├── GridFile.jsx │ │ │ └── cells/ │ │ │ ├── Cell.jsx │ │ │ ├── FileAction.jsx │ │ │ ├── FileName.jsx │ │ │ ├── FileNamePath.jsx │ │ │ ├── LastUpdate.jsx │ │ │ ├── Share.jsx │ │ │ ├── ShareContent.jsx │ │ │ ├── SharingShortcutBadge.jsx │ │ │ └── Size.jsx │ │ ├── folder/ │ │ │ ├── components/ │ │ │ │ └── FolderBody.jsx │ │ │ └── hooks/ │ │ │ ├── useNeedsToWait.jsx │ │ │ └── useScrollToTop.jsx │ │ ├── layout/ │ │ │ ├── DummyLayout.tsx │ │ │ ├── Layout.jsx │ │ │ ├── Main.jsx │ │ │ └── Topbar.jsx │ │ ├── move/ │ │ │ ├── MoveInsideSharedFolderModal.jsx │ │ │ ├── MoveModal.jsx │ │ │ ├── MoveModal.spec.jsx │ │ │ ├── MoveOutsideSharedFolderModal.jsx │ │ │ ├── MoveSharedFolderInsideAnotherModal.jsx │ │ │ ├── components/ │ │ │ │ └── MoveModalSuccessAction.tsx │ │ │ ├── helpers.js │ │ │ ├── helpers.spec.js │ │ │ └── hooks/ │ │ │ ├── useCancelable.jsx │ │ │ └── useMove.tsx │ │ ├── navigation/ │ │ │ ├── AppRoute.jsx │ │ │ ├── ExternalNavItem.jsx │ │ │ ├── ExternalRedirect.jsx │ │ │ ├── FavoriteList.tsx │ │ │ ├── FavoriteListItem.tsx │ │ │ ├── Index.jsx │ │ │ ├── Index.spec.js │ │ │ ├── Nav.jsx │ │ │ ├── NavContent.tsx │ │ │ ├── NavContext.jsx │ │ │ ├── NavItem.jsx │ │ │ ├── NavLink.jsx │ │ │ ├── PublicNoteRedirect.tsx │ │ │ ├── SharingsNavItem.jsx │ │ │ ├── components/ │ │ │ │ ├── ExternalDriveListItem.tsx │ │ │ │ ├── ExternalDrivesList.tsx │ │ │ │ └── FileLink.tsx │ │ │ ├── duck/ │ │ │ │ ├── actions.jsx │ │ │ │ ├── actions.spec.jsx │ │ │ │ ├── async.js │ │ │ │ ├── index.js │ │ │ │ ├── reducer.js │ │ │ │ ├── utils.js │ │ │ │ └── utils.spec.js │ │ │ ├── helpers.js │ │ │ └── hooks/ │ │ │ ├── helpers.spec.js │ │ │ ├── helpers.ts │ │ │ ├── useFileLink.tsx │ │ │ └── useSharedDriveLink.tsx │ │ ├── nextcloud/ │ │ │ ├── components/ │ │ │ │ ├── NextcloudBanner.tsx │ │ │ │ ├── NextcloudBreadcrumb.jsx │ │ │ │ ├── NextcloudDeleteConfirm.jsx │ │ │ │ ├── NextcloudFolderBody.jsx │ │ │ │ ├── NextcloudToolbar.jsx │ │ │ │ ├── NextcloudTrashFolderBody.tsx │ │ │ │ └── actions/ │ │ │ │ ├── addFolder.jsx │ │ │ │ ├── deleteNextcloudFile.tsx │ │ │ │ ├── downloadNextcloudFile.jsx │ │ │ │ ├── downloadNextcloudFolder.jsx │ │ │ │ ├── duplicateNextcloudFile.jsx │ │ │ │ ├── moveNextcloud.jsx │ │ │ │ ├── openWithinNextcloud.jsx │ │ │ │ ├── rename.jsx │ │ │ │ ├── restoreNextcloudFile.tsx │ │ │ │ ├── shareNextcloudFile.jsx │ │ │ │ ├── trash.jsx │ │ │ │ └── upload.jsx │ │ │ ├── helpers.ts │ │ │ └── hooks/ │ │ │ ├── useNextcloudCurrentFolder.tsx │ │ │ ├── useNextcloudEntries.tsx │ │ │ ├── useNextcloudFolder.tsx │ │ │ ├── useNextcloudInfos.jsx │ │ │ └── useNextcloudPath.jsx │ │ ├── paste/ │ │ │ ├── index.js │ │ │ ├── index.spec.js │ │ │ ├── utils.js │ │ │ └── utils.spec.js │ │ ├── public/ │ │ │ ├── DownloadFilesButton.jsx │ │ │ ├── LightFileViewer.jsx │ │ │ ├── LightFileViewer.spec.jsx │ │ │ ├── PublicLayout.jsx │ │ │ ├── PublicProvider.tsx │ │ │ ├── PublicToolbar.jsx │ │ │ ├── PublicToolbarByLink.jsx │ │ │ ├── PublicToolbarCozyToCozy.jsx │ │ │ ├── PublicToolbarMoreMenu.jsx │ │ │ └── helpers.js │ │ ├── routeUtils.js │ │ ├── search/ │ │ │ ├── components/ │ │ │ │ ├── BarSearchAutosuggest.jsx │ │ │ │ ├── BarSearchInputGroup.jsx │ │ │ │ ├── SearchEmpty.jsx │ │ │ │ ├── SuggestionItem.jsx │ │ │ │ ├── SuggestionItemSkeleton.jsx │ │ │ │ ├── SuggestionItemTextHighlighted.jsx │ │ │ │ ├── SuggestionItemTextSecondary.jsx │ │ │ │ ├── SuggestionListSkeleton.jsx │ │ │ │ ├── helpers.js │ │ │ │ ├── helpers.spec.jsx │ │ │ │ └── styles.styl │ │ │ └── hooks/ │ │ │ └── useSearch.jsx │ │ ├── selection/ │ │ │ ├── RectangularSelection.jsx │ │ │ ├── RectangularSelection.styl │ │ │ ├── SelectionBar.tsx │ │ │ ├── SelectionProvider.d.ts │ │ │ ├── SelectionProvider.jsx │ │ │ ├── SelectionProvider.spec.jsx │ │ │ └── types.ts │ │ ├── selectors.js │ │ ├── selectors.spec.js │ │ ├── services/ │ │ │ ├── components/ │ │ │ │ ├── Embeder.jsx │ │ │ │ └── IntentHandler.jsx │ │ │ ├── index.jsx │ │ │ └── services.styl │ │ ├── shareddrives/ │ │ │ ├── components/ │ │ │ │ ├── SharedDriveBreadcrumb.jsx │ │ │ │ ├── SharedDriveFolderBody.jsx │ │ │ │ └── actions/ │ │ │ │ ├── leaveSharedDrive.js │ │ │ │ └── shareSharedDrive.js │ │ │ ├── helpers.ts │ │ │ └── hooks/ │ │ │ ├── useQueryMultipleSharedDriveFolders.tsx │ │ │ ├── useSharedDriveFolder.spec.jsx │ │ │ ├── useSharedDriveFolder.tsx │ │ │ ├── useSharedDriveFolderHelpers.ts │ │ │ └── useSharedDrives.js │ │ ├── trash/ │ │ │ └── components/ │ │ │ ├── DestroyConfirm.tsx │ │ │ ├── EmptyTrashConfirm.tsx │ │ │ ├── TrashBreadcrumb.tsx │ │ │ ├── TrashToolbar.spec.jsx │ │ │ ├── TrashToolbar.tsx │ │ │ └── actions/ │ │ │ ├── destroy.tsx │ │ │ └── emptyTrash.tsx │ │ ├── upload/ │ │ │ ├── Dropzone.jsx │ │ │ ├── DropzoneDnD.jsx │ │ │ ├── DropzoneTeaser.jsx │ │ │ ├── NewItemHighlightProvider.jsx │ │ │ ├── UploadButton.jsx │ │ │ ├── UploadLimitDialog.jsx │ │ │ ├── UploadQueue.jsx │ │ │ ├── index.js │ │ │ └── index.spec.js │ │ ├── viewer/ │ │ │ ├── CallToAction.jsx │ │ │ ├── CallToAction.spec.jsx │ │ │ ├── Fallback.jsx │ │ │ ├── FileOpenerExternal.jsx │ │ │ ├── FilesViewer.jsx │ │ │ ├── FilesViewer.spec.jsx │ │ │ ├── MoreMenu.jsx │ │ │ ├── NoViewerButton.jsx │ │ │ ├── barviewer.styl │ │ │ ├── helpers.js │ │ │ └── styles.styl │ │ └── views/ │ │ ├── AI/ │ │ │ └── AIAssistantPaywallView.tsx │ │ ├── Drive/ │ │ │ ├── DriveFolderView.jsx │ │ │ ├── DriveFolderView.spec.jsx │ │ │ ├── FilesViewerDrive.jsx │ │ │ ├── HarvestBanner.jsx │ │ │ ├── KonnectorRoutes.jsx │ │ │ ├── SharedDrivesFolderView.tsx │ │ │ └── useTrashRedirect.jsx │ │ ├── Favorites/ │ │ │ └── FavoritesView.tsx │ │ ├── Folder/ │ │ │ ├── ColoredFolder.jsx │ │ │ ├── CustomizedIcon.jsx │ │ │ ├── FolderCustomizer.jsx │ │ │ ├── FolderDuplicateView.tsx │ │ │ ├── FolderView.jsx │ │ │ ├── FolderViewBody.jsx │ │ │ ├── FolderViewBreadcrumb.jsx │ │ │ ├── FolderViewBreadcrumb.spec.jsx │ │ │ ├── FolderViewHeader.jsx │ │ │ ├── OldFolderViewBreadcrumb.jsx │ │ │ ├── PublicFolderDuplicateView.tsx │ │ │ ├── helpers.js │ │ │ ├── helpers.spec.js │ │ │ ├── hooks/ │ │ │ │ ├── useFileSorting.js │ │ │ │ └── useFileSorting.spec.js │ │ │ ├── syncHelpers.js │ │ │ ├── syncHelpers.spec.js │ │ │ ├── useSyncingFakeFile.js │ │ │ └── virtualized/ │ │ │ ├── AddFolderWrapper.jsx │ │ │ ├── FolderViewBody.jsx │ │ │ ├── FolderViewBodyContent.jsx │ │ │ ├── Grid.jsx │ │ │ ├── GridWrapper.jsx │ │ │ ├── Table.jsx │ │ │ ├── helpers.js │ │ │ ├── useScrollToHighlightedItem.jsx │ │ │ └── useScrollToHighlightedItem.spec.jsx │ │ ├── Modal/ │ │ │ ├── DuplicateSharedDriveFilesView.jsx │ │ │ ├── MoveFilesView.jsx │ │ │ ├── MovePublicFilesView.tsx │ │ │ ├── MoveSharedDriveFilesView.jsx │ │ │ ├── QualifyFileView.jsx │ │ │ ├── ShareDisplayedFolderView.jsx │ │ │ └── ShareFileView.jsx │ │ ├── Nextcloud/ │ │ │ ├── NextcloudDeleteView.jsx │ │ │ ├── NextcloudDestroyView.tsx │ │ │ ├── NextcloudDuplicateView.tsx │ │ │ ├── NextcloudFolderView.jsx │ │ │ ├── NextcloudMoveView.jsx │ │ │ ├── NextcloudTrashEmptyView.tsx │ │ │ └── NextcloudTrashView.tsx │ │ ├── OnlyOffice/ │ │ │ ├── Create.jsx │ │ │ ├── Editor.jsx │ │ │ ├── Editor.spec.jsx │ │ │ ├── Error.jsx │ │ │ ├── Loading.jsx │ │ │ ├── OnlyOfficeAIAssistantPanel.tsx │ │ │ ├── OnlyOfficePaywallView.jsx │ │ │ ├── OnlyOfficeProvider.jsx │ │ │ ├── ReadOnlyFab.jsx │ │ │ ├── Title.jsx │ │ │ ├── Toolbar/ │ │ │ │ ├── BackButton.jsx │ │ │ │ ├── EditButton.jsx │ │ │ │ ├── FileIcon.jsx │ │ │ │ ├── FileName.jsx │ │ │ │ ├── HomeIcon.jsx │ │ │ │ ├── HomeLinker.jsx │ │ │ │ ├── Separator.jsx │ │ │ │ ├── Sharing.jsx │ │ │ │ ├── SummarizeByAIButtonWrapper.tsx │ │ │ │ ├── helpers.js │ │ │ │ ├── index.jsx │ │ │ │ ├── index.spec.jsx │ │ │ │ └── styles.styl │ │ │ ├── View.jsx │ │ │ ├── components/ │ │ │ │ ├── FileDeletedModal.jsx │ │ │ │ └── FileDivergedModal.jsx │ │ │ ├── config.js │ │ │ ├── helpers.js │ │ │ ├── helpers.spec.js │ │ │ ├── index.jsx │ │ │ ├── styles.styl │ │ │ ├── useConfig.jsx │ │ │ └── useCreateFile.jsx │ │ ├── Public/ │ │ │ ├── PublicFileViewer.jsx │ │ │ ├── PublicFolderView.jsx │ │ │ ├── PublicFolderView.spec.jsx │ │ │ ├── usePublicFileByIdsQuery.spec.jsx │ │ │ ├── usePublicFileByIdsQuery.tsx │ │ │ ├── usePublicFilesQuery.jsx │ │ │ ├── usePublicFilesQuery.spec.jsx │ │ │ └── usePublicWritePermissions.jsx │ │ ├── Recent/ │ │ │ ├── FilesViewerRecent.jsx │ │ │ ├── index.jsx │ │ │ └── index.spec.jsx │ │ ├── Search/ │ │ │ └── SearchView.jsx │ │ ├── SharedDrive/ │ │ │ ├── CreateSharedDriveButton.jsx │ │ │ ├── FilesViewerSharedDrive.jsx │ │ │ └── SharedDriveFolderView.jsx │ │ ├── Sharings/ │ │ │ ├── FilesViewerSharings.jsx │ │ │ ├── SharingsFolderView.jsx │ │ │ ├── index.jsx │ │ │ ├── index.spec.jsx │ │ │ └── withSharedDocumentIds.jsx │ │ ├── Trash/ │ │ │ ├── FilesViewerTrash.jsx │ │ │ ├── TrashDestroyView.tsx │ │ │ ├── TrashEmptyView.tsx │ │ │ ├── TrashFolderView.jsx │ │ │ └── TrashFolderView.spec.jsx │ │ ├── Upload/ │ │ │ ├── UploadTypes.ts │ │ │ ├── UploadUtils.ts │ │ │ ├── UploaderComponent.tsx │ │ │ ├── __mocks__/ │ │ │ │ └── cozy-intent.ts │ │ │ ├── useResumeFromFlagship.spec.tsx │ │ │ ├── useResumeFromFlagship.ts │ │ │ └── useUploadFromFlagship.ts │ │ ├── testUtils.jsx │ │ ├── useUpdateDocumentTitle.jsx │ │ └── useUpdateDocumentTitle.spec.js │ ├── queries/ │ │ └── index.ts │ ├── store/ │ │ ├── __mocks__/ │ │ │ └── configureStore.js │ │ ├── configureStore.js │ │ ├── persistedState.js │ │ └── rootReducer.js │ ├── styles/ │ │ ├── actionmenu.styl │ │ ├── coz-bar-size.styl │ │ ├── dropzone.styl │ │ ├── filelist.styl │ │ ├── filenameinput.styl │ │ ├── folder-customizer.styl │ │ ├── folder-picker.styl │ │ ├── folder-view.styl │ │ ├── main.styl │ │ ├── toolbar.styl │ │ └── topbar.styl │ └── targets/ │ ├── browser/ │ │ ├── index.ejs │ │ ├── index.jsx │ │ ├── setupAppContext.js │ │ └── wdyr.js │ ├── intents/ │ │ ├── index.ejs │ │ └── index.jsx │ ├── public/ │ │ ├── components/ │ │ │ ├── AppRouter.jsx │ │ │ └── AppRouter.spec.jsx │ │ ├── index.ejs │ │ ├── index.jsx │ │ ├── localeHelper.js │ │ └── localeHelper.spec.js │ └── services/ │ ├── dacc.js │ └── qualificationMigration.js ├── test/ │ ├── __mocks__/ │ │ ├── fileMock.js │ │ └── mockedRouter.js │ ├── components/ │ │ ├── AppLike.jsx │ │ ├── FolderContent.jsx │ │ ├── FolderContent.spec.jsx │ │ └── __snapshots__/ │ │ └── File.spec.js.snap │ ├── data.js │ ├── dummies/ │ │ ├── dummyBreadcrumbPath.js │ │ └── dummyFile.js │ ├── generate.js │ ├── helpers/ │ │ └── index.js │ ├── jestLib/ │ │ └── json-transformer.js │ └── setup.jsx ├── transifex.yml └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .bundlemonrc ================================================ { "baseDir": "./build", "pathLabels": { "chunkId": "[\\d-]+" }, "files": [ { "path": "static/js/..js" }, { "path": "static/js/cozy..js" }, { "path": "static/js/main..js" }, { "path": "static/js/lib-react..js" }, { "path": "static/js/lib-router..js" }, { "path": "static/css/cozy..css" }, { "path": "static/css/main..css" }, { "path": "public/.js" }, { "path": "public/static/js/..js" }, { "path": "public/static/js/public..js" }, { "path": "public/static/js/cozy..js" }, { "path": "public/static/js/lib-react..js" }, { "path": "public/static/js/lib-router..js" }, { "path": "public/static/css/cozy..css" }, { "path": "public/static/css/public..css" }, { "path": "services/dacc.js" }, { "path": "services/qualificationMigration.js" }, { "path": ".js" }, { "path": "index.html" }, { "path": "assets/manifest.json" }, { "path": "manifest.webapp" } ], "groups": [ { "path": "**/*.js" }, { "path": "**/*.css" }, { "path": "**/*.{png,svg,ico}" } ], "reportOutput": [ "github" ] } ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true indent_style = space indent_size = 2 [*.{jade,pug}] trim_trailing_whitespace = false [*.styl] indent_size = 4 [*.xml] indent_size = 4 ================================================ FILE: .github/workflows/ci-cd.yml ================================================ name: CI/CD on: pull_request: push: branches: - master tags: - '[0-9]+.[0-9]+.[0-9]+' - '[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+' env: MATTERMOST_CHANNEL: '{"dev":"appvengers","beta":"appvengers,publication","stable":"appvengers,publication"}' MATTERMOST_HOOK_URL: ${{ secrets.MATTERMOST_HOOK_URL }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} jobs: build: name: Build and publish runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' - name: Install dependencies run: yarn install --frozen-lockfile - name: Lint run: yarn lint - name: Test run: yarn test - name: Build run: yarn build - name: BundleMon uses: lironer/bundlemon-action@v1 continue-on-error: true - name: Set SSH for downcloud if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.DOWNCLOUD_SSH_KEY }} - name: Publish if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') run: yarn run cozyPublish --yes ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ master, prod ] pull_request: # The branches below must be a subset of the branches above branches: [ master ] schedule: - cron: '26 3 * * 1' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'javascript' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository uses: actions/checkout@v2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v1 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 ================================================ FILE: .github/workflows/create-bump-pr.yml ================================================ name: 'Create Bump PR' on: workflow_dispatch: inputs: version: description: 'New version' required: true permissions: contents: write pull-requests: write jobs: bump: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v5 - name: Create new branch run: | git config --global user.name 'GitHub Actions' git config --global user.email 'actions@github.com' git checkout -b chore/bump-version-${{ inputs.version }} - name: Bump version run: | sed -i 's/"version": "[^"]*"/"version": "${{ inputs.version }}"/g' package.json sed -i 's/"version": "[^"]*"/"version": "${{ inputs.version }}"/g' manifest.webapp - name: Commit changes run: | git add package.json git add manifest.webapp git commit -m "chore: Bump version to ${{ inputs.version }}" - name: Push changes run: git push origin chore/bump-version-${{ inputs.version }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create Pull Request run: gh pr create -B master -H chore/bump-version-${{ inputs.version }} --title 'Bump ${{ inputs.version }}' --body 'This PR bumps the version to ${{ inputs.version }}.' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ # NPM node_modules/ npm-debug.log yarn-error.log # Build build/ # Test coverage/ .consoleUsageReporter.json # Stack ./storage/ storage/ # Reports reports/ # Default !.gitkeep # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db desktop.ini # Editors / IDEs .floo .flooignore .brackets.json .vscode # cozy-jobs-cli .token.json konnector-dev-config.json # SWC .swc .opencode ================================================ FILE: .nvmrc ================================================ 20 ================================================ FILE: .transifexrc.tpl ================================================ [https://www.transifex.com] rest_hostname = https://rest.api.transifex.com ================================================ FILE: .tx/config ================================================ [main] host = https://www.transifex.com [o:cozy:p:cozy-drive:r:e8e90edbf54aede8b7b026659d6fe40a] file_filter = src/locales/.json source_file = src/locales/en.json source_lang = en type = KEYVALUEJSON ================================================ FILE: CHANGELOG.md ================================================ # 1.45.0 ## ✨ Features ## 🐛 Bug Fixes ## 🔧 Tech # 1.44.0 ## ✨ Features * Improvements to DACC service ## 🐛 Bug Fixes * Remove double elevator on file list on public pages * Fix issue on file upload * Fix display when moving element to the root ## 🔧 Tech * Use new DACC API * Remove verbose mode * Add all files for bundlemon and make it drastically sensitive # 1.43.0 ## ✨ Features * Upgrade cozy-bar@7.20.1 to be able to call onSelect function * Improve speed of search suggestion by preventing fetch notes url until click * Update cozy-stack-client and cozy-pouch-link to sync with cozy-client version * Update cozy-ui - Modify Viewers to handle [68.0.0 BC](https://github.com/cozy/cozy-ui/releases/tag/v68.0.0) - Fix on progress bar when uploading files [[68.4.0]](https://github.com/cozy/cozy-ui/releases/tag/v68.4.0) - re-enable the Viewer's download button from cozy/cozy-ui#2234 [[74.4.0]](https://github.com/cozy/cozy-ui/releases/tag/v74.4.0) * Update cozy-scripts for Amirale development * Add visual feedback when uploading on a public view * Cache on clients request. Specially useful when the user didn't hide the "Install Cozy Drive for desktop" banner. ## 🐛 Bug Fixes * Improve cozy-bar implementation to fix UI bugs in Amirale * Fix navigation through mobile Flagship on Note creation and opening * Remove unused contacts permissions on Photos * fix in photos: timeline query needs select fields to be completed ## 🔧 Tech * Move dacc-run file to a lib folder to prevent it occurring in build * Shortcut links are now opened directly in the webview when executed inside the Flagship mobile app * fix: Viewer issue, make search backward compatible, add cache to the clients query # 1.42.1 ## 🐛 Bug Fixes * Fix services that were broken due to latest cozy-client update [[PR]](https://github.com/cozy/cozy-client/pull/1180) # 1.42.0 ## 🐛 Bug Fixes * Disable sharing on public file viewer ## 🔧 Tech * Remove useless props to Viewer + useless Viewer footer/panel code # 1.41.0 ## ✨ Features * When displaying cozy-home from Cozy's native application, the Support Us is not displayed * Upgrade Cozy-Scripts to enable service-worker * Photos: Fix pagination issue * Change Sentry url * Display tiny thumbnail instead of small * Display thumbnail for PDF (behind a flag) * Support client-side encrypted files visualization * Disable unsuported items inside encrypted folder ## 🐛 Bug Fixes * Compute sizes in MB instead of MiB in dacc service. * Query files based on their uploaded date in dacc service. * Do not query encryption files when flag is not set * Fix upload on shared folders ## 🔧 Tech * Upgrade bundlemon to run on master pipeline and explicit delta on PR * Add pull request template, explicit CHANGELOG.md to update * Update several dependencies packages * Publish in our internal communication tool, when new versions of the applications are released * Update documentation about standalone mode and Transifex * Add script command to execute version update for drive and photos simultaneously * Clear mocks automatically in the configuration of Jest, our test runner * Minor improvements in the code revealed by our linters * Remove react-autosuggest as not used directly in this package * Remove react-tooltip as not used directly in this package * Upgrade eslint-cozy-config-app to use eslint@v7 * Unregister any service worker that could have been registered during development * Improve a fragile test, breaking while some Node 16 pipeline * Add codeowners in the repository * Upgrade cozy-client for flagship app * Upgrade cozy-ui for matomo # 1.40.0 ## 🐛 Bug Fixes * Escape public name in public cozy-to-cozy sharing view * Fix upload when file name contains characters like `#` or `&` * Fix AppIcon issue ## 🔧 Tech * Update several dependencies packages * Remove cozy-jobs-cli useless devDependencies packages * Remove piwik-react-router useless dependencies packages * Add date attribute to dacc flag * Add generic build command * Move [dependabot](https://github.com/dependabot) config file to correct location * Fix auto-merge job what disallowed used merge commits * Remove Drive Android job # 1.39.0 ## ✨ Features * Use MUI Breadcrumb with fully fetched path * Add feature flag on Breadcrumb on public view * Allow all users to see progress on upload file * DACC service to send anonymized measures about the file sizes grouped by app/konnector * Implement cozy-bar AA navigation * Log sentry exception on click on add menu when offline * Upgrade cozy-client to allow all users to see progress on upload file * Upgrade cozy-ui to benefit of new version of material-ui component ## 🐛 Bug Fixes * Upgrade cozy-ui to make upload progress bar size fixed * Upload: return average remaining time each 3 seconds ## 🔧 Tech * Format style files of the full repository to respect the Cozy Stylint config * Update several dependencies packages * Remove node-uuid unused package * Configure the bot [dependabot](https://github.com/dependabot) to commit according to our convention * Use only one syntax of data-testid # 1.38.0 ## ✨ Features * Filename is displayed in title when hovering the line. * Add multiple import at once for Android * Remove Pouch adapter migration ## 🐛 Bug Fixes * Do not update files in parallel in the qualification migration service, as it might fail in nsjail for too many files * Fix MoveModal breadcrumb * Display reasons of incorrect file name (illegal characters, forbidden name) * Prevent errors during upload of file inside Dropzone * Handle better icon inside the searchbar * Upgrade cozy-client in order to fix albums page from photos ## 🔧 Tech * Use `` and `useSharingInfos()` from `cozy-sharing` instead of internal components * Fixed an error in Search result when the result contained at least one Cozy Note * Update cordova to 8.1.2 and cordova-android to 9.1.0 * Upgrade cozy-client, cozy-scanner caniuse-lite and fix tests * Upgrade cozy-sharing to fix typo in French * Add locales in gitignore * Explicit full path when importing cozy-ui component inside doc # 1.37.0 ## 🐛 Bug Fixes * Fixed an error on mobile that was preventing users to long tap in order to trigger multiple files selection * Fixed an error in directory tree names appearing under filenames where sometimes, the path appeared scrambled * Fixed an error where creating a directory sent two save actions instead of one * Added a missing loading status on delete confirm modal button * Fixed issues related to recent view not going where it should when navigating back and forth in directory paths ## 🔧 Tech * Add CodeQL in order to scan the code 🚫 * Add rel noopener on target blank link ================================================ FILE: CODEOWNERS ================================================ # General code owners * @JF-Cozy @zatteo @rezk2ll @lethemanh @doubleface @lenhanphung ================================================ FILE: CONTRIBUTING.md ================================================ How to contribute to Cozy Drive? ==================================== Thank you for your interest in contributing to Cozy! There are many ways to contribute, and we appreciate all of them. Security Issues --------------- If you discover a security issue, please bring it to our attention right away! Please **DO NOT** file a public issue, instead send your report privately to security AT cozycloud DOT cc. Security reports are greatly appreciated and we will publicly thank you for it. We currently do not offer a paid security bounty program, but are not ruling it out in the future. Bug Reports ----------- While bugs are unfortunate, they're a reality in software. We can't fix what we don't know about, so please report liberally. If you're not sure if something is a bug or not, feel free to file a bug anyway. Opening an issue is as easy as following [this link][issues] and filling out the fields. Here are some things you can write about your bug: - A short summary - What did you try, step by step? - What did you expect? - What did happen instead? - What is the version of the Cozy Drive? Pull Requests ------------- Please keep in mind that: - Pull-Requests point to the `master` branch - You need to cover your code and feature by tests - You may add documentation in the `/docs` directory to explain your choices if needed - We recommend to use [task lists][checkbox] to explain steps / features in your Pull-Request description - you do _not_ need to build app to submit a PR - you should update the Transifex source locale file if you modify it for your feature needs (see [Localization section in README][localization]) ### Workflow Pull requests are the primary mechanism we use to change Cozy. GitHub itself has some [great documentation][pr] on using the Pull Request feature. We use the _fork and pull_ model described there. #### Step 1: Fork Fork the project on GitHub and [check out your copy locally][forking]. ``` $ git clone github.com/cozy/cozy-drive.git $ cd cozy-drive $ git remote add fork git://github.com/yourusername/cozy-drive.git ``` #### Step 2: Branch Create a branch and start hacking: ``` $ git checkout -b my-branch origin/master ``` #### Step 3: Code Well, we think you know how to do that. Just be sure to follow the coding guidelines from the community ([standard JS][stdjs], comment the code, etc). #### Step 4: Test Don't forget to add tests and be sure they are green: ``` $ cd cozy-drive $ npm run test ``` #### Step 5: Commit Writing [good commit messages][commitmsg] is important. A commit message should describe what changed and why. #### Step 6: Rebase Use `git rebase` (_not_ `git merge`) to sync your work from time to time. ``` $ git fetch origin $ git rebase origin/master my-branch ``` #### Step 7: Push ``` $ git push -u fork my-branch ``` Go to https://github.com/yourusername/cozy-drive and select your branch. Click the 'Pull Request' button and fill out the form. Alternatively, you can use [hub] to open the pull request from your terminal: ``` $ git pull-request -b master -m "My PR message" -o ``` Pull requests are usually reviewed within a few days. If there are comments to address, apply your changes in a separate commit and push that to your branch. Post a comment in the pull request afterwards; GitHub doesn't send out notifications when you add commits. Writing documentation --------------------- Documentation improvements are very welcome. We try to keep a good documentation in the `/docs` folder. But, you know, we are developers, we can forget to document important stuff that look obvious to us. And documentation can always be improved. Translations ------------ The Cozy Drive is translated on a platform called [Transifex][tx]. [This tutorial][tx-start] can help you to learn how to make your first steps here. If you have any question, don't hesitate to ask us! Community --------- You can help us by making our community even more vibrant. For example, you can write a blog post, take some videos, answer the questions on [the forum][forum], organize new meetups, and speak about what you like in Cozy! [issues]: https://github.com/cozy/cozy-drive/issues/new [pr]: https://help.github.com/categories/collaborating-with-issues-and-pull-requests/ [forking]: http://blog.campoy.cat/2014/03/github-and-go-forking-pull-requests-and.html [stdjs]: http://standardjs.com/ [commitmsg]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html [localization]: https://github.com/cozy/cozy-drive/blob/master/README.md#localization [hub]: https://hub.github.com/ [tx]: https://www.transifex.com/cozy/ [tx-start]: https://help.transifex.com/en/articles/6248698-getting-started-as-a-translator [forum]: https://forum.cozy.io/ ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ # Twake Drive

banner

The open-source alternative to Google Drive.
Learn more »

Website | Issues

## About booking-screen ## What's Drive? Twake Drive makes your file management easy. Main features are: - File tree - Files and folders upload. - Files and folders sharing (via URLs) - Files and folders search ## Getting Started _:pushpin: Note:_ [Yarn] is the official Node package manager of Twake Drive. Don't hesitate to [install Yarn][yarn-install] ### Install Starting the Drive app requires you to [setup a dev environment][setup]. You can then clone the app repository and install dependencies: ```sh $ git clone https://github.com/linagora/twake-drive.git $ cd twake-drive $ yarn install ``` :pushpin: Don't forget to set the local node version indicated in the `.nvmrc` before doing a `yarn install`. Twake Drive use a standard set of _npm scripts_ to run common tasks, like watch, lint, test, build… ### Run in dev mode Using a watcher - with Hot Module Replacement: ```sh $ cd twake-drive $ yarn watch $ cozy-stack serve --appdir drive://twake-drive/build/drive --disable-csp ``` Or directly build the app (static file generated): ```sh $ cd twake-drive $ yarn build $ cozy-stack serve --appdir drive://twake-drive/build/drive ``` Your app is available at http://drive.cozy.localhost:8080/#/folder Note: it's mandatory to explicit to cozy-stack the folder of the build that should be served, to be able to run the app. ### Run it inside the VM You can view your current running app, you can use the [cozy-stack docker image][cozy-stack-docker]: ```sh # in a terminal, run your app in watch mode $ cd twake-drive $ yarn watch ``` ```sh # in another terminal, run the docker container $ docker run --rm -it -p 8080:8080 -v "$(pwd)/build/drive":/data/cozy-app/drive cozy/cozy-app-dev ``` Your app is available at http://drive.cozy.tools:8080. ## Advanced case ### Share and send mails in development Twake Drive let users [share documents from twake to twake](https://github.com/cozy/cozy-stack/blob/master/docs/sharing.md#cozy-to-cozy-sharing). Meet Alice and Bob. Alice wants to share a folder with Bob. Alice clicks on the share button and fills in the email input with Bob's email address. Bob receives an email with a _« Accept the sharing »_ button. Bob clicks on that button and is redirected to Alice's twake to enter his own twake url to link both twakes. Bob sees Alice's shared folder in his own twake. 🤔 But how could we do this scenario on binary cozy-stack development environment? If you develop with the [cozy-stack CLI](https://github.com/cozy/cozy-stack/blob/master/docs/cli/cozy-stack.md), you have to run [MailHog](https://github.com/mailhog/MailHog) on your computer and tell `cozy-stack serve` where to find the mail server with some [options](https://github.com/cozy/cozy-stack/blob/master/docs/cli/cozy-stack_serve.md#options): ``` ./cozy-stack serve --appdir drive:../twake-drive/build --mail-disable-tls --mail-port 1025 ``` _This commands assumes you `git clone` [twake-drive](https://github.com/linagora/twake-drive) in the same folder than you `git clone` [cozy-stack](https://github.com/cozy/cozy-stack)._ Then simply run `mailhog` and open http://cozy.tools:8025/. #### Retrieve sent emails With MailHog, **every email** sent by cozy-stack is caught. That means the email address _does not have to be a real one_, ie. `bob@cozy`, `bob@cozy.tools` are perfectly fine. It _could be a real one_, but the email will not reach the real recipient's inbox, say `contact@cozycloud.cc`. ### Living on the edge [Cozy-ui] is our frontend stack library that provides common styles and components accross the whole Twake React apps. You can use it for you own application to follow the official Twake's guidelines and styles. If you need to develop / hack cozy-ui, it's sometimes more useful to develop on it through another app. You can do it by cloning cozy-ui locally and link it to yarn local index: ```sh git clone https://github.com/cozy/cozy-ui.git cd cozy-ui yarn install yarn link ``` then go back to your app project and replace the distributed cozy-ui module with the linked one: ```sh cd twake-drive yarn link cozy-ui ``` You can now run the watch task and your project will hot-reload each times a cozy-ui source file is touched. ###### Troubleshooting Consider using [rlink] instead of `yarn link` [Cozy-client] is our API library that provides an unified API on top of the cozy-stack. If you need to develop / hack cozy-client in parallel of your application, you can use the same trick that we used with [cozy-ui]: yarn linking. ### Tests Tests are run by [jest] under the hood, and written using [chai] and [sinon]. You can easily run the tests suite with: ```sh $ cd twake-drive $ yarn test ``` :pushpin: Don't forget to update / create new tests when you contribute to code to keep the app the consistent. ### Open a Pull-Request If you want to work on Drive and submit code modifications, feel free to open pull-requests! See the [contributing guide][contribute] for more information about how to properly open pull-requests. ## Community ### Localization Localization and translations are handled by [Transifex][tx]. As a _translator_, you can login to [Transifex][tx-signin] (using your Github account) and claim access to the [app repository][tx-app]. Locales are pulled [by the pipeline][yarn tx in travis.yml] when app is build before publishing. As a _developer_, you must configure the [Transifex CLI][tx-cli], and claim access as _maintainer_ to the [app repository][tx-app]. Then please **only update** the source locale file (usually `en.json` in client and/or server parts), and push it to Transifex repository using the `tx push -s` command. If you were using a [transifex-client](tx-client), you must move to [Transifex CLI](tx-cli) to be compatible with the v3 API. The transifex configuration file is still in an old version. Please use the previous client for the moment [https://github.com/transifex/transifex-client/](https://github.com/transifex/transifex-client/). ## License Twake Drive is developed by Linagora and distributed under the [AGPL v3 license][agpl-3.0]. [cozy]: https://cozy.io 'Cozy Cloud' [setup]: https://docs.cozy.io/en/tutorials/app/#install-the-development-environment 'Cozy dev docs: Set up the Development Environment' [yarn]: https://yarnpkg.com/ [yarn-install]: https://yarnpkg.com/en/docs/install [cozy-ui]: https://github.com/cozy/cozy-ui [rlink]: https://gist.github.com/ptbrowne/add609bdcf4396d32072acc4674fff23 [cozy-client]: https://github.com/cozy/cozy-client/ [cozy-stack-docker]: https://github.com/cozy/cozy-stack/blob/master/docs/client-app-dev.md#with-docker [doctypes]: https://cozy.github.io/cozy-doctypes/ [bill-doctype]: https://github.com/cozy/cozy-konnector-libs/blob/master/models/bill.js [konnector-doctype]: https://github.com/cozy/cozy-konnector-libs/blob/master/models/base_model.js [konnectors]: https://github.com/cozy/cozy-konnector-libs [agpl-3.0]: https://www.gnu.org/licenses/agpl-3.0.html [contribute]: CONTRIBUTING.md [tx]: https://www.transifex.com/cozy/ [tx-signin]: https://www.transifex.com/signin/ [tx-app]: https://www.transifex.com/cozy/cozy-drive/dashboard/ [tx-translate]: https://www.transifex.com/cozy/cozy-drive/translate/ [tx-cli]: https://developers.transifex.com/docs/cli [tx-client]: https://github.com/transifex/transifex-client [libera]: https://web.libera.chat/#cozycloud [forum]: https://forum.cozy.io/ [github]: https://github.com/cozy/ [twitter]: https://twitter.com/linagora [nvm]: https://github.com/creationix/nvm [cozy-dev]: https://github.com/cozy/cozy-dev/ [jest]: https://jestjs.io/fr/ [chai]: http://chaijs.com/ [sinon]: http://sinonjs.org/ [checkbox]: https://help.github.com/articles/basic-writing-and-formatting-syntax/#task-lists [yarn tx in travis.yml]: .travis.yml#L41 ================================================ FILE: babel.config.js ================================================ module.exports = { presets: ['cozy-app', '@babel/env'] } ================================================ FILE: docs/nextcloud.md ================================================ # Nextcloud The integration of Nextcloud within cozy-drive relies heavily on cozy-client and the proxy made by cozy-stack ([doc](https://docs.cozy.io/en/cozy-stack/nextcloud/)). This implies 2 main constraints that are worth mentioning if you want to understand the code better. **1. Obtain a folder itself** The query to `io.cozy.remote.nextcloud.files` can only retrieve the contents of one folder. To avoid this problem, we query its parent and filter by id to get data about. **2. Reload data after mutation** The mutations doesn't have any effect on the local store because we cannot update it with the request's response like for `io.cozy.files` and there are no real-time event that would update. To avoid this problem, we reset the affected queries with `client.reset(queryId)`. When the query involves moving a file, the destination query is privileged. The cache of the source query will be updated when cozy-client receives the reset query answer. This avoids an additional network request. If the query does not exist, then we reset the source query. ================================================ FILE: eslint.config.mjs ================================================ import basics from 'eslint-config-cozy-app/basics' import cozyReact from 'eslint-config-cozy-app/react' const baseImportOrderRule = basics.find(c => c.rules?.['import/order'])?.rules[ 'import/order' ] const baseImportOrderOptions = baseImportOrderRule[1] const basePathGroups = baseImportOrderOptions.pathGroups export default [ ...cozyReact, { rules: { 'import/order': [ 'warn', { ...baseImportOrderOptions, pathGroups: [ ...basePathGroups, { pattern: '**/*.styl', group: 'index', position: 'after' }, { pattern: 'test/**/*', group: 'index' }, { pattern: 'lib/**/*', group: 'index' }, { pattern: 'hooks/**/*', group: 'index' }, { pattern: 'components/**/*', group: 'index' }, { pattern: 'modules/**/*', group: 'index' }, { pattern: 'assets/**/*', group: 'index' }, { pattern: 'models/**/*', group: 'index' }, { pattern: 'config/**/*', group: 'index' }, { pattern: 'constants/**/*', group: 'index' }, { pattern: 'locales/**/*', group: 'index' }, { pattern: 'queries', group: 'index' } ] } ] } } ] ================================================ FILE: jest.config.js ================================================ module.exports = { roots: ['/src'], setupFiles: ['/jestHelpers/setup.js'], setupFilesAfterEnv: ['/jestHelpers/setupFilesAfterEnv.js'], moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'styl'], moduleNameMapper: { '.(png|gif|jpe?g)$': '/jestHelpers/mocks/fileMock.js', '.svg$': '/jestHelpers/mocks/iconMock.js', '\\?raw$': '/jestHelpers/mocks/svgRawMock.js', '.styl$': 'identity-obj-proxy', '\\.(css|less)$': 'identity-obj-proxy', '^locales/.*': '/src/locales/en.json', '^models(.*)': '/src/models$1', '^sharing(.*)': '/src/sharing$1', '^authentication(.*)': '/src/authentication$1', '^viewer(.*)': '/src/viewer$1', '^react-cozy-helpers(.*)': '/src/lib/react-cozy-helpers$1', '^components(.*)': '/src/components$1', '^hooks(.*)': '/src/hooks$1', '^test(.*)': '/test/$1', '^lib(.*)': '/src/lib$1', 'react-pdf/dist/esm/pdf.worker.entry': '/jestHelpers/mocks/pdfjsWorkerMock.js', '^cozy-client$': 'cozy-client/dist/index.js', '^react-redux': '/node_modules/react-redux', '^cozy-ui/react(.*)$': '/node_modules/cozy-ui/transpiled/react$1', '^config/(.*)': '/src/config/$1', '^constants/(.*)': '/src/constants/$1', '^modules/(.*)': '/src/modules/$1', '^queries(.*)': '/src/queries$1', '^@/(.*)$': '/src/$1' }, clearMocks: true, transform: { '\\.(js|jsx|mjs)$': [ '@swc/jest', { jsc: { experimental: { plugins: [['@swc-contrib/mut-cjs-exports', {}]] }, parser: { jsx: true } } } ], '\\.(ts|tsx)$': [ '@swc/jest', { jsc: { experimental: { plugins: [['@swc-contrib/mut-cjs-exports', {}]] }, parser: { syntax: 'typescript', tsx: true } } } ], '^.+\\.webapp$': '/test/jestLib/json-transformer.js' }, transformIgnorePatterns: [ 'node_modules/(?!cozy-ui|cozy-harvest-lib|cozy-keys-lib|cozy-sharing|)', 'jest-runner' ], testEnvironment: 'jsdom', testEnvironmentOptions: { url: 'http://cozy.localhost:8080/' }, testMatch: ['**/(*.)(spec|test).[jt]s?(x)'], reporters: ['default', '/jestHelpers/ConsoleUsageReporter.js'] } ================================================ FILE: jestHelpers/ConsoleUsageReporter.js ================================================ /* eslint-disable class-methods-use-this */ const fs = require('fs') const path = require('path') const { red, reset } = require('chalk') const TMP_FILE_PATH = path.join(process.cwd(), '.consoleUsageReporter.json') /** * Prevents using the console in the tests without mocking it and prevents not * handling errors/warnings from 3rd parties. */ module.exports = class ConsoleUsageReporter { static deleteTemporaryFile() { try { fs.unlinkSync(TMP_FILE_PATH) } catch (e) { // Ignored } } static getTestFilesThatUsedConsole() { try { return JSON.parse(fs.readFileSync(TMP_FILE_PATH, 'utf8')) } catch (e) { return [] } } static recordConsoleUsedInCurrentTestFile() { const testPath = global.jasmine?.testPath || expect.getState()?.testPath || '' const testFilesThatUsedConsole = this.getTestFilesThatUsedConsole() if (!testFilesThatUsedConsole.includes(testPath)) { testFilesThatUsedConsole.push(testPath) fs.writeFileSync( TMP_FILE_PATH, JSON.stringify(testFilesThatUsedConsole), 'utf8' ) } } static makeTestsFailWhenConsoleUsed() { let consoleCalls = [] let testsRunning = true const formatConsoleCalls = calls => calls .map(({ args, callStack, method }) => { const formattedArgs = args .map(arg => (arg instanceof Error ? arg.stack || arg : arg)) .join(' ') .split('\n') .map(line => ` ${line}`) .join('\n') const formattedCallStack = !/^\s*(at|in) /m.test(formattedArgs) ? red( `\n\n${callStack .split('\n') .map(line => ` ${line}`) .join('\n')}` ) : '' return `console.${method}\n${reset( formattedArgs )}${formattedCallStack}` }) .join('\n\n') ;['error', 'info', 'log', 'warn'].forEach(method => { global.console[method] = (...args) => { const callStack = new Error().stack .split('\n') .slice(2) .map(line => line.trim()) .join('\n') if (consoleCalls.length === 0) { ConsoleUsageReporter.recordConsoleUsedInCurrentTestFile() } if (testsRunning) { consoleCalls.push({ args, callStack, method }) } else { process.stderr.write( red(` The console has been called outside a test which usually means you mishandled asynchronous actions. Here is what have been logged: ${reset(formatConsoleCalls([{ args, callStack, method }]))} `) ) } } }) beforeAll(() => { testsRunning = true }) beforeEach(() => { consoleCalls = [] }) afterEach(() => { if (consoleCalls.length > 0) { throw new Error( red(`\ This test called the console which is forbidden. Here is what have been logged: ${reset(formatConsoleCalls(consoleCalls))} If calling the console is normal in your test case, consider mocking the \ console as is: jest.spyOn(console, 'method').mockImplementation(); `) ) } }) afterAll(() => { testsRunning = false }) } constructor(globalConfig) { this.globalConfig = globalConfig } onRunComplete() { const isWatchModeEnabled = this.globalConfig.watch || this.globalConfig.watchAll const testFilesThatUsedConsole = ConsoleUsageReporter.getTestFilesThatUsedConsole() ConsoleUsageReporter.deleteTemporaryFile() if (testFilesThatUsedConsole.length > 0) { const error = new Error( red( `\ The following test files called the console which is forbidden: ${testFilesThatUsedConsole.map(file => `- ${file}`).join('\n')} You should find more information in the report of the test that called the \ console. We list all the test files there to allow you to find the console calls that \ did not make any test fail (possibly because of async issues). ` ) ) if (isWatchModeEnabled) { // Prevents to freeze watch mode console.error(error) } else { throw error } } } onRunStart() { ConsoleUsageReporter.deleteTemporaryFile() } } ================================================ FILE: jestHelpers/mocks/fileMock.js ================================================ module.exports = {}; ================================================ FILE: jestHelpers/mocks/iconMock.js ================================================ let id = 0; module.exports = { id: `icon-${id++}` }; ================================================ FILE: jestHelpers/mocks/pdfjsWorkerMock.js ================================================ module.exports = () => {}; ================================================ FILE: jestHelpers/mocks/svgRawMock.js ================================================ module.exports = '' ================================================ FILE: jestHelpers/setup.js ================================================ import React from 'react' import { TransformStream } from 'stream/web' global.cozy = {} global.TransformStream = TransformStream jest.mock('cozy-search', () => ({ AssistantDesktop: () => null, AssistantDialog: () => null, SearchDialog: () => null })) jest.mock('cozy-bar', () => ({ ...jest.requireActual('cozy-bar'), BarComponent: () =>
Bar
, BarLeft: ({ children }) => children, BarRight: ({ children }) => children, BarCenter: ({ children }) => children, BarSearch: ({ children }) => children })) jest.mock('cozy-intent', () => ({ useWebviewIntent: jest.fn() })) jest.mock('cozy-dataproxy-lib', () => ({ DataProxyProvider: ({ children }) => children })) // Mock cozy-flags with jest mock function that supports both flag checking and test mocking jest.mock('cozy-flags', () => { const mockFn = jest.fn(() => { // Return false for all other flags to avoid issues return false }) // Add initialize method that some tests expect mockFn.initialize = jest.fn() return mockFn }) // see https://github.com/jsdom/jsdom/issues/1695 window.HTMLElement.prototype.scroll = function () {} ================================================ FILE: jestHelpers/setupFilesAfterEnv.js ================================================ import '@testing-library/jest-dom' import ConsoleUsageReporter from './ConsoleUsageReporter' ConsoleUsageReporter.makeTestsFailWhenConsoleUsed() process.on('unhandledRejection', error => console.error(error)) ================================================ FILE: manifest.webapp ================================================ { "name": "Drive", "name_prefix": "Twake", "slug": "drive", "version": "1.99.0", "type": "webapp", "licence": "AGPL-3.0", "icon": "assets/app-icon.svg", "categories": ["cozy"], "source": "https://github.com/cozy/cozy-drive", "editor": "Cozy", "developer": { "name": "Twake Workplace", "url": "https://twake.app" }, "locales": { "en": { "short_description": "Twake Drive helps you to save, sync and secure your files on your Twake.", "long_description": "With Twake Drive, you can easily:\n- Store your important files and keep them secure in your Twake\n- Access to all your documents online & offline, from your desktop, and on your smartphone or tablet\n- Share links to files ans folders with who you like;\n- Automatically retrieve bills, payrolls, tax notices and other data from your main online services (internet, energy, retail, mobile, energy, travel...)\n- Upload files to your Twake from your Android", "screenshots": [ "assets/screenshots/en/screenshot01.png", "assets/screenshots/en/screenshot02.png", "assets/screenshots/en/screenshot03.png", "assets/screenshots/en/screenshot04.png" ] }, "fr": { "short_description": "Twake Drive est l’application de sauvegarde, de synchronisation et de sécurisation de tous vos fichiers sur Twake.", "long_description": "Avec Twake Drive vous pourrez :\n- Sauvegarder et synchroniser gratuitement tous vos documents importants (carte d’identité, photos de vacances, avis d’imposition, fiches de salaires…);\n- Accéder à vos documents n’importe quand, n’importe ou même en mode avion depuis votre bureau, votre smartphone ou tablette;\n- Partager vos fichiers et dossiers par lien avec qui vous le souhaitez;\n- Récupérer automatiquement vos documents administratifs de vos principaux fournisseurs de service (opérateur mobile, fournisseur d’énergie, assureur, internet, santé…);\n- Rester synchronisé·e lors de vos voyages et déplacements professionnels avec nos applications mobiles.", "screenshots": [ "assets/screenshots/fr/screenshot01.png", "assets/screenshots/fr/screenshot02.png", "assets/screenshots/fr/screenshot03.png", "assets/screenshots/fr/screenshot04.png" ] } }, "screenshots": [ "assets/screenshots/fr/screenshot01.png", "assets/screenshots/fr/screenshot02.png", "assets/screenshots/fr/screenshot03.png", "assets/screenshots/fr/screenshot04.png" ], "langs": ["en", "fr"], "routes": { "/": { "folder": "/", "index": "index.html", "public": false }, "/intents": { "folder": "/intents", "index": "index.html", "public": false }, "/public": { "folder": "/public", "index": "index.html", "public": true }, "/preview": { "folder": "/public", "index": "index.html", "public": true }, "/assets": { "folder": "/assets", "public": true } }, "intents": [ { "action": "OPEN", "type": ["io.cozy.files"], "href": "/intents" }, { "action": "OPEN", "type": ["io.cozy.suggestions"], "href": "/intents" } ], "entrypoints": [ { "name": "new-file-type-text", "title": { "en": "Doc", "fr": "Doc", "ru": "документ", "vi": "Doc" }, "hash": "/onlyoffice/create/io.cozy.files.root-dir/text", "icon": "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzAwNzZFRCIgZD0iTTguNCAwQTYuNCA2LjQgMCAwIDAgMiA2LjR2MTkuMkE2LjQgNi40IDAgMCAwIDguNCAzMmgxNmE2LjQgNi40IDAgMCAwIDYuNC02LjRWMTAuNEwyMC40IDBjLTQuMDEzIDAtNCAwIDAgMGgtMTJaIi8+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjIuOTA5IiB4PSI4IiB5PSIxMCIgZmlsbD0iI2ZmZiIgcng9IjEuNDU1Ii8+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjIuOTA5IiB4PSI4IiB5PSIxNS45MDkiIGZpbGw9IiNmZmYiIHJ4PSIxLjQ1NSIvPjxyZWN0IHdpZHRoPSIxNiIgaGVpZ2h0PSIyLjkwOSIgeD0iOCIgeT0iMjEuODE4IiBmaWxsPSIjZmZmIiByeD0iMS40NTUiLz48cGF0aCBmaWxsPSIjMDA2OEQyIiBkPSJNMzAuOCAxMC44IDIwIDB2NS41ODZjMCAyLjg4IDIuMjU3IDUuMjE0IDUuMDQgNS4yMTRoNS43NloiLz48L3N2Zz4=", "conditions": [{ "type": "flag", "name": "drive.office.enabled", "value": true }, { "type": "flag", "name": "drive.office.write", "value": true }, { "type": "flag", "name": "bar.onlyoffice.enabled", "value": true }] }, { "name": "new-file-type-sheet", "title": { "en": "Spreadsheet", "fr": "Tableur", "ru": "Электронная таблица", "vi": "Bảng tính" }, "hash": "/onlyoffice/create/io.cozy.files.root-dir/spreadsheet", "icon": "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzBEOUUzMCIgZD0iTTguNCAwQTYuNCA2LjQgMCAwIDAgMiA2LjR2MTkuMkE2LjQgNi40IDAgMCAwIDguNCAzMmgxNmE2LjQgNi40IDAgMCAwIDYuNC02LjRWMTAuNEwyMC40IDBjLTQuMDEzIDAtNCAwIDAgMGgtMTJaIi8+PHJlY3Qgd2lkdGg9IjYuODU3IiBoZWlnaHQ9IjYuODU3IiB4PSI4IiB5PSIxMCIgZmlsbD0iI2ZmZiIgcng9Ii40NTciLz48cmVjdCB3aWR0aD0iNi44NTciIGhlaWdodD0iNi44NTciIHg9IjgiIHk9IjE5LjE0MyIgZmlsbD0iI2ZmZiIgcng9Ii40NTciLz48cmVjdCB3aWR0aD0iNi44NTciIGhlaWdodD0iNi44NTciIHg9IjE3LjE0MyIgeT0iMTkuMTQzIiBmaWxsPSIjZmZmIiByeD0iLjQ1NyIvPjxyZWN0IHdpZHRoPSI2Ljg1NyIgaGVpZ2h0PSI2Ljg1NyIgeD0iMTcuMTQzIiB5PSIxMCIgZmlsbD0iI2ZmZiIgcng9Ii40NTciLz48cGF0aCBmaWxsPSIjMDA4NDIwIiBkPSJNMzAuOCAxMC44IDIwIDB2NS41ODZjMCAyLjg4IDIuMjU3IDUuMjE0IDUuMDQgNS4yMTRoNS43NloiLz48L3N2Zz4=", "conditions": [{ "type": "flag", "name": "drive.office.enabled", "value": true }, { "type": "flag", "name": "drive.office.write", "value": true }, { "type": "flag", "name": "bar.onlyoffice.enabled", "value": true }] }, { "name": "new-file-type-slide", "title": { "fr": "Présentation", "en": "Presentation", "ru": "Презентация", "vi": "Giới thiệu" }, "hash": "/onlyoffice/create/io.cozy.files.root-dir/slide", "icon": "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iI0ZGOTUwMCIgZD0iTTguNCAwQTYuNCA2LjQgMCAwIDAgMiA2LjR2MTkuMkE2LjQgNi40IDAgMCAwIDguNCAzMmgxNmE2LjQgNi40IDAgMCAwIDYuNC02LjRWMTAuNEwyMC40IDBjLTQuMDEzIDAtNCAwIDAgMGgtMTJaIi8+PHBhdGggZmlsbD0iI0RGNjMxMCIgZD0iTTMwLjggMTAuOCAyMCAwdjUuNTg2YzAgMi44OCAyLjI1NyA1LjIxNCA1LjA0IDUuMjE0aDUuNzZaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE1LjQyMiAxMS4xNTdhNy40MjIgNy40MjIgMCAxIDAgNy40MjEgNy40MjFoLTcuNDIxdi03LjQyMVoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTYuNTc4IDEwdjcuNDIySDI0QTcuNDIyIDcuNDIyIDAgMCAwIDE2LjU3OCAxMFoiLz48L3N2Zz4=", "conditions": [{ "type": "flag", "name": "drive.office.enabled", "value": true }, { "type": "flag", "name": "drive.office.write", "value": true }, { "type": "flag", "name": "bar.onlyoffice.enabled", "value": true }] } ], "services": { "qualificationMigration": { "type": "node", "file": "services/qualificationMigration/drive.js" }, "dacc": { "type": "node", "file": "services/dacc/drive.js", "trigger": "@monthly on the 5-7 between 2pm and 7pm" } }, "permissions": { "files": { "description": "Required to access the files", "type": "io.cozy.files", "verbs": ["ALL"] }, "allFiles": { "description": "Required to access the files", "type": "io.cozy.files.*", "verbs": ["ALL"] }, "apps": { "description": "Required by the cozy-bar to display the icons of the apps", "type": "io.cozy.apps", "verbs": ["GET"] }, "sharings": { "description": "Required to have access to the sharings in realtime", "type": "io.cozy.sharings", "verbs": ["GET"] }, "albums": { "description": "Required to manage photos albums", "type": "io.cozy.photos.albums", "verbs": ["PUT", "GET"] }, "contacts": { "type": "io.cozy.contacts", "verbs": ["GET", "POST"] }, "groups": { "type": "io.cozy.contacts.groups", "verbs": ["GET"] }, "settings": { "description": "Required by the cozy-bar to display Claudy and know which applications are coming soon", "type": "io.cozy.settings", "verbs": ["GET"] }, "oauth": { "description": "Required to display the cozy-desktop banner", "type": "io.cozy.oauth.clients", "verbs": ["GET"] }, "errorsreporting": { "description": "Allow to report unexpected errors to the support team", "type": "cc.cozycloud.errors", "verbs": ["POST"] }, "mail": { "description": "Send feedback emails to the support team", "type": "io.cozy.jobs", "verbs": ["POST"], "selector": "worker", "values": ["sendmail"] }, "konnectors": { "description": "Required to display additional information in the viewer for files automatically retrieved by services", "type": "io.cozy.konnectors", "verbs": ["GET"] }, "accounts": { "description": "Required to display additional information in the viewer for files automatically retrieved by services", "type": "io.cozy.accounts", "verbs": ["ALL"] }, "jobs": { "type": "io.cozy.jobs", "verbs": ["ALL"] }, "triggers": { "description": "Required to display additional information in the viewer for files automatically retrieved by services", "type": "io.cozy.triggers", "verbs": ["ALL"] }, "dacc": { "type": "cc.cozycloud.dacc_v2", "verbs": ["POST"], "description": "Remote-doctype required to send anonymized measures to the DACC shared among mycozy.cloud's Cozy." }, "dacc-eu": { "type": "eu.mycozy.dacc_v2", "verbs": ["POST"], "description": "Remote-doctype required to send anonymized measures to the DACC shared among mycozy.eu's Cozy." }, "chatConversations": { "description": "Required by the cozy Assistant", "type": "io.cozy.ai.chat.conversations", "verbs": ["GET", "POST"] }, "chatEvents": { "description": "Required by the cozy Assistant", "type": "io.cozy.ai.chat.events", "verbs": ["GET"] }, "driveSettings": { "description": "Required to access the drive settings", "type": "io.cozy.drive.settings", "verbs": ["ALL"] }, "nextcloud_migrations": { "description": "Read Nextcloud migration documents and subscribe to updates", "type": "io.cozy.nextcloud.migrations", "verbs": ["GET"] } }, "accept_from_flagship": true, "accept_documents_from_flagship": { "accepted_mime_types": ["*/*"], "max_number_of_files": 10, "max_size_per_file_in_MB": 100, "route_to_upload": "/#/upload?fromFlagshipUpload=true" } } ================================================ FILE: package.json ================================================ { "name": "cozy-drive", "version": "1.99.0", "main": "src/main.jsx", "scripts": { "build": "rsbuild build", "watch": "rsbuild build --watch --mode development", "start": "rsbuild dev", "analyze": "RSDOCTOR=true yarn build", "cozyPublish": "cozy-app-publish --token $REGISTRY_TOKEN --prepublish downcloud --postpublish mattermost", "tx": "tx pull --all || true", "lint": "npm-run-all --parallel 'lint:*'", "lint:styles": "stylint src --config ./node_modules/stylus-config-cozy-app/.stylintrc", "lint:js": "eslint '{src,test}/**/*.{js,jsx,ts,tsx}'", "test": "env NODE_ENV='test' jest", "service": "yarn cozy-konnector-dev -m ./manifest.webapp" }, "repository": { "type": "git", "url": "git+https://github.com/cozy/cozy-drive.git" }, "author": "Cozy Cloud (https://cozy.io/)", "contributors": [ "CPatchane", "enguerran", "GoOz", "goldoraf", "gregorylegarec", "kossi", "m4dz", "nono", "ptbrowne", "y_lohse", "trollepierre" ], "license": "AGPL-3.0", "bugs": { "url": "https://github.com/cozy/cozy-drive/issues" }, "homepage": "https://github.com/cozy/cozy-drive#readme", "devDependencies": { "@rsbuild/core": "^1.5.15", "@swc-contrib/mut-cjs-exports": "^14.7.0", "@swc/core": "^1.15.18", "@swc/jest": "^0.2.39", "@testing-library/jest-dom": "5.17.0", "@testing-library/react": "14.3.1", "@types/react-redux": "7.1.26", "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "5.62.0", "@welldone-software/why-did-you-render": "^10.0.1", "babel-preset-cozy-app": "2.1.0", "bundlemon": "3.1.0", "cozy-app-publish": "^0.40.1", "cozy-jobs-cli": "^2.4.3", "cozy-tsconfig": "^1.8.1", "css-mediaquery": "0.1.2", "eslint": "10.0.2", "eslint-config-cozy-app": "7.0.0", "husky": "0.14.3", "identity-obj-proxy": "3.0.0", "jest": "^30.0.0", "jest-environment-jsdom": "^30.0.0", "mockdate": "^3.0.5", "npm-run-all2": "5.0.0", "prettier": "2.8.8", "rsbuild-config-cozy-app": "^0.7.1", "stylint": "1.5.9", "stylus-config-cozy-app": "^0.1.0", "typescript": "4.9.5", "worker-loader": "2.0.0" }, "dependencies": { "@sentry/react": "7.119.0", "classnames": "2.3.1", "cozy-bar": "^33.3.0", "cozy-client": "^60.23.1", "cozy-dataproxy-lib": "^4.13.0", "cozy-device-helper": "^4.0.1", "cozy-devtools": "^1.2.1", "cozy-doctypes": "1.85.4", "cozy-flags": "^4.6.1", "cozy-harvest-lib": "^37.0.6", "cozy-intent": "^2.30.1", "cozy-interapp": "^0.17.1", "cozy-keys-lib": "^7.0.0", "cozy-logger": "^1.17.0", "cozy-minilog": "3.9.1", "cozy-pouch-link": "^60.19.0", "cozy-realtime": "^5.8.0", "cozy-search": "^0.25.3", "cozy-sharing": "^30.3.1", "cozy-stack-client": "^60.23.0", "cozy-ui": "^138.10.0", "cozy-ui-plus": "^7.1.0", "cozy-viewer": "^28.0.7", "date-fns": "2.30.0", "diacritics": "1.3.0", "filesize": "10.1.6", "leaflet": "1.9.4", "localforage": "1.10.0", "lodash": "4.17.21", "mime-types": "2.1.35", "node-fetch": "2.6.7", "node-polyglot": "2.4.2", "prop-types": "15.8.1", "react": "18.2.0", "react-autosuggest": "10.1.0", "react-dnd": "16.0.1", "react-dnd-html5-backend": "16.0.1", "react-dom": "18.2.0", "react-dropzone": "14.3.8", "react-inspector": "5.1.1", "react-pdf": "^5.7.2", "react-redux": "7.2.0", "react-remove-scroll": "2.4.4", "react-router-dom": "6.14.2", "react-selecto": "^1.26.3", "redux": "3.7.2", "redux-logger": "3.0.6", "redux-mock-store": "1.5.4", "redux-thunk": "2.4.2", "twake-i18n": "^0.3.4", "whatwg-fetch": "3.0.0" } } ================================================ FILE: public/browserconfig.xml ================================================ #2d89ef ================================================ FILE: public/manifest.json ================================================ { "name": "", "icons": [ { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } ], "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" } ================================================ FILE: renovate.json ================================================ { "extends": ["cozy"] } ================================================ FILE: rsbuild.config.mjs ================================================ import { defineConfig, mergeRsbuildConfig } from '@rsbuild/core' import { getRsbuildConfig } from 'rsbuild-config-cozy-app' const config = getRsbuildConfig({ title: 'Twake Drive', hasServices: true, hasPublic: true, hasIntents: true }) const mergedConfig = mergeRsbuildConfig(config, { environments: { main: { output: { copy: [ { from: 'src/assets/onlyOffice', to: 'onlyOffice' }, { from: 'src/assets/favicons', to: 'favicons' } ] } } }, resolve: { alias: { 'react-pdf$': 'react-pdf/dist/esm/entry.webpack' } } }) export default defineConfig(mergedConfig) ================================================ FILE: src/components/App/App.jsx ================================================ import PropTypes from 'prop-types' import React, { Fragment } from 'react' import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' import { Provider } from 'react-redux' import { BarProvider } from 'cozy-bar' import flag from 'cozy-flags' import { WebviewIntentProvider } from 'cozy-intent' import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import MoveValidationModals from '@/components/MoveValidationModals' import PushBannerProvider from '@/components/PushBanner/PushBannerProvider' import ClipboardProvider from '@/contexts/ClipboardProvider' import { AcceptingSharingProvider } from '@/lib/AcceptingSharingContext' import DriveProvider from '@/lib/DriveProvider' import { ModalContextProvider } from '@/lib/ModalContext' import { ViewSwitcherContextProvider } from '@/lib/ViewSwitcherContext' import { PublicProvider } from '@/modules/public/PublicProvider' import { onFileUploaded } from '@/modules/views/Upload/UploadUtils' const Providers = ({ children }) => { const { isMobile } = useBreakpoints() const [DnDProvider, dnDProviderProps] = flag('drive.virtualization.enabled') && !isMobile ? [DndProvider, { backend: HTML5Backend }] : [Fragment, {}] return ( {children} ) } const App = ({ isPublic, store, client, lang, polyglot, children }) => { return ( onFileUploaded({ file, isSuccess }, store.dispatch) }} > {children} ) } App.propTypes = { store: PropTypes.object, lang: PropTypes.string, polyglot: PropTypes.object, client: PropTypes.object } export default App ================================================ FILE: src/components/Bar.jsx ================================================ import React from 'react' import { BarRight } from 'cozy-bar' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' export const BarRightOnMobile = ({ children }) => { const { isMobile } = useBreakpoints() if (isMobile) { return {children} } return children } ================================================ FILE: src/components/Button/BackButton.jsx ================================================ import React from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import PreviousIcon from 'cozy-ui/transpiled/react/Icons/Previous' import { useI18n } from 'twake-i18n' export const BackButton = ({ onClick, ...props }) => { const { t } = useI18n() return ( ) } export default BackButton ================================================ FILE: src/components/Button/MoreButton.jsx ================================================ import React from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots' import { useI18n } from 'twake-i18n' const MoreButton = ({ disabled, onClick, ...props }) => { const { t } = useI18n() return (
) } export default MoreButton ================================================ FILE: src/components/Button/OpenFolderButton.tsx ================================================ import React, { FC } from 'react' import { NavigateFunction } from 'react-router-dom' import Button from 'cozy-ui/transpiled/react/Buttons' import { useI18n } from 'twake-i18n' import { File } from '@/components/FolderPicker/types' interface OpenFolderButtonProps { folder: File navigate: NavigateFunction } const OpenFolderButton: FC = ({ folder, navigate }) => { const { t } = useI18n() const handleNavigateFolder = (): void => { if (folder._type === 'io.cozy.remote.nextcloud.files') { return navigate( `/nextcloud/${folder.cozyMetadata.sourceAccount}?path=${folder.path}` ) } return navigate(`/folder/${folder._id}`) } return (
{isItemCut('file1').toString()}
) } const renderWithProvider = ( ui: React.ReactElement ): ReturnType => { return render({ui}) } describe('ClipboardProvider', () => { beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers() }) afterEach(() => { jest.useRealTimers() }) describe('Copy Operations', () => { it('should copy files to clipboard', () => { renderWithProvider() fireEvent.click(screen.getByText('Copy Files')) // act(() => { // screen.getByText('Copy Files').click() // }) expect(screen.getByTestId('clipboard-files-count')).toHaveTextContent('2') expect(screen.getByTestId('clipboard-operation')).toHaveTextContent( 'copy' ) expect(screen.getByTestId('has-clipboard-data')).toHaveTextContent('true') expect(screen.getByTestId('source-folder-id')).toHaveTextContent( 'source-folder' ) expect(screen.getByTestId('cut-item-ids')).toHaveTextContent('') }) it('should handle copy without source folder', () => { const { result } = renderHook(() => useClipboardContext(), { wrapper: ClipboardProvider }) actHook(() => { result.current.copyFiles([mockFile1]) }) expect(result.current.clipboardData.sourceFolderIds).toBeNull() }) }) describe('Cut Operations', () => { it('should cut files to clipboard', () => { renderWithProvider() fireEvent.click(screen.getByText('Cut Files')) // act(() => { // screen.getByText('Cut Files').click() // }) expect(screen.getByTestId('clipboard-files-count')).toHaveTextContent('1') expect(screen.getByTestId('clipboard-operation')).toHaveTextContent('cut') expect(screen.getByTestId('has-clipboard-data')).toHaveTextContent('true') expect(screen.getByTestId('cut-item-ids')).toHaveTextContent('file1') expect(screen.getByTestId('is-file1-cut')).toHaveTextContent('true') }) it('should track multiple cut item IDs', () => { const { result } = renderHook(() => useClipboardContext(), { wrapper: ClipboardProvider }) actHook(() => { result.current.cutFiles([mockFile1, mockFile2]) }) expect(result.current.clipboardData.cutItemIds.has('file1')).toBe(true) expect(result.current.clipboardData.cutItemIds.has('file2')).toBe(true) expect(result.current.isItemCut('file1')).toBe(true) expect(result.current.isItemCut('file2')).toBe(true) expect(result.current.isItemCut('file3')).toBe(false) }) }) describe('Clear Operations', () => { it('should clear clipboard data', () => { renderWithProvider() // First copy some files fireEvent.click(screen.getByText('Copy Files')) // act(() => { // screen.getByText('Copy Files').click() // }) expect(screen.getByTestId('has-clipboard-data')).toHaveTextContent('true') // Then clear fireEvent.click(screen.getByText('Clear Clipboard')) // act(() => { // screen.getByText('Clear Clipboard').click() // }) expect(screen.getByTestId('clipboard-files-count')).toHaveTextContent('0') expect(screen.getByTestId('clipboard-operation')).toHaveTextContent( 'none' ) expect(screen.getByTestId('has-clipboard-data')).toHaveTextContent( 'false' ) expect(screen.getByTestId('cut-item-ids')).toHaveTextContent('') expect(screen.getByTestId('source-folder-id')).toHaveTextContent('none') }) }) describe('Move Validation Modal', () => { it('should show move validation modal', () => { renderWithProvider() fireEvent.click(screen.getByText('Show Modal')) // act(() => { // screen.getByText('Show Modal').click() // }) expect(screen.getByTestId('modal-visible')).toHaveTextContent('true') expect(screen.getByTestId('modal-type')).toHaveTextContent('moveOutside') }) it('should hide move validation modal', () => { renderWithProvider() // First show modal fireEvent.click(screen.getByText('Show Modal')) // act(() => { // screen.getByText('Show Modal').click() // }) expect(screen.getByTestId('modal-visible')).toHaveTextContent('true') // Then hide modal fireEvent.click(screen.getByText('Hide Modal')) // act(() => { // screen.getByText('Hide Modal').click() // }) expect(screen.getByTestId('modal-visible')).toHaveTextContent('false') expect(screen.getByTestId('modal-type')).toHaveTextContent('none') }) it('should handle all modal types', () => { const { result } = renderHook(() => useClipboardContext(), { wrapper: ClipboardProvider }) const modalTypes = [ 'moveOutside', 'moveInside', 'moveSharedInside' ] as const modalTypes.forEach(type => { actHook(() => { result.current.showMoveValidationModal( type, mockFile1, mockFolder, async (): Promise => { // Empty async function for test }, (): void => { // Empty function for test } ) }) expect(result.current.moveValidationModal.type).toBe(type) expect(result.current.moveValidationModal.isVisible).toBe(true) expect(result.current.moveValidationModal.file).toEqual(mockFile1) expect(result.current.moveValidationModal.targetFolder).toEqual( mockFolder ) }) }) }) describe('State Transitions', () => { it('should replace clipboard data when copying new files', () => { const { result } = renderHook(() => useClipboardContext(), { wrapper: ClipboardProvider }) // First copy actHook(() => { result.current.copyFiles([mockFile1]) }) expect(result.current.clipboardData.files).toHaveLength(1) expect(result.current.clipboardData.files[0]._id).toBe('file1') // Second copy should replace actHook(() => { result.current.copyFiles([mockFile2]) }) expect(result.current.clipboardData.files).toHaveLength(1) expect(result.current.clipboardData.files[0]._id).toBe('file2') }) it('should transition from copy to cut operation', () => { const { result } = renderHook(() => useClipboardContext(), { wrapper: ClipboardProvider }) // First copy actHook(() => { result.current.copyFiles([mockFile1]) }) expect(result.current.clipboardData.operation).toBe('copy') expect(result.current.clipboardData.cutItemIds.size).toBe(0) // Then cut actHook(() => { result.current.cutFiles([mockFile2]) }) expect(result.current.clipboardData.operation).toBe('cut') expect(result.current.clipboardData.cutItemIds.has('file2')).toBe(true) expect(result.current.clipboardData.cutItemIds.has('file1')).toBe(false) }) }) }) ================================================ FILE: src/contexts/ClipboardProvider.tsx ================================================ import React, { createContext, useContext, useReducer, useCallback, ReactNode } from 'react' import { IOCozyFile } from 'cozy-client/types/types' export const OPERATION_CUT = 'cut' as const export const OPERATION_COPY = 'copy' as const interface MoveValidationModal { isVisible: boolean type: 'moveOutside' | 'moveInside' | 'moveSharedInside' | null file: IOCozyFile | null targetFolder: IOCozyFile onConfirm: (() => Promise) | null onCancel: (() => void) | null } interface ClipboardState { files: IOCozyFile[] operation: typeof OPERATION_COPY | typeof OPERATION_CUT | null timestamp: number | null cutItemIds: Set sourceFolderIds: Set | null moveValidationModal: MoveValidationModal sourceDirectory: IOCozyFile } interface ClipboardContextValue { clipboardData: ClipboardState copyFiles: (files: IOCozyFile[], sourceFolderIds?: Set) => void cutFiles: ( files: IOCozyFile[], sourceFolderIds?: Set, sourceDirectory?: IOCozyFile ) => void clearClipboard: () => void hasClipboardData: boolean isItemCut: (itemId: string) => boolean showMoveValidationModal: ( type: MoveValidationModal['type'], file: IOCozyFile, targetFolder: IOCozyFile, onConfirm: () => Promise, onCancel: () => void ) => void hideMoveValidationModal: () => void moveValidationModal: MoveValidationModal } const COPY_FILES = 'COPY_FILES' const CUT_FILES = 'CUT_FILES' const CLEAR_CLIPBOARD = 'CLEAR_CLIPBOARD' const SHOW_SHARING_MODAL = 'SHOW_SHARING_MODAL' const HIDE_SHARING_MODAL = 'HIDE_SHARING_MODAL' type ClipboardAction = | { type: typeof COPY_FILES payload: { files: IOCozyFile[] sourceFolderIds?: Set sourceDirectory?: IOCozyFile } } | { type: typeof CUT_FILES payload: { files: IOCozyFile[] sourceFolderIds?: Set sourceDirectory?: IOCozyFile } } | { type: typeof CLEAR_CLIPBOARD } | { type: typeof SHOW_SHARING_MODAL payload: { type: MoveValidationModal['type'] file: IOCozyFile targetFolder: IOCozyFile onConfirm: () => Promise onCancel: () => void } } | { type: typeof HIDE_SHARING_MODAL } const initialState: ClipboardState = { files: [], operation: null, timestamp: null, cutItemIds: new Set(), sourceFolderIds: new Set(), sourceDirectory: {} as IOCozyFile, moveValidationModal: { isVisible: false, type: null, file: null, targetFolder: {} as IOCozyFile, onConfirm: null, onCancel: null } } const clipboardReducer = ( state: ClipboardState, action: ClipboardAction ): ClipboardState => { switch (action.type) { case COPY_FILES: return { ...state, files: [...action.payload.files], operation: OPERATION_COPY, timestamp: Date.now(), cutItemIds: new Set(), sourceFolderIds: action.payload.sourceFolderIds ?? null, sourceDirectory: action.payload.sourceDirectory ?? ({} as IOCozyFile) } case CUT_FILES: return { ...state, files: [...action.payload.files], operation: OPERATION_CUT, timestamp: Date.now(), cutItemIds: new Set(action.payload.files.map(file => file._id)), sourceFolderIds: action.payload.sourceFolderIds ?? null, sourceDirectory: action.payload.sourceDirectory ?? ({} as IOCozyFile) } case CLEAR_CLIPBOARD: return { ...initialState } case SHOW_SHARING_MODAL: return { ...state, moveValidationModal: { isVisible: true, type: action.payload.type, file: action.payload.file, targetFolder: action.payload.targetFolder, onConfirm: action.payload.onConfirm, onCancel: action.payload.onCancel } } case HIDE_SHARING_MODAL: return { ...state, moveValidationModal: { ...initialState.moveValidationModal } } default: return state } } const ClipboardContext = createContext( undefined ) interface ClipboardProviderProps { children: ReactNode } const ClipboardProvider: React.FC = ({ children }) => { const [state, dispatch] = useReducer(clipboardReducer, initialState) const copyFiles = useCallback( (files: IOCozyFile[], sourceFolderIds?: Set) => { dispatch({ type: COPY_FILES, payload: { files, sourceFolderIds } }) }, [] ) const cutFiles = useCallback( ( files: IOCozyFile[], sourceFolderIds?: Set, sourceDirectory?: IOCozyFile ) => { dispatch({ type: CUT_FILES, payload: { files, sourceFolderIds, sourceDirectory } }) }, [] ) const clearClipboard = useCallback(() => { dispatch({ type: CLEAR_CLIPBOARD }) }, []) const showMoveValidationModal = useCallback( ( type: MoveValidationModal['type'], file: IOCozyFile, targetFolder: IOCozyFile, onConfirm: () => Promise, onCancel: () => void ) => { dispatch({ type: SHOW_SHARING_MODAL, payload: { type, file, targetFolder, onConfirm, onCancel } }) }, [] ) const hideMoveValidationModal = useCallback(() => { dispatch({ type: HIDE_SHARING_MODAL }) }, []) const hasClipboardData = state.files.length > 0 && Boolean(state.operation) const isItemCut = useCallback( (itemId: string) => { return state.cutItemIds.has(itemId) }, [state.cutItemIds] ) const value: ClipboardContextValue = { clipboardData: state, copyFiles, cutFiles, clearClipboard, hasClipboardData, isItemCut, showMoveValidationModal, hideMoveValidationModal, moveValidationModal: state.moveValidationModal } return ( {children} ) } export const useClipboardContext = (): ClipboardContextValue => { const context = useContext(ClipboardContext) if (!context) { throw new Error( 'useClipboardContext must be used within a ClipboardProvider' ) } return context } export default ClipboardProvider ================================================ FILE: src/declarations.d.ts ================================================ declare module 'cozy-ui/*' declare module 'cozy-ui/transpiled/react/styles' { export function makeStyles(styles: T): () => T } declare module 'cozy-ui/transpiled/react/Icons/*' { const Icon: React.ComponentType<{ className?: string color?: string size?: string }> export default Icon } declare module 'cozy-ui/transpiled/react' { export const logger: { info: (message: string, ...rest: unknown[]) => void } export const BreakpointsProvider: React.ComponentType export const MuiCozyTheme: React.ComponentType } declare module 'twake-i18n' { export const useI18n: () => { t: (key: string, options?: Record) => string f: (date: Date | number, format: string) => string lang: string } } declare module 'cozy-ui/transpiled/react/providers/Alert' { export interface showAlertProps { message: string severity?: | 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info' action?: React.ReactNode duration?: number | null noClickAway?: boolean } export type showAlertFunction = (props: showAlertProps) => void export const useAlert: () => { showAlert: showAlertFunction } } declare module 'cozy-ui/transpiled/react/providers/Breakpoints' { const useBreakpoints: () => { isMobile: boolean isTablet: boolean isDesktop: boolean } export default useBreakpoints } declare module 'models/index' { export const CozyFile: { splitFilename: (file: IOCozyFile) => { filename: string; extension: string } } } declare module 'cozy-client/dist/models/file' { export const splitFilename: (file: IOCozyFile) => { filename: string extension: string } export const isFile: (file: IOCozyFile) => boolean export const isDirectory: ( file: import('components/FolderPicker/types').File ) => boolean export const isOnlyOfficeFile: ( file: import('components/FolderPicker/types').File ) => boolean export const isShortcut: ( file: import('components/FolderPicker/types').File ) => boolean export const isNote: ( file: import('components/FolderPicker/types').File ) => boolean export const isDocs: ( file: import('components/FolderPicker/types').File ) => boolean export const shouldBeOpenedByOnlyOffice: ( file: import('components/FolderPicker/types').File ) => boolean export const getFullpath: ( client: import('cozy-client/types/CozyClient').CozyClient, dirID: string, filename: string, driveId: string ) => Promise } declare module 'cozy-client/dist/models/note' { export const fetchURL: ( client: import('cozy-client/types/CozyClient').CozyClient, file: { id: string }, options: { pathname: string } ) => Promise } declare module 'cozy-client/dist/models/instance' { export const buildPremiumLink: (instanceInfo: InstanceInfo) => string } declare module 'cozy-ui-plus/dist/Paywall' { export const AiAssistantPaywall: React.ComponentType<{ onClose: () => void }> } declare module '*.svg' { import { FC, SVGProps } from 'react' const content: FC> export default content } declare module 'cozy-ui/transpiled/react/ActionsMenu/Actions' { export interface Action { name: string label?: string icon: React.ComponentType | string displayInSelectionBar?: boolean displayCondition?: ( docs: import('cozy-client/types/types').IOCozyFile[] ) => boolean disabled?: (docs: import('cozy-client/types/types').IOCozyFile[]) => boolean action?: ( docs: T[], opts: { handleAction: HandleActionCallback } ) => Promise | void Component: ForwardRefExoticComponent> } export function divider(): Action export function makeActions( arg1: (((props?: T) => Action) | boolean)[], T ): Record[] } declare module 'cozy-sharing' { export const useSharingContext: () => { allLoaded: boolean refresh: () => void } export const useNativeFileSharing: () => { isNativeFileSharingAvailable: boolean shareFilesNative: ( files: import('cozy-client/types/CozyClient').CozyClient[] ) => void } export const shareNative: (props?: T) => Action } declare module 'cozy-ui/transpiled/react/Nav' { export const NavIcon: React.ComponentType<{ icon: string | React.ComponentType }> export const NavText: React.ComponentType export const NavItem: React.ComponentType export const NavLink: { className: string; activeClassName: string } } declare module 'cozy-ui/transpiled/react/Typography' { const Typography: React.ComponentType<{ variant?: string color?: string noWrap?: boolean className?: string }> export default Typography } declare module 'cozy-keys-lib' { export const useVaultClient: () => object } declare module '*.styl' { const content: Record export default content } declare module 'cozy-realtime' { export default class CozyRealtime { constructor(options: { client: import('cozy-client').default sharedDriveId?: string }) subscribe: ( event: string, doctype: string, callback: () => void | Promise ) => void unsubscribe: ( event: string, doctype: string, callback: () => void | Promise ) => void stop: () => void } } declare module 'cozy-viewer/dist/Panel/AI/AIAssistantPanel' { const AIAssistantPanel: React.ComponentType<{ className?: string }> export default AIAssistantPanel } declare module 'cozy-viewer/dist/hoc/withViewerLocales' { const withViewerLocales:

( Component: React.ComponentType

) => React.ComponentType

export { withViewerLocales } } declare module 'cozy-viewer/dist/providers/ViewerProvider' { export const useViewer: () => { isOpenAiAssistant: boolean } } ================================================ FILE: src/hooks/helpers.d.ts ================================================ export declare function changeLocation(url: string): void export declare function displayedFolderOrRootFolder(displayedFolder: unknow): { id: string } export declare function isEditableTarget(target: EventTarget | null): boolean export declare function shouldBlockKeyboardShortcuts( target: EventTarget | null ): boolean export declare function normalizeKey( event: KeyboardEvent, isApple: boolean ): string ================================================ FILE: src/hooks/helpers.js ================================================ import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config' /** * This helper function is used to change the location of the current window * This main purpose is to help for testing * @param {string} url - The url to change the location to */ export const changeLocation = url => { window.location = url } /** * Returns displayed folder or root folder if no display folder (like in recent or sharing) * or if trash folder * @param {object} displayedFolder * @returns {object} */ export const displayedFolderOrRootFolder = displayedFolder => !displayedFolder || displayedFolder._id === TRASH_DIR_ID ? { id: ROOT_DIR_ID } : displayedFolder /** * Check if targeted element can editable * @param {EventTarget | null} target * @returns {boolean} */ export const isEditableTarget = target => target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable) /** * Check if targeted element can editable except checkbox * @param {EventTarget | null} target * @returns {boolean} */ export const shouldBlockKeyboardShortcuts = target => { if (!target || !(target instanceof HTMLElement)) return false const tag = target.tagName.toLowerCase() const type = target.getAttribute('type')?.toLowerCase() if ( tag === 'input' && type !== 'checkbox' && !target.readOnly && !target.disabled ) { return true } if (tag === 'textarea' && !target.readOnly && !target.disabled) { return true } if (target.isContentEditable) { return true } return false } /** * Normalize shortcut keys * @param {KeyboardEvent} event * @param {boolean} isApple * @returns {string} */ export const normalizeKey = (event, isApple) => { const keys = [] if (isApple ? event.metaKey : event.ctrlKey) keys.push('Ctrl') const key = event.key.toLowerCase() if (key === 'delete' || key === 'del' || (isApple && key === 'backspace')) { keys.push('delete') } else { keys.push(key) } return keys.join('+') } ================================================ FILE: src/hooks/index.js ================================================ export { default as useCurrentFileId } from './useCurrentFileId' export { default as useCurrentFolderId } from './useCurrentFolderId' export { default as useDisplayedFolder } from './useDisplayedFolder' export { default as useParentFolder } from './useParentFolder' export { useRedirectLink } from './useRedirectLink' export { useFolderSort } from './useFolderSort' export { useRecentIcons, addRecentIcon } from './useRecentIcons' ================================================ FILE: src/hooks/useCurrentFileId.jsx ================================================ import { useParams } from 'react-router-dom' const useCurrentFileId = () => { const { fileId } = useParams() if (fileId) { return fileId } return null } export default useCurrentFileId ================================================ FILE: src/hooks/useCurrentFileId.spec.jsx ================================================ import ReactRouter from 'react-router-dom' import useCurrentFileId from './useCurrentFileId' jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: jest.fn() })) describe('useCurrentFileId', () => { it('should return file id if in params', () => { jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ fileId: 'file-id' }) const currentFileId = useCurrentFileId() expect(currentFileId).toBe('file-id') }) it('should return null id if not in params', () => { jest.spyOn(ReactRouter, 'useParams').mockReturnValue({}) const currentFileId = useCurrentFileId() expect(currentFileId).toBe(null) }) }) ================================================ FILE: src/hooks/useCurrentFolderId.jsx ================================================ import { useParams, useLocation } from 'react-router-dom' import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config' const useCurrentFolderId = () => { const { folderId } = useParams() const { pathname = '' } = useLocation() if (folderId) { return folderId } else if (pathname.startsWith('/folder/io.cozy.files.shared-drives-dir')) { return 'io.cozy.files.shared-drives-dir' } else if (pathname === '/folder') { return ROOT_DIR_ID } else if (pathname === '/trash') { return TRASH_DIR_ID } return null } export default useCurrentFolderId ================================================ FILE: src/hooks/useCurrentFolderId.spec.jsx ================================================ import ReactRouter from 'react-router-dom' import useCurrentFolderId from './useCurrentFolderId' import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config' jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: jest.fn(), useLocation: jest.fn() })) describe('useCurrentFolderId', () => { it('should return file id if in params', () => { jest .spyOn(ReactRouter, 'useParams') .mockReturnValue({ folderId: 'folder-id' }) jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({}) const currentFolderId = useCurrentFolderId() expect(currentFolderId).toBe('folder-id') }) it('should return ROOT_DIR_ID if in /folder', () => { jest.spyOn(ReactRouter, 'useParams').mockReturnValue({}) jest .spyOn(ReactRouter, 'useLocation') .mockReturnValue({ pathname: '/folder' }) const currentFolderId = useCurrentFolderId() expect(currentFolderId).toBe(ROOT_DIR_ID) }) it('should return TRASH_DIR_ID if in /trash', () => { jest.spyOn(ReactRouter, 'useParams').mockReturnValue({}) jest .spyOn(ReactRouter, 'useLocation') .mockReturnValue({ pathname: '/trash' }) const currentFolderId = useCurrentFolderId() expect(currentFolderId).toBe(TRASH_DIR_ID) }) it('should return io.cozy.files.shared-drives-dir if in /folder/io.cozy.files.shared-drives-dir', () => { jest.spyOn(ReactRouter, 'useParams').mockReturnValue({}) jest .spyOn(ReactRouter, 'useLocation') .mockReturnValue({ pathname: '/folder/io.cozy.files.shared-drives-dir' }) const currentFolderId = useCurrentFolderId() expect(currentFolderId).toBe('io.cozy.files.shared-drives-dir') }) it('should return null', () => { jest.spyOn(ReactRouter, 'useParams').mockReturnValue({}) jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({}) const currentFolderId = useCurrentFolderId() expect(currentFolderId).toBe(null) }) }) ================================================ FILE: src/hooks/useDebounce.jsx ================================================ import { useEffect, useState } from 'react' const useDebounce = (value, { delay, ignore }) => { const [debouncedValue, setDebouncedValue] = useState(value) useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect if (ignore) return setDebouncedValue(value) const handler = setTimeout(() => { setDebouncedValue(value) }, delay) return () => { clearTimeout(handler) } }, [value, delay, ignore]) return debouncedValue } export default useDebounce ================================================ FILE: src/hooks/useDisplayedFolder.spec.jsx ================================================ import { useQuery } from 'cozy-client' import useCurrentFolderId from './useCurrentFolderId' import useDisplayedFolder from './useDisplayedFolder' import { ROOT_DIR_ID } from '@/constants/config' jest.mock('cozy-client', () => ({ ...jest.requireActual('cozy-client'), useQuery: jest.fn() })) jest.mock('./useCurrentFolderId') describe('useDisplayedFolder', () => { it('should return file folder if current folder exists', () => { const FOLDER = { id: 'folder-id', name: 'Folder name' } useQuery.mockReturnValue({ data: FOLDER }) useCurrentFolderId.mockReturnValue(FOLDER.id) const { displayedFolder } = useDisplayedFolder() expect(displayedFolder).toBe(FOLDER) }) it("should return root dir if current folder isn't found", () => { const FOLDER = { id: ROOT_DIR_ID, name: 'Root' } useQuery.mockReturnValue({ data: FOLDER }) useCurrentFolderId.mockReturnValue(null) const { displayedFolder } = useDisplayedFolder() expect(displayedFolder).toBe(FOLDER) }) }) ================================================ FILE: src/hooks/useDisplayedFolder.tsx ================================================ import { useQuery } from 'cozy-client' import { IOCozyFile } from 'cozy-client/types/types' import { ROOT_DIR_ID } from '@/constants/config' import useCurrentFolderId from '@/hooks/useCurrentFolderId' import { buildFileOrFolderByIdQuery } from '@/queries' interface DisplayedFolderResult { isNotFound: boolean displayedFolder: IOCozyFile | null initialDirId: string | null } const useDisplayedFolder = (): DisplayedFolderResult => { const folderId = useCurrentFolderId() ?? ROOT_DIR_ID const folderQuery = buildFileOrFolderByIdQuery(folderId) const folderResult = useQuery( folderQuery.definition, folderQuery.options ) as unknown as { data?: IOCozyFile | null fetchStatus: string lastError: { status: number } } const displayedFolder = folderResult.data ?? null const initialDirId = displayedFolder?.id ?? null if (folderId) { const isNotFound = folderResult.fetchStatus === 'failed' && folderResult.lastError.status === 404 return { isNotFound, displayedFolder, initialDirId } } return { isNotFound: true, displayedFolder: null, initialDirId: null } } export default useDisplayedFolder ================================================ FILE: src/hooks/useFolderSort/index.spec.jsx ================================================ import { renderHook, act, waitFor } from '@testing-library/react' import { useClient } from 'cozy-client' import flag from 'cozy-flags' import { useFolderSort } from './index' import { DEFAULT_SORT, SORT_BY_UPDATE_DATE } from '@/config/sort' import { TRASH_DIR_ID } from '@/constants/config' import { DOCTYPE_DRIVE_SETTINGS } from '@/lib/doctypes' import logger from '@/lib/logger' import { usePublicContext } from '@/modules/public/PublicProvider' jest.mock('cozy-client', () => ({ useClient: jest.fn(), Q: jest.fn().mockReturnValue('mocked-query'), useQuery: jest.fn() })) jest.mock('cozy-flags', () => jest.fn()) jest.mock('@/lib/logger', () => ({ warn: jest.fn(), info: jest.fn(), error: jest.fn() })) jest.mock('@/modules/public/PublicProvider', () => ({ usePublicContext: jest.fn() })) const mockUseClient = useClient const mockFlag = flag const mockUsePublicContext = usePublicContext describe('useFolderSort', () => { let mockClient let consoleErrorSpy beforeEach(() => { jest.clearAllMocks() consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) mockClient = { save: jest.fn().mockResolvedValue({}), query: jest.fn().mockResolvedValue({ data: [] }) } mockUseClient.mockReturnValue(mockClient) mockFlag.mockImplementation(flagName => { if (flagName === 'drive.save-sort-choice.enabled') { return true } return false }) mockUsePublicContext.mockReturnValue({ isPublic: false }) }) afterEach(() => { consoleErrorSpy.mockRestore() }) describe('default sort behavior', () => { it('should use DEFAULT_SORT for regular folders', () => { const folderId = 'regular-folder-id' mockClient.query.mockResolvedValue({ data: [] }) const { result } = renderHook(() => useFolderSort(folderId)) const [currentSort] = result.current expect(currentSort).toEqual(DEFAULT_SORT) }) it('should use SORT_BY_UPDATE_DATE for trash folder', () => { const folderId = TRASH_DIR_ID mockClient.query.mockResolvedValue({ data: [] }) const { result } = renderHook(() => useFolderSort(folderId)) const [currentSort] = result.current expect(currentSort).toEqual(SORT_BY_UPDATE_DATE) }) it('should use SORT_BY_UPDATE_DATE for recent folder', () => { const folderId = 'recent' mockClient.query.mockResolvedValue({ data: [] }) const { result } = renderHook(() => useFolderSort(folderId)) const [currentSort] = result.current expect(currentSort).toEqual(SORT_BY_UPDATE_DATE) }) }) describe('loading existing sorting settings', () => { it('should load and apply existing sorting settings when available', async () => { const folderId = 'test-folder' const existingSettings = { _id: 'settings-id', _type: DOCTYPE_DRIVE_SETTINGS, attributes: { attribute: 'updated_at', order: 'desc' } } mockClient.query.mockResolvedValue({ data: [existingSettings] }) const { result } = renderHook(() => useFolderSort(folderId)) await waitFor(() => expect(result.current[2]).toBe(true)) await waitFor(() => expect(result.current[0]).toEqual({ attribute: 'updated_at', order: 'desc' }) ) const [currentSort] = result.current expect(currentSort).toEqual({ attribute: 'updated_at', order: 'desc' }) }) it('should use default values when settings exist but attributes are missing', () => { const folderId = 'test-folder' const existingSettings = { _id: 'settings-id', _type: DOCTYPE_DRIVE_SETTINGS } mockClient.query.mockResolvedValue({ data: [existingSettings] }) const { result } = renderHook(() => useFolderSort(folderId)) const [currentSort] = result.current expect(currentSort).toEqual(DEFAULT_SORT) }) it('should return consistent sort values on multiple renders', async () => { const folderId = 'test-folder' const existingSettings = { _id: 'settings-id', _type: DOCTYPE_DRIVE_SETTINGS, attributes: { attribute: 'name', order: 'asc' } } mockClient.query.mockResolvedValue({ data: [existingSettings] }) const { result, rerender } = renderHook(() => useFolderSort(folderId)) await waitFor(() => expect(result.current[2]).toBe(true)) await waitFor(() => expect(result.current[0]).toEqual({ attribute: 'name', order: 'asc' }) ) const [firstSort] = result.current expect(firstSort).toEqual({ attribute: 'name', order: 'asc' }) rerender() const [secondSort] = result.current expect(secondSort).toEqual({ attribute: 'name', order: 'asc' }) }) }) describe('persisting settings', () => { it('should persist new sorting settings when no existing settings', async () => { const folderId = 'test-folder' const newSort = { attribute: 'updated_at', order: 'desc' } mockClient.query.mockResolvedValue({ data: [] }) const { result } = renderHook(() => useFolderSort(folderId)) const [, setSortOrder] = result.current await act(async () => { await setSortOrder(newSort) }) expect(mockClient.save).toHaveBeenCalledWith({ _type: DOCTYPE_DRIVE_SETTINGS, attributes: { ...DEFAULT_SORT, attribute: 'updated_at', order: 'desc' } }) expect(logger.info).toHaveBeenCalledWith( 'Sort settings persisted', newSort ) }) it('should update existing sorting settings', async () => { const folderId = 'test-folder' const existingSettings = { _id: 'settings-id', _type: DOCTYPE_DRIVE_SETTINGS, attributes: { attribute: 'name', order: 'asc' } } const newSort = { attribute: 'updated_at', order: 'desc' } mockClient.query.mockResolvedValue({ data: [existingSettings] }) const { result } = renderHook(() => useFolderSort(folderId)) // Wait for the settings to load await waitFor(() => expect(result.current[2]).toBe(true)) await waitFor(() => expect(result.current[0]).toEqual({ attribute: 'name', order: 'asc' }) ) const [, setSortOrder] = result.current await act(async () => { await setSortOrder(newSort) }) expect(mockClient.save).toHaveBeenCalledWith({ ...existingSettings, attributes: { attribute: 'updated_at', order: 'desc' } }) expect(logger.info).toHaveBeenCalledWith( 'Sort settings persisted', newSort ) }) it('should handle save errors gracefully', async () => { const folderId = 'test-folder' const newSort = { attribute: 'updated_at', order: 'desc' } const saveError = new Error('Save failed') mockClient.save.mockRejectedValue(saveError) mockClient.query.mockResolvedValue({ data: [] }) const { result } = renderHook(() => useFolderSort(folderId)) const [, setSortOrder] = result.current await act(async () => { await setSortOrder(newSort) }) expect(logger.error).toHaveBeenCalledWith( 'Failed to save sorting preference:', saveError ) }) }) describe('public context behavior', () => { it('should not load settings in public view', async () => { const folderId = 'test-folder' const existingSettings = { _id: 'settings-id', _type: DOCTYPE_DRIVE_SETTINGS, attributes: { attribute: 'updated_at', order: 'desc' } } mockUsePublicContext.mockReturnValue({ isPublic: true }) mockClient.query.mockResolvedValue({ data: [existingSettings] }) const { result } = renderHook(() => useFolderSort(folderId)) const [currentSort] = result.current expect(currentSort).toEqual(DEFAULT_SORT) expect(mockClient.query).not.toHaveBeenCalled() }) it('should not persist settings in public view', async () => { const folderId = 'test-folder' const newSort = { attribute: 'updated_at', order: 'desc' } mockUsePublicContext.mockReturnValue({ isPublic: true }) const { result } = renderHook(() => useFolderSort(folderId)) const [, setSortOrder] = result.current await act(async () => { await setSortOrder(newSort) }) expect(logger.warn).toHaveBeenCalledWith( 'Cannot persist sort: in public view' ) expect(mockClient.save).not.toHaveBeenCalled() expect(mockClient.query).not.toHaveBeenCalled() }) }) }) ================================================ FILE: src/hooks/useFolderSort/index.ts ================================================ import { useCallback, useEffect, useState } from 'react' import { useClient, Q } from 'cozy-client' import flag from 'cozy-flags' import { DEFAULT_SORT, SORT_BY_UPDATE_DATE } from '@/config/sort' import { RECENT_FOLDER_ID, TRASH_DIR_ID } from '@/constants/config' import { DOCTYPE_DRIVE_SETTINGS } from '@/lib/doctypes' import logger from '@/lib/logger' import { usePublicContext } from '@/modules/public/PublicProvider' export interface Sort { attribute: string order: string } interface DriveSettings { _type?: string attributes: Sort } interface QueryResult { data?: DriveSettings[] fetchStatus?: string } const useFolderSort = ( folderId: string ): [Sort, (props: Sort) => void, boolean] => { const defaultSort: Sort = folderId === TRASH_DIR_ID || folderId === RECENT_FOLDER_ID ? SORT_BY_UPDATE_DATE : DEFAULT_SORT const client = useClient() const { isPublic } = usePublicContext() const [isSettingsLoaded, setIsSettingsLoaded] = useState(false) const [currentSort, setCurrentSort] = useState(defaultSort) const [isSaving, setIsSaving] = useState(false) useEffect(() => { const load = async (): Promise => { if (!client || !flag('drive.save-sort-choice.enabled') || isPublic) { setIsSettingsLoaded(true) return } try { const { data } = (await client.query( Q(DOCTYPE_DRIVE_SETTINGS) )) as QueryResult if (!data?.length) return setCurrentSort(data[0]?.attributes) } catch (error) { logger.error('Failed to load settings:', error) } finally { setIsSettingsLoaded(true) } } void load() }, [client, isPublic]) const setSortOrder = useCallback( async ({ attribute, order }: Sort) => { setCurrentSort({ attribute, order }) if (!flag('drive.save-sort-choice.enabled')) { logger.warn( 'Cannot persist sort: flag drive.save-sort-choice.enabled is not enabled' ) return } if (!client) { logger.warn('Cannot persist sort: client unavailable') return } if (isPublic) { logger.warn('Cannot persist sort: in public view') return } if (isSaving) { logger.warn('Cannot persist sort: already saving') return } setIsSaving(true) try { const { data } = (await client.query( Q(DOCTYPE_DRIVE_SETTINGS) )) as QueryResult const settingsToSave: DriveSettings = data?.length ? { ...data[0], attributes: { attribute, order } } : { _type: DOCTYPE_DRIVE_SETTINGS, attributes: { attribute, order } } await client.save(settingsToSave) logger.info('Sort settings persisted', { attribute, order }) } catch (error) { logger.error('Failed to save sorting preference:', error) } finally { setIsSaving(false) } }, [client, isSaving, isPublic, setIsSaving] ) return [currentSort, setSortOrder, isSettingsLoaded] } export { useFolderSort } ================================================ FILE: src/hooks/useKeyboardShortcuts.spec.jsx ================================================ import '@testing-library/jest-dom' import { renderHook, act } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import { createStore } from 'redux' jest.mock('cozy-client/dist/models/file', () => ({ isFile: jest.fn() })) jest.mock('cozy-ui/transpiled/react/providers/Alert', () => ({ useAlert: jest.fn() })) jest.mock('twake-i18n', () => ({ useI18n: jest.fn(), translate: jest.fn(key => key), createUseI18n: jest.fn(() => () => ({ t: key => key })), I18nProvider: ({ children }) => children, withOnlyLocales: jest.fn(() => Component => Component), withLocales: jest.fn(() => Component => Component), useExtendI18n: jest.fn() })) jest.mock('./helpers', () => ({ isEditableTarget: jest.fn(), shouldBlockKeyboardShortcuts: jest.fn(), normalizeKey: jest.fn() })) jest.mock('@/components/pushClient', () => ({ isMacOS: jest.fn() })) jest.mock('@/contexts/ClipboardProvider', () => ({ useClipboardContext: jest.fn() })) jest.mock('@/hooks', () => ({ useDisplayedFolder: jest.fn() })) jest.mock('@/modules/drive/rename', () => ({ startRenamingAsync: jest.fn() })) jest.mock('@/modules/nextcloud/hooks/useNextcloudCurrentFolder', () => ({ useNextcloudCurrentFolder: jest.fn() })) jest.mock('@/modules/paste', () => ({ handlePasteOperation: jest.fn() })) jest.mock('@/modules/selection/SelectionProvider', () => ({ useSelectionContext: jest.fn() })) jest.mock('cozy-flags', () => jest.fn()) jest.mock('cozy-sharing', () => ({ SharedDocument: ({ children }) => children({ isSharedByMe: false, link: null, recipients: [] }), SharedRecipientsList: () => null, withLocales: component => component })) jest.mock('@/modules/upload/NewItemHighlightProvider', () => ({ useNewItemHighlightContext: jest.fn(() => ({ addItems: jest.fn() })) })) import { isFile } from 'cozy-client/dist/models/file' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { shouldBlockKeyboardShortcuts, normalizeKey } from './helpers' import { useKeyboardShortcuts } from './useKeyboardShortcuts.tsx' import { isMacOS } from '@/components/pushClient' import { OPERATION_COPY, OPERATION_CUT, useClipboardContext } from '@/contexts/ClipboardProvider' import { useDisplayedFolder } from '@/hooks' import { startRenamingAsync } from '@/modules/drive/rename' import { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder' import { handlePasteOperation } from '@/modules/paste' import { useSelectionContext } from '@/modules/selection/SelectionProvider' describe('useKeyboardShortcuts', () => { let mockDispatch let mockShowAlert let mockT let mockCopyFiles let mockCutFiles let mockClearClipboard let mockSelectAll let mockClearSelection let mockHideSelectionBar let mockShowMoveValidationModal let mockOnPaste let mockClient let mockCurrentFolder let mockSelectedItems let mockItems let store const createWrapper = () => { const mockReducer = (state = {}) => state store = createStore(mockReducer) return ({ children }) => {children} } beforeEach(() => { mockT = jest.fn((key, options) => { if (options && options.count !== undefined) { return `${key}_${options.count}` } return key }) mockCopyFiles = jest.fn() mockCutFiles = jest.fn() mockClearClipboard = jest.fn() mockSelectAll = jest.fn() mockClearSelection = jest.fn() mockHideSelectionBar = jest.fn() mockShowMoveValidationModal = jest.fn() mockOnPaste = jest.fn() mockDispatch = jest.fn() mockShowAlert = jest.fn() mockClient = { save: jest.fn(), query: jest.fn(), collection: jest.fn() } mockCurrentFolder = { _id: 'current-folder-id', name: 'Current Folder' } mockSelectedItems = [ { _id: 'file1', name: 'test1.txt', type: 'file', dir_id: 'parent-folder-1' }, { _id: 'file2', name: 'test2.txt', type: 'file', dir_id: 'parent-folder-2' } ] mockItems = [ { _id: 'file1', name: 'test1.txt', type: 'file' }, { _id: 'file2', name: 'test2.txt', type: 'file' }, { _id: 'folder1', name: 'Test Folder', type: 'directory' } ] jest .spyOn(require('react-redux'), 'useDispatch') .mockReturnValue(mockDispatch) useAlert.mockReturnValue({ showAlert: mockShowAlert }) useI18n.mockReturnValue({ t: mockT }) useClipboardContext.mockReturnValue({ clipboardData: { files: [{ _id: 'clipboard-file', name: 'clipboard.txt' }], operation: OPERATION_COPY, sourceFolderIds: new Set(['source-folder-id']) }, copyFiles: mockCopyFiles, cutFiles: mockCutFiles, clearClipboard: mockClearClipboard, hasClipboardData: true, showMoveValidationModal: mockShowMoveValidationModal }) useSelectionContext.mockReturnValue({ selectedItems: mockSelectedItems, selectAll: mockSelectAll, hideSelectionBar: mockHideSelectionBar, clearSelection: mockClearSelection, isSelectAll: false }) useDisplayedFolder.mockReturnValue({ displayedFolder: mockCurrentFolder }) useNextcloudCurrentFolder.mockReturnValue(mockCurrentFolder) isFile.mockReturnValue(true) shouldBlockKeyboardShortcuts.mockReturnValue(false) normalizeKey.mockImplementation((event, isApple) => { const key = event.key.toLowerCase() const ctrl = isApple ? event.metaKey : event.ctrlKey if (ctrl && key === 'c') return 'Ctrl+c' if (ctrl && key === 'x') return 'Ctrl+x' if (ctrl && key === 'v') return 'Ctrl+v' if (ctrl && key === 'a') return 'Ctrl+a' if (key === 'f2') return 'f2' if (key === 'escape') return 'escape' if (key === 'delete') return 'delete' return key }) isMacOS.mockReturnValue(false) handlePasteOperation.mockResolvedValue([ { success: true, file: { _id: 'pasted-file' }, operation: OPERATION_COPY } ]) mockCopyFiles.mockClear() mockCutFiles.mockClear() mockClearClipboard.mockClear() mockSelectAll.mockClear() mockClearSelection.mockClear() mockHideSelectionBar.mockClear() mockShowMoveValidationModal.mockClear() mockOnPaste.mockClear() mockDispatch.mockClear() mockShowAlert.mockClear() handlePasteOperation.mockClear() }) describe('Copy Operations (Ctrl+C / Cmd+C)', () => { it('should copy selected files when Ctrl+C is pressed', () => { const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems, allowCopy: true }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockCopyFiles).toHaveBeenCalledWith( mockSelectedItems, new Set(['parent-folder-1', 'parent-folder-2']) ) expect(mockShowAlert).toHaveBeenCalledWith({ message: 'alert.items_copied_2', severity: 'success' }) expect(mockClearSelection).toHaveBeenCalled() }) it('should show alert when copy is not allowed', () => { const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems, allowCopy: false }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockCopyFiles).not.toHaveBeenCalled() expect(mockShowAlert).toHaveBeenCalledWith({ message: 'alert.copy_not_allowed', severity: 'secondary' }) }) it('should filter only files for copying', () => { isFile.mockImplementation(item => item.type === 'file') const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems, allowCopy: true }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockCopyFiles).toHaveBeenCalledWith( mockSelectedItems.filter(item => item.type === 'file'), new Set(['parent-folder-1', 'parent-folder-2']) ) }) }) describe('Cut Operations (Ctrl+X / Cmd+X)', () => { it('should cut selected items when Ctrl+X is pressed', () => { const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'x', ctrlKey: true, bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockCutFiles).toHaveBeenCalledWith( mockSelectedItems, new Set(['parent-folder-1', 'parent-folder-2']), mockCurrentFolder ) expect(mockShowAlert).toHaveBeenCalledWith({ message: 'alert.items_cut_2', severity: 'success' }) expect(mockClearSelection).toHaveBeenCalled() }) }) describe('Paste Operations (Ctrl+V / Cmd+V)', () => { it('should paste files when Ctrl+V is pressed', async () => { const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems, canPaste: true, onPaste: mockOnPaste }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true }) await act(async () => { document.dispatchEvent(event) }) expect(handlePasteOperation).toHaveBeenCalledWith( mockClient, [{ _id: 'clipboard-file', name: 'clipboard.txt' }], OPERATION_COPY, undefined, mockCurrentFolder, { sharingContext: null, showAlert: mockShowAlert, showMoveValidationModal: mockShowMoveValidationModal, t: mockT, isPublic: false } ) expect(mockShowAlert).toHaveBeenCalledWith({ message: 'alert.item_pasted', severity: 'success' }) expect(mockOnPaste).toHaveBeenCalled() }) it('should clear clipboard after cut operation', async () => { useClipboardContext.mockReturnValue({ clipboardData: { files: [{ _id: 'clipboard-file', name: 'clipboard.txt' }], operation: OPERATION_CUT, sourceFolderIds: new Set(['source-folder-id']) }, copyFiles: mockCopyFiles, cutFiles: mockCutFiles, clearClipboard: mockClearClipboard, hasClipboardData: true, showMoveValidationModal: mockShowMoveValidationModal }) const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems, canPaste: true }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true }) await act(async () => { document.dispatchEvent(event) }) expect(mockClearClipboard).toHaveBeenCalled() }) it('should skip paste when cutting and pasting in same folder', async () => { useClipboardContext.mockReturnValue({ clipboardData: { files: [ { _id: 'clipboard-file', name: 'clipboard.txt' } ], operation: OPERATION_CUT, sourceFolderIds: new Set(['current-folder-id']) }, copyFiles: mockCopyFiles, cutFiles: mockCutFiles, clearClipboard: mockClearClipboard, hasClipboardData: true, showMoveValidationModal: mockShowMoveValidationModal }) const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems, canPaste: true }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true }) await act(async () => { document.dispatchEvent(event) }) expect(mockShowAlert).toHaveBeenCalledWith({ message: 'alert.paste_same_folder_skipped', severity: 'secondary' }) expect(handlePasteOperation).not.toHaveBeenCalled() }) }) describe('Move with Validation Modals', () => { it('should call showMoveValidationModal during paste operation', async () => { const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems, canPaste: true, sharingContext: { isShared: true } }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true }) await act(async () => { document.dispatchEvent(event) }) expect(handlePasteOperation).toHaveBeenCalledWith( mockClient, expect.any(Array), OPERATION_COPY, undefined, mockCurrentFolder, expect.objectContaining({ sharingContext: { isShared: true }, showMoveValidationModal: mockShowMoveValidationModal }) ) }) }) describe('Select All (Ctrl+A / Cmd+A)', () => { it('should select all items when Ctrl+A is pressed', () => { const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'a', ctrlKey: true, bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockSelectAll).toHaveBeenCalledWith(mockItems) }) it('should clear selection when all items are already selected', () => { useSelectionContext.mockReturnValue({ selectedItems: mockSelectedItems, selectAll: mockSelectAll, hideSelectionBar: mockHideSelectionBar, clearSelection: mockClearSelection, isSelectAll: true }) const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'a', ctrlKey: true, bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockClearSelection).toHaveBeenCalled() expect(mockSelectAll).not.toHaveBeenCalled() }) }) describe('Rename (F2)', () => { it('should start renaming when F2 is pressed with single selection', () => { useSelectionContext.mockReturnValue({ selectedItems: [mockSelectedItems[0]], // Single item selected selectAll: mockSelectAll, hideSelectionBar: mockHideSelectionBar, clearSelection: mockClearSelection, isSelectAll: false }) const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'F2', bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockDispatch).toHaveBeenCalledWith( startRenamingAsync(mockSelectedItems[0]) ) }) it('should not start renaming when multiple items are selected', () => { const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'F2', bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockDispatch).not.toHaveBeenCalled() }) }) describe('the delete shortcut key', () => { it('should show delete confirmation when Delete key is pressed', () => { const mockPushModal = jest.fn() const mockPopModal = jest.fn() const mockRefresh = jest.fn() const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems, pushModal: mockPushModal, popModal: mockPopModal, refresh: mockRefresh }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockPushModal).toHaveBeenCalledWith( expect.objectContaining({ type: expect.any(Function) }) ) }) it('should not show delete confirmation when no items are selected', () => { useSelectionContext.mockReturnValue({ selectedItems: [], selectAll: mockSelectAll, hideSelectionBar: mockHideSelectionBar, clearSelection: mockClearSelection, isSelectAll: false }) const mockPushModal = jest.fn() const mockPopModal = jest.fn() const mockRefresh = jest.fn() const wrapper = createWrapper() renderHook( () => useKeyboardShortcuts({ client: mockClient, items: mockItems, pushModal: mockPushModal, popModal: mockPopModal, refresh: mockRefresh }), { wrapper } ) const event = new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockPushModal).not.toHaveBeenCalled() }) }) describe('Shared Drive Operations', () => { const sharedDriveFiles = [ { _id: 'shared-file-1', name: 'shared-doc.pdf', type: 'file', dir_id: 'shared-folder-1', driveId: 'shared-drive-123' } ] const sharedDriveFolder = { _id: 'shared-folder-1', name: 'Shared Folder', type: 'directory', driveId: 'shared-drive-456' } beforeEach(() => { // Reset all mocks shouldBlockKeyboardShortcuts.mockReturnValue(false) isFile.mockReturnValue(true) useDisplayedFolder.mockReturnValue({ displayedFolder: sharedDriveFolder }) useSelectionContext.mockReturnValue({ selectedItems: sharedDriveFiles, selectAll: mockSelectAll, clearSelection: mockClearSelection, isSelectionBarVisible: false }) }) it('should copy shared drive files when Ctrl+C is pressed', () => { const wrapper = createWrapper() renderHook(() => useKeyboardShortcuts({ onPaste: mockOnPaste }), { wrapper }) const event = new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockCopyFiles).toHaveBeenCalledWith( sharedDriveFiles, new Set(['shared-folder-1']) ) expect(mockShowAlert).toHaveBeenCalledWith({ message: 'alert.item_copied', severity: 'success' }) expect(mockClearSelection).toHaveBeenCalled() }) it('should cut shared drive files when Ctrl+X is pressed', () => { useDisplayedFolder.mockReturnValue({ displayedFolder: sharedDriveFolder }) const wrapper = createWrapper() renderHook(() => useKeyboardShortcuts({ onPaste: mockOnPaste }), { wrapper }) const event = new KeyboardEvent('keydown', { key: 'x', ctrlKey: true, bubbles: true }) act(() => { document.dispatchEvent(event) }) expect(mockCutFiles).toHaveBeenCalledWith( sharedDriveFiles, new Set(['shared-folder-1']), sharedDriveFolder ) expect(mockShowAlert).toHaveBeenCalledWith({ message: 'alert.item_cut', severity: 'success' }) expect(mockClearSelection).toHaveBeenCalled() }) it('should handle paste operations with shared drive folders', async () => { // Test that handlePasteOperation can be called with shared drive folder // This verifies the integration works correctly await handlePasteOperation( mockClient, [{ _id: 'regular-file', name: 'regular.txt' }], 'copy', null, sharedDriveFolder, { sharingContext: null, showAlert: mockShowAlert, showMoveValidationModal: mockShowMoveValidationModal, t: mockT } ) // Verify that the function was called with shared drive folder expect(handlePasteOperation).toHaveBeenCalledWith( mockClient, [{ _id: 'regular-file', name: 'regular.txt' }], 'copy', null, expect.objectContaining({ _id: 'shared-folder-1', driveId: 'shared-drive-456' }), expect.any(Object) ) }) }) }) ================================================ FILE: src/hooks/useKeyboardShortcuts.tsx ================================================ import React, { useEffect, useCallback } from 'react' import { useDispatch } from 'react-redux' import { isFile } from 'cozy-client/dist/models/file' import CozyClient from 'cozy-client/types/CozyClient' import { IOCozyFile } from 'cozy-client/types/types' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { shouldBlockKeyboardShortcuts, normalizeKey } from './helpers' import { isMacOS } from '@/components/pushClient' import { SHARED_DRIVES_DIR_ID } from '@/constants/config' import { useClipboardContext, OPERATION_CUT } from '@/contexts/ClipboardProvider' import { useDisplayedFolder } from '@/hooks' import DeleteConfirm from '@/modules/drive/DeleteConfirm' import { startRenamingAsync } from '@/modules/drive/rename' import { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder' import { handlePasteOperation } from '@/modules/paste' import { useSelectionContext } from '@/modules/selection/SelectionProvider' import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider' // Type for the result returned by copy/move operations from cozy-client interface PasteResultFile { data?: IOCozyFile moved?: IOCozyFile } interface PasteOperationResult { success: boolean file: PasteResultFile | IOCozyFile error?: Error operation: string } interface UseKeyboardShortcutsProps { onPaste?: (() => void) | null canPaste?: boolean client?: CozyClient | null items?: IOCozyFile[] sharingContext?: unknown allowCopy?: boolean allowCut?: boolean allowDelete?: boolean isNextCloudFolder?: boolean isPublic?: boolean pushModal?: (modal: React.ReactElement) => void popModal?: () => void refresh?: () => void } export const useKeyboardShortcuts = ({ onPaste = null, canPaste = false, client = null, items = [], sharingContext = null, allowCopy = true, allowCut = true, allowDelete = true, isNextCloudFolder = false, isPublic = false, pushModal, popModal, refresh }: UseKeyboardShortcutsProps): void => { const dispatch = useDispatch() const { t } = useI18n() const { showAlert } = useAlert() const { selectedItems, selectAll, hideSelectionBar, clearSelection, isSelectAll } = useSelectionContext() as unknown as { selectedItems: IOCozyFile[] selectAll: (items: IOCozyFile[]) => void hideSelectionBar: () => void clearSelection: () => void isSelectAll: boolean } const { clipboardData, copyFiles, cutFiles, clearClipboard, hasClipboardData, showMoveValidationModal } = useClipboardContext() const { addItems } = useNewItemHighlightContext() as { addItems: (items: IOCozyFile[]) => void } const { displayedFolder } = useDisplayedFolder() const currentNextCloudFolder = useNextcloudCurrentFolder() const currentFolder = isNextCloudFolder ? currentNextCloudFolder : displayedFolder const isApple = isMacOS() const handleCopy = useCallback(() => { if (!allowCopy) { showAlert({ message: t('alert.copy_not_allowed'), severity: 'secondary' }) return } const parentFolderIds = selectedItems.map(item => item.dir_id) if (parentFolderIds.includes(SHARED_DRIVES_DIR_ID)) { showAlert({ message: t('alert.cannot_copy_shared_drive'), severity: 'secondary' }) return } if (!selectedItems.length) return const filesToCopy = selectedItems.filter(isFile) if (filesToCopy.length === 0) { showAlert({ message: t('alert.copy_files_only'), severity: 'secondary' }) return } copyFiles(filesToCopy, new Set(parentFolderIds)) const message = filesToCopy.length === 1 ? t('alert.item_copied') : t('alert.items_copied', { count: filesToCopy.length }) showAlert({ message, severity: 'success' }) clearSelection() }, [allowCopy, selectedItems, copyFiles, showAlert, t, clearSelection]) const handleCut = useCallback(() => { if (!selectedItems.length) return if (!allowCut) { showAlert({ message: t('alert.cut_not_allowed'), severity: 'secondary' }) return } const parentFolderIds = selectedItems.map(item => item.dir_id) if (parentFolderIds.includes(SHARED_DRIVES_DIR_ID)) { showAlert({ message: t('alert.cannot_move_shared_drive'), severity: 'secondary' }) return } cutFiles( selectedItems, new Set(parentFolderIds), currentFolder as IOCozyFile ) const message = selectedItems.length === 1 ? t('alert.item_cut') : t('alert.items_cut', { count: selectedItems.length }) showAlert({ message, severity: 'success' }) clearSelection() }, [ selectedItems, allowCut, currentFolder, cutFiles, t, showAlert, clearSelection ]) const handlePaste = useCallback(async () => { if (!hasClipboardData || !client || !currentFolder) return if (!canPaste) { showAlert({ message: t('alert.paste_not_allowed'), severity: 'secondary' }) return } // Skip operation if cutting and pasting in the same folder if ( clipboardData.operation === OPERATION_CUT && clipboardData.sourceFolderIds?.has(currentFolder._id) ) { showAlert({ message: t('alert.paste_same_folder_skipped'), severity: 'secondary' }) return } try { const results = (await handlePasteOperation( client, clipboardData.files, clipboardData.operation, clipboardData.sourceDirectory, currentFolder, { showAlert, t, sharingContext, showMoveValidationModal, isPublic } )) as PasteOperationResult[] const successCount = results.filter(r => r.success).length const failureCount = results.filter(r => !r.success).length if (successCount > 0) { const message = successCount === 1 ? t('alert.item_pasted') : t('alert.items_pasted', { count: successCount }) showAlert({ message, severity: 'success' }) const successfulFiles = results .filter(r => r.success) .map(r => { const file = r.file if ('data' in file && file.data) { return file.data } if ('moved' in file && file.moved) { return file.moved } return null }) .filter((file): file is IOCozyFile => file !== null) if (successfulFiles.length > 0) { addItems(successfulFiles) } } else if (failureCount > 0) { showAlert({ message: t('alert.paste_failed'), severity: 'error' }) } if (clipboardData.operation === OPERATION_CUT) { clearClipboard() } onPaste?.() } catch (_error) { showAlert({ message: t('alert.paste_error'), severity: 'error' }) } }, [ hasClipboardData, client, currentFolder, canPaste, clipboardData.operation, clipboardData.sourceFolderIds, clipboardData.files, clipboardData.sourceDirectory, showAlert, t, sharingContext, showMoveValidationModal, isPublic, onPaste, clearClipboard, addItems ]) const handleSelectAll = useCallback(() => { if (isSelectAll) { clearSelection() } else { selectAll(items) } }, [isSelectAll, clearSelection, selectAll, items]) const handleRename = useCallback(() => { if (selectedItems.length === 1) { dispatch(startRenamingAsync(selectedItems[0])) } }, [selectedItems, dispatch]) const handleEscape = useCallback(() => { hideSelectionBar() clearClipboard() }, [hideSelectionBar, clearClipboard]) const handleDelete = useCallback(() => { if (!selectedItems.length || !pushModal || !popModal || !refresh) return if (!allowDelete) { showAlert({ message: t('alert.delete_not_allowed'), severity: 'secondary' }) return } const driveId = selectedItems[0]?.driveId pushModal( ) }, [selectedItems, pushModal, popModal, refresh, allowDelete, showAlert, t]) useEffect(() => { const shortcuts: Record void | Promise) | undefined> = { 'Ctrl+c': handleCopy, 'Ctrl+x': handleCut, 'Ctrl+v': handlePaste, 'Ctrl+a': handleSelectAll, f2: handleRename, escape: handleEscape, delete: handleDelete } const handleKeyDown = (event: KeyboardEvent): void => { if (!event.target || shouldBlockKeyboardShortcuts(event.target)) return const combo = normalizeKey(event, isApple) const handler = shortcuts[combo] if (handler) { event.preventDefault() void handler() } } document.addEventListener('keydown', handleKeyDown) return (): void => document.removeEventListener('keydown', handleKeyDown) }, [ isApple, handleCopy, handleCut, handlePaste, handleSelectAll, handleRename, handleEscape, handleDelete ]) } ================================================ FILE: src/hooks/useMoreMenuActions.jsx ================================================ import { useState, useEffect } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { useClient } from 'cozy-client' import { fetchBlobFileById, isFile } from 'cozy-client/dist/models/file' import { useWebviewIntent } from 'cozy-intent' import { useVaultClient } from 'cozy-keys-lib' import { useSharingContext, useNativeFileSharing, shareNative, addToCozySharingLink, syncToCozySharingLink, useSharingInfos } from 'cozy-sharing' import { makeActions, print } from 'cozy-ui/transpiled/react/ActionsMenu/Actions' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { useI18n } from 'twake-i18n' import { useCurrentFolderId } from '@/hooks' import { useModalContext } from '@/lib/ModalContext' import { share, download, trash, versions, hr } from '@/modules/actions' import { addToFavorites } from '@/modules/actions/components/addToFavorites' import { duplicateTo } from '@/modules/actions/components/duplicateTo' import { moveTo } from '@/modules/actions/components/moveTo' import { removeFromFavorites } from '@/modules/actions/components/removeFromFavorites' import { details } from '@/modules/actions/details' import { filterActionsByPolicy } from '@/modules/actions/policies' export const useMoreMenuActions = file => { const [isPrintAvailable, setIsPrintAvailable] = useState(false) const client = useClient() const vaultClient = useVaultClient() const webviewIntent = useWebviewIntent() const { t, lang } = useI18n() const { isMobile } = useBreakpoints() const navigate = useNavigate() const { pushModal, popModal } = useModalContext() const { allLoaded, hasWriteAccess, isOwner, byDocId } = useSharingContext() const { showAlert } = useAlert() const { isNativeFileSharingAvailable, shareFilesNative } = useNativeFileSharing() const currentFolderId = useCurrentFolderId() const { isSharingShortcutCreated, addSharingLink, syncSharingLink } = useSharingInfos() const location = useLocation() const canWriteToCurrentFolder = hasWriteAccess(currentFolderId, file.driveId) const isPDFDoc = file.mime === 'application/pdf' const showPrintAction = isPDFDoc && isPrintAvailable const isCozySharing = window.location.pathname === '/preview' const actions = makeActions( [ share, shareNative, isCozySharing && addToCozySharingLink, isCozySharing && syncToCozySharingLink, download, showPrintAction && print, details, hr, moveTo, duplicateTo, addToFavorites, removeFromFavorites, hr, versions, hr, trash ], { client, t, lang, vaultClient, pushModal, popModal, refresh: () => navigate('..'), navigate, hasWriteAccess: canWriteToCurrentFolder, canMove: canWriteToCurrentFolder, isPublic: false, allLoaded, showAlert, isOwner, byDocId, isNativeFileSharingAvailable, shareFilesNative, isSharingShortcutCreated, openSharingLinkDisplayed: isCozySharing, syncSharingLink, isMobile, fetchBlobFileById, isFile, addSharingLink, driveId: file.driveId, location } ) const filteredActions = filterActionsByPolicy(actions, [file]) useEffect(() => { const init = async () => { const isAvailable = (await webviewIntent?.call('isAvailable', 'print')) ?? true setIsPrintAvailable(isAvailable) } init() }, [webviewIntent]) return filteredActions } ================================================ FILE: src/hooks/useOnLongPress/helpers.js ================================================ const DOUBLECLICKDELAY = 400 export const handleClick = ({ event, file, disabled, isRenaming, openLink, toggle, lastClickTime, setLastClickTime, setSelectedItems, onInteractWithFile, clearHighlightedItems }) => { // if default behavior is opening a file, it blocks that to force other bahavior event.preventDefault() if (disabled || isRenaming) return clearHighlightedItems?.() const currentTime = Date.now() const isDoubleClick = currentTime - lastClickTime < DOUBLECLICKDELAY if (isDoubleClick) { openLink(event) } else if (event.ctrlKey || event.metaKey) { toggle(event) } else { // we should not use file.index // we should probablt not use index - 1 // we should use only one func to set things on click, and not 3 setters setSelectedItems({ [file._id]: file }) } onInteractWithFile?.(file._id, event) setLastClickTime(currentTime) } export const makeDesktopHandlers = ({ file, timerId, disabled, isRenaming, openLink, toggle, selectionModeActive, lastClickTime, setLastClickTime, clearSelection, setSelectedItems, clearHighlightedItems, onInteractWithFile }) => { return { // first event triggered on Desktop onMouseDown: () => clearTimeout(timerId.current), // second event triggered on Desktop onMouseUp: () => clearTimeout(timerId.current), // third event triggered on Desktop onClick: event => handleClick({ event, file, disabled, isRenaming, openLink, toggle, selectionModeActive, lastClickTime, setLastClickTime, clearSelection, setSelectedItems, clearHighlightedItems, onInteractWithFile }) } } export const handlePress = ({ event, disabled, selectionModeActive, isLongPress, isRenaming, openLink, toggle, clearHighlightedItems }) => { // if default behavior is opening a file, it blocks that to force other bahavior event.preventDefault() // isLongPress is to prevent executing onPress twice while a longpress // can happen if button is released quickly just after startPressTimer execution if (disabled || isLongPress.current || isRenaming) return if (selectionModeActive) { toggle(event) } else { openLink(event) } clearHighlightedItems?.() } export const makeMobileHandlers = ({ timerId, disabled, selectionModeActive, isRenaming, isLongPress, openLink, toggle, clearHighlightedItems }) => { // used to determine if it's a longpress // i.e. delay onClick const startPressTimer = e => { e.persist() isLongPress.current = false timerId.current = setTimeout(() => { isLongPress.current = true if (!isRenaming) { toggle(e) } }, 250) } return { // first event triggered on Mobile when taping an item onTouchStart: startPressTimer, // second event triggered on Mobile when dragging an item onTouchMove: () => clearTimeout(timerId.current), // third event triggered on Mobile when taping an item onTouchEnd: () => clearTimeout(timerId.current), // fourth event triggered on Mobile onClick: event => handlePress({ event, disabled, selectionModeActive, isLongPress, isRenaming, openLink, toggle, clearHighlightedItems }) } } ================================================ FILE: src/hooks/useOnLongPress/helpers.spec.jsx ================================================ import MockDate from 'mockdate' import flag from 'cozy-flags' import { handlePress, handleClick } from './helpers' jest.mock('cozy-flags', () => jest.fn()) const mockToggle = jest.fn() const mockOpenLink = jest.fn() const ev = { preventDefault: jest.fn() } describe('handlePress', () => { const setup = ({ event = ev, disabled = false, selectionModeActive = false, isLongPress = { current: false }, isRenaming = false }) => { return { params: { event, disabled, selectionModeActive, isLongPress, isRenaming, openLink: mockOpenLink, toggle: mockToggle } } } afterEach(() => { jest.clearAllMocks() }) it('should only toggle if selectionModeActive', () => { const { params } = setup({ selectionModeActive: true }) handlePress(params) expect(mockToggle).toHaveBeenCalledWith(ev) expect(mockOpenLink).not.toHaveBeenCalled() }) it('should only open link if not renaming', () => { const { params } = setup({ isRenaming: false }) handlePress(params) expect(mockToggle).not.toHaveBeenCalledWith() expect(mockOpenLink).toHaveBeenCalledWith(ev) }) describe('should do nothing if', () => { it('disabled is true', () => { const { params } = setup({ disabled: true }) handlePress(params) expect(mockToggle).not.toHaveBeenCalled() expect(mockOpenLink).not.toHaveBeenCalled() }) it('isRenaming is true', () => { const { params } = setup({ isRenaming: true }) handlePress(params) expect(mockToggle).not.toHaveBeenCalledWith() expect(mockOpenLink).not.toHaveBeenCalled() }) it('isLongPress is true', () => { const { params } = setup({ isLongPress: { current: true } }) handlePress(params) expect(mockToggle).not.toHaveBeenCalledWith() expect(mockOpenLink).not.toHaveBeenCalled() }) }) }) describe('handleClick', () => { const setup = ({ event = ev, disabled = false, isRenaming = false, file = { _id: 'file-id' }, lastClickTime = new Date('2025-01-01T12:00:00.000Z').getTime() // date of the first click }) => { return { params: { event, disabled, isRenaming, file, openLink: mockOpenLink, toggle: mockToggle, lastClickTime, setLastClickTime: jest.fn(), setSelectedItems: jest.fn(), onInteractWithFile: jest.fn() } } } afterEach(() => { jest.clearAllMocks() MockDate.reset() }) // should create a real life test to replace toggle by final func xit('should only toggle by default', () => { const { params } = setup({}) handleClick(params) expect(mockToggle).toHaveBeenCalledWith(ev) expect(mockOpenLink).not.toHaveBeenCalled() }) describe('should do nothing if', () => { it('disabled is true', () => { const { params } = setup({ disabled: true }) handleClick(params) expect(mockToggle).not.toHaveBeenCalled() expect(mockOpenLink).not.toHaveBeenCalled() }) it('isRenaming is true', () => { const { params } = setup({ isRenaming: true }) handleClick(params) expect(mockToggle).not.toHaveBeenCalledWith() expect(mockOpenLink).not.toHaveBeenCalled() }) }) describe('with dynamic-selection enabled and selectionModeActive', () => { const file = { _id: 'file-1' } const mockSetSelectedItems = jest.fn() const mockOnInteractWithFile = jest.fn() const setupDynamic = (eventOverrides = {}) => { flag.mockImplementation(name => { if (name === 'drive.dynamic-selection.enabled') return true if (name === 'drive.doubleclick.enabled') return false return false }) const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), shiftKey: false, ctrlKey: false, metaKey: false, ...eventOverrides } return { params: { event, file, disabled: false, isRenaming: false, openLink: mockOpenLink, toggle: mockToggle, selectionModeActive: true, lastClickTime: 0, setLastClickTime: jest.fn(), setSelectedItems: mockSetSelectedItems, onInteractWithFile: mockOnInteractWithFile, clearHighlightedItems: jest.fn() }, event } } afterEach(() => { flag.mockReset() }) it('should replace selection on simple click', () => { const { params } = setupDynamic() handleClick(params) expect(mockSetSelectedItems).toHaveBeenCalledWith({ [file._id]: file }) expect(mockToggle).not.toHaveBeenCalled() }) it('should toggle item on Ctrl+Click', () => { const { params, event } = setupDynamic({ ctrlKey: true }) handleClick(params) expect(mockToggle).toHaveBeenCalledWith(event) expect(mockSetSelectedItems).not.toHaveBeenCalled() }) it('should toggle item on Cmd+Click (metaKey)', () => { const { params, event } = setupDynamic({ metaKey: true }) handleClick(params) expect(mockToggle).toHaveBeenCalledWith(event) expect(mockSetSelectedItems).not.toHaveBeenCalled() }) }) describe('with doubleclick enabled', () => { const file = { _id: 'file-1' } const mockSetSelectedItems = jest.fn() const mockOnInteractWithFile = jest.fn() const setupDoubleClick = (eventOverrides = {}) => { flag.mockImplementation(name => { if (name === 'drive.doubleclick.enabled') return true return false }) const event = { preventDefault: jest.fn(), stopPropagation: jest.fn(), shiftKey: false, ctrlKey: false, metaKey: false, ...eventOverrides } return { params: { event, file, disabled: false, isRenaming: false, openLink: mockOpenLink, toggle: mockToggle, selectionModeActive: true, lastClickTime: 0, setLastClickTime: jest.fn(), setSelectedItems: mockSetSelectedItems, onInteractWithFile: mockOnInteractWithFile, clearHighlightedItems: jest.fn() }, event } } afterEach(() => { flag.mockReset() }) it('should replace selection on simple click', () => { const { params } = setupDoubleClick() handleClick(params) expect(mockSetSelectedItems).toHaveBeenCalledWith({ [file._id]: file }) expect(mockToggle).not.toHaveBeenCalled() }) it('should toggle item on Ctrl+Click', () => { const { params, event } = setupDoubleClick({ ctrlKey: true }) handleClick(params) expect(mockToggle).toHaveBeenCalledWith(event) expect(mockSetSelectedItems).not.toHaveBeenCalled() }) it('should toggle item on Cmd+Click (metaKey)', () => { const { params, event } = setupDoubleClick({ metaKey: true }) handleClick(params) expect(mockToggle).toHaveBeenCalledWith(event) expect(mockSetSelectedItems).not.toHaveBeenCalled() }) }) describe('for double click', () => { beforeEach(() => { MockDate.set('2025-01-01T12:00:00.300Z') // date of the second click }) it('it should do nothing when renainming', () => { const { params } = setup({ isRenaming: true }) handleClick(params) expect(mockToggle).not.toHaveBeenCalled() expect(mockOpenLink).not.toHaveBeenCalled() }) it('it should only open link', () => { const { params } = setup({}) handleClick(params) expect(mockToggle).not.toHaveBeenCalled() expect(mockOpenLink).toHaveBeenCalledWith(ev) }) }) }) ================================================ FILE: src/hooks/useOnLongPress/index.js ================================================ import { useRef, useState } from 'react' import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { makeDesktopHandlers, makeMobileHandlers } from './helpers' import { useSelectionContext } from '@/modules/selection/SelectionProvider' import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider' export const useLongPress = ({ file, disabled, isRenaming, openLink, toggle, onInteractWithFile }) => { const timerId = useRef() const isLongPress = useRef(false) const [lastClickTime, setLastClickTime] = useState(0) const { isDesktop } = useBreakpoints() const { setSelectedItems, clearSelection, isSelectionBarVisible: selectionModeActive } = useSelectionContext() const { clearItems: clearHighlightedItems } = useNewItemHighlightContext() if (isDesktop) { // eslint-disable-next-line react-hooks/refs return makeDesktopHandlers({ file, timerId, disabled, isRenaming, openLink, toggle, selectionModeActive, lastClickTime, setLastClickTime, clearSelection, setSelectedItems, onInteractWithFile, clearHighlightedItems }) } // eslint-disable-next-line react-hooks/refs return makeMobileHandlers({ timerId, disabled, selectionModeActive, isRenaming, isLongPress, openLink, toggle, clearHighlightedItems }) } ================================================ FILE: src/hooks/useParentFolder.jsx ================================================ import { useClient } from 'cozy-client' import { DOCTYPE_FILES } from '@/lib/doctypes' const useParentFolder = parentFolderId => { const client = useClient() if (parentFolderId) { return client.getDocumentFromState(DOCTYPE_FILES, parentFolderId) } return null } export default useParentFolder ================================================ FILE: src/hooks/useParentFolder.spec.jsx ================================================ import useParentFolder from './useParentFolder' const mockGetDocumentFromState = jest.fn() jest.mock('cozy-client', () => ({ ...jest.requireActual('cozy-client'), useClient: () => ({ getDocumentFromState: mockGetDocumentFromState }) })) describe('useParentFolder', () => { it('should return file folder if parent folder exists', () => { const FOLDER = { id: 'folder-id', name: 'Folder name' } mockGetDocumentFromState.mockReturnValue(FOLDER) const parentFolder = useParentFolder(FOLDER.id) expect(parentFolder).toBe(FOLDER) }) it('should return null if parent folder does not exist', () => { const parentFolder = useParentFolder() expect(parentFolder).toBe(null) }) }) ================================================ FILE: src/hooks/useRecentFiles.jsx ================================================ import { useEffect, useState, useMemo } from 'react' import { useClient } from 'cozy-client' import { useDataProxy } from 'cozy-dataproxy-lib' import logger from '@/lib/logger' import { buildRecentQuery } from '@/queries' const useDataProxyRecents = () => { const [data, setData] = useState([]) const [fetchStatus, setFetchStatus] = useState('loading') const [error, setError] = useState(null) const dataProxy = useDataProxy() const client = useClient() const recentQuery = useMemo(() => buildRecentQuery(), []) useEffect(() => { const fetchRecents = async () => { setFetchStatus('loading') setError(null) if (dataProxy.dataProxyServicesAvailable) { try { const data = await dataProxy.recents() setData(data || []) setFetchStatus('loaded') return } catch (err) { logger.warn('Error fetching recents from dataproxy', err) } } if (client) { try { const result = await client.fetchQueryAndGetFromState({ definition: recentQuery.definition(), options: recentQuery.options }) setData(result?.data || []) setFetchStatus('loaded') } catch (err) { logger.warn('Error fetching recents from fallback query', err) setError(err) setFetchStatus('error') } } else { setError(new Error('Client not available')) setFetchStatus('error') } } fetchRecents() }, [dataProxy, client, recentQuery]) return { data, fetchStatus, error } } export default useDataProxyRecents ================================================ FILE: src/hooks/useRecentFiles.spec.jsx ================================================ import { renderHook, waitFor } from '@testing-library/react' import { useClient } from 'cozy-client' import { useDataProxy } from 'cozy-dataproxy-lib' import useDataProxyRecents from './useRecentFiles' import logger from '@/lib/logger' import { buildRecentQuery } from '@/queries' jest.mock('cozy-client', () => ({ useClient: jest.fn() })) jest.mock('cozy-dataproxy-lib', () => ({ useDataProxy: jest.fn() })) jest.mock('@/lib/logger', () => ({ warn: jest.fn() })) jest.mock('@/queries', () => ({ buildRecentQuery: jest.fn() })) const mockUseClient = useClient const mockUseDataProxy = useDataProxy const mockBuildRecentQuery = buildRecentQuery describe('useDataProxyRecents', () => { let mockClient beforeEach(() => { jest.clearAllMocks() mockClient = { fetchQueryAndGetFromState: jest.fn() } mockUseClient.mockReturnValue(mockClient) mockBuildRecentQuery.mockReturnValue({ definition: jest.fn(() => ({})), options: {} }) }) describe('when dataProxy is available and succeeds', () => { it('should return data from dataProxy', async () => { const mockData = [ { id: '1', name: 'file1' }, { id: '2', name: 'file2' } ] const mockDataProxy = { dataProxyServicesAvailable: true, recents: jest.fn().mockResolvedValue(mockData) } mockUseDataProxy.mockReturnValue(mockDataProxy) const { result } = renderHook(() => useDataProxyRecents()) expect(result.current.fetchStatus).toBe('loading') expect(result.current.data).toEqual([]) await waitFor(() => expect(result.current.fetchStatus).toBe('loaded')) expect(result.current.data).toEqual(mockData) expect(result.current.error).toBe(null) expect(mockDataProxy.recents).toHaveBeenCalledTimes(1) expect(mockClient.fetchQueryAndGetFromState).not.toHaveBeenCalled() expect(logger.warn).not.toHaveBeenCalled() }) }) describe('when dataProxy throws an error', () => { it('should use fallback query when dataProxy fails', async () => { const mockError = new Error('DataProxy error') const mockDataProxy = { dataProxyServicesAvailable: true, recents: jest.fn().mockRejectedValue(mockError) } const fallbackData = [ { id: '3', name: 'file3' }, { id: '4', name: 'file4' } ] mockUseDataProxy.mockReturnValue(mockDataProxy) mockClient.fetchQueryAndGetFromState.mockResolvedValue({ data: fallbackData }) const { result } = renderHook(() => useDataProxyRecents()) expect(result.current.fetchStatus).toBe('loading') expect(result.current.data).toEqual([]) // Wait for fallback query to complete await waitFor(() => expect(result.current.fetchStatus).toBe('loaded')) expect(result.current.data).toEqual(fallbackData) expect(result.current.error).toBe(null) expect(logger.warn).toHaveBeenCalledWith( 'Error fetching recents from dataproxy', mockError ) expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1) expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledWith({ definition: expect.any(Object), options: expect.any(Object) }) }) it('should handle fallback query error', async () => { const mockError = new Error('DataProxy error') const fallbackError = new Error('Fallback query error') const mockDataProxy = { dataProxyServicesAvailable: true, recents: jest.fn().mockRejectedValue(mockError) } mockUseDataProxy.mockReturnValue(mockDataProxy) mockClient.fetchQueryAndGetFromState.mockRejectedValue(fallbackError) const { result } = renderHook(() => useDataProxyRecents()) // Wait for fallback query error to be processed await waitFor(() => expect(result.current.fetchStatus).toBe('error')) expect(result.current.error).toEqual(fallbackError) expect(logger.warn).toHaveBeenCalledWith( 'Error fetching recents from dataproxy', mockError ) expect(logger.warn).toHaveBeenCalledWith( 'Error fetching recents from fallback query', fallbackError ) expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1) }) }) describe('when dataProxy is not available', () => { it('should use fallback query when dataProxy is not available', async () => { const mockDataProxy = { dataProxyServicesAvailable: false } const fallbackData = [ { id: '5', name: 'file5' }, { id: '6', name: 'file6' } ] mockUseDataProxy.mockReturnValue(mockDataProxy) mockClient.fetchQueryAndGetFromState.mockResolvedValue({ data: fallbackData }) const { result } = renderHook(() => useDataProxyRecents()) // When dataProxy is not available, the hook should execute fallback query expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1) // Wait for fallback query to complete await waitFor(() => expect(result.current.fetchStatus).toBe('loaded')) expect(result.current.data).toEqual(fallbackData) expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledWith({ definition: expect.any(Object), options: expect.any(Object) }) }) it('should handle fallback query loading state', async () => { const mockDataProxy = { dataProxyServicesAvailable: false } mockUseDataProxy.mockReturnValue(mockDataProxy) // Don't resolve the query immediately to test loading state mockClient.fetchQueryAndGetFromState.mockImplementation( () => new Promise(() => {}) // Never resolves ) const { result } = renderHook(() => useDataProxyRecents()) expect(result.current.fetchStatus).toBe('loading') expect(result.current.data).toEqual([]) expect(result.current.error).toBe(null) expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1) }) }) describe('when client is not available', () => { it('should set error when client is not available', async () => { const mockDataProxy = { dataProxyServicesAvailable: false } mockUseDataProxy.mockReturnValue(mockDataProxy) mockUseClient.mockReturnValue(null) const { result } = renderHook(() => useDataProxyRecents()) // Wait for error to be set await waitFor(() => { expect(result.current.fetchStatus).toBe('error') }) expect(result.current.error).toEqual(new Error('Client not available')) expect(result.current.data).toEqual([]) expect(mockClient.fetchQueryAndGetFromState).not.toHaveBeenCalled() }) }) }) ================================================ FILE: src/hooks/useRecentIcons.jsx ================================================ import { useState, useEffect } from 'react' import logger from '@/lib/logger' const STORAGE_KEY = 'iconPicker_recent_icons' const MAX_RECENT_ICONS = 8 /** * Hook to get recent icons from localStorage * @returns {string[]} recentIcons - List of recently used icon names */ export const useRecentIcons = () => { const [recentIcons, setRecentIcons] = useState(null) useEffect(() => { try { const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY)) // eslint-disable-next-line react-hooks/set-state-in-effect setRecentIcons(Array.isArray(parsed) ? parsed : []) } catch (error) { logger.error('Failed to load recent icons from localStorage:', error) setRecentIcons([]) } }, []) return recentIcons } /** * Add an icon to the recent icons list (for use outside of React components) * This function directly updates localStorage and can be called from anywhere * @param {string} iconName - Name of the icon to add */ export const addRecentIcon = iconName => { if (!iconName || iconName === 'none') return try { const stored = localStorage.getItem(STORAGE_KEY) let current = [] if (stored) { const parsed = JSON.parse(stored) current = Array.isArray(parsed) ? parsed : [] } // Remove icon if it already exists and add it at the beginning const filtered = current.filter(icon => icon !== iconName) const updated = [iconName, ...filtered].slice(0, MAX_RECENT_ICONS) localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)) } catch (error) { logger.error('Failed to save recent icons to localStorage:', error) } } ================================================ FILE: src/hooks/useRecentIcons.spec.jsx ================================================ import { renderHook, act } from '@testing-library/react' import { useRecentIcons, addRecentIcon } from './useRecentIcons' import logger from '@/lib/logger' jest.mock('@/lib/logger', () => ({ error: jest.fn() })) const STORAGE_KEY = 'iconPicker_recent_icons' const MAX_RECENT_ICONS = 8 describe('useRecentIcons', () => { beforeEach(() => { jest.clearAllMocks() localStorage.clear() }) afterEach(() => { localStorage.clear() }) it('should return [] initially', () => { const { result } = renderHook(() => useRecentIcons()) expect(result.current).toEqual([]) }) it('should return [] when localStorage is empty', () => { const { result } = renderHook(() => useRecentIcons()) expect(result.current).toEqual([]) }) it('should return parsed array when localStorage has valid data', async () => { const icons = ['icon1', 'icon2', 'icon3'] localStorage.setItem(STORAGE_KEY, JSON.stringify(icons)) let result await act(async () => { const hook = renderHook(() => useRecentIcons()) result = hook.result }) expect(result.current).toEqual(icons) }) it('should return empty array when localStorage has invalid JSON', async () => { localStorage.setItem(STORAGE_KEY, 'invalid json') let result await act(async () => { const hook = renderHook(() => useRecentIcons()) result = hook.result }) expect(result.current).toEqual([]) expect(logger.error).toHaveBeenCalledWith( 'Failed to load recent icons from localStorage:', expect.any(SyntaxError) ) }) it('should return empty array when localStorage has non-array data', async () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: 'an array' })) let result await act(async () => { const hook = renderHook(() => useRecentIcons()) result = hook.result }) expect(result.current).toEqual([]) }) it('should handle localStorage.getItem errors gracefully', async () => { const error = new Error('localStorage error') const getItemSpy = jest .spyOn(Storage.prototype, 'getItem') .mockImplementation(() => { throw error }) let result await act(async () => { const hook = renderHook(() => useRecentIcons()) result = hook.result // Wait a bit for useEffect to run and state to update await new Promise(resolve => setTimeout(resolve, 10)) }) expect(result.current).toEqual([]) expect(logger.error).toHaveBeenCalledWith( 'Failed to load recent icons from localStorage:', error ) getItemSpy.mockRestore() }) }) describe('addRecentIcon', () => { beforeEach(() => { jest.clearAllMocks() localStorage.clear() }) afterEach(() => { localStorage.clear() }) it('should do nothing when iconName is falsy', () => { addRecentIcon(null) addRecentIcon(undefined) addRecentIcon('') expect(localStorage.getItem(STORAGE_KEY)).toBeNull() }) it('should do nothing when iconName is "none"', () => { addRecentIcon('none') expect(localStorage.getItem(STORAGE_KEY)).toBeNull() }) it('should add icon to empty localStorage', () => { addRecentIcon('icon1') const stored = localStorage.getItem(STORAGE_KEY) expect(stored).toBe(JSON.stringify(['icon1'])) }) it('should add icon to existing localStorage', () => { const existingIcons = ['icon1', 'icon2'] localStorage.setItem(STORAGE_KEY, JSON.stringify(existingIcons)) addRecentIcon('icon3') const stored = localStorage.getItem(STORAGE_KEY) expect(stored).toBe(JSON.stringify(['icon3', 'icon1', 'icon2'])) }) it('should move existing icon to the beginning', () => { const existingIcons = ['icon1', 'icon2', 'icon3'] localStorage.setItem(STORAGE_KEY, JSON.stringify(existingIcons)) addRecentIcon('icon2') const stored = localStorage.getItem(STORAGE_KEY) expect(stored).toBe(JSON.stringify(['icon2', 'icon1', 'icon3'])) }) it('should limit to MAX_RECENT_ICONS', () => { const existingIcons = Array.from( { length: MAX_RECENT_ICONS }, (_, i) => `icon${i + 1}` ) localStorage.setItem(STORAGE_KEY, JSON.stringify(existingIcons)) addRecentIcon('newIcon') const stored = localStorage.getItem(STORAGE_KEY) const parsed = JSON.parse(stored) expect(parsed).toHaveLength(MAX_RECENT_ICONS) expect(parsed[0]).toBe('newIcon') expect(parsed).not.toContain(existingIcons[existingIcons.length - 1]) }) it('should handle localStorage.getItem errors gracefully', () => { const error = new Error('localStorage error') const getItemSpy = jest .spyOn(Storage.prototype, 'getItem') .mockImplementation(() => { throw error }) addRecentIcon('icon1') expect(logger.error).toHaveBeenCalledWith( 'Failed to save recent icons to localStorage:', error ) getItemSpy.mockRestore() }) it('should handle localStorage.setItem errors gracefully', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(['icon1'])) const error = new Error('localStorage setItem error') const setItemSpy = jest .spyOn(Storage.prototype, 'setItem') .mockImplementation(() => { throw error }) addRecentIcon('icon2') expect(logger.error).toHaveBeenCalledWith( 'Failed to save recent icons to localStorage:', error ) setItemSpy.mockRestore() }) it('should handle invalid JSON in localStorage', () => { localStorage.setItem(STORAGE_KEY, 'invalid json') addRecentIcon('icon1') // When JSON.parse fails, error is caught and logged, but localStorage is not updated expect(logger.error).toHaveBeenCalledWith( 'Failed to save recent icons to localStorage:', expect.any(SyntaxError) ) // localStorage still contains the invalid JSON because the error happened before setItem const stored = localStorage.getItem(STORAGE_KEY) expect(stored).toBe('invalid json') }) it('should handle non-array data in localStorage', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: 'an array' })) addRecentIcon('icon1') const stored = localStorage.getItem(STORAGE_KEY) expect(stored).toBe(JSON.stringify(['icon1'])) }) it('should maintain order when adding same icon multiple times', () => { addRecentIcon('icon1') addRecentIcon('icon2') addRecentIcon('icon3') addRecentIcon('icon1') const stored = localStorage.getItem(STORAGE_KEY) expect(stored).toBe(JSON.stringify(['icon1', 'icon3', 'icon2'])) }) }) ================================================ FILE: src/hooks/useRedirectLink.jsx ================================================ import { useState, useEffect } from 'react' import { useSearchParams, useNavigate } from 'react-router-dom' import { useClient, generateWebLink, deconstructRedirectLink } from 'cozy-client' import { changeLocation } from '@/hooks/helpers' import logger from '@/lib/logger' /** * @typedef {object} ReturnRedirectLink * @property {string} redirectLink - The redirect link * @property {function} redirectBack - The function to redirect the user * @property {boolean} canRedirect - True if the user can be redirected */ /** * This hook is used to redirect from an OnlyOffice file * @param {boolean} isPublic - true if the file is public * @returns {ReturnRedirectLink} - The redirect link and the function to redirect from an OnlyOffice file */ const useRedirectLink = ({ isPublic = false } = {}) => { const [searchParams] = useSearchParams() const params = new URLSearchParams(location.search) const client = useClient() const navigate = useNavigate() const isFromPublicFolder = searchParams.get('fromPublicFolder') === 'true' const [currentMemberInstance, setCurrentMemberInstance] = useState(undefined) useEffect(() => { const fetch = async () => { try { const permissions = await client .collection('io.cozy.permissions') .fetchOwnPermissions() // We gets in included the member of the sharing, corresponding to the user who accessed the file // If the file is open on the instance of the share owner, we can retrieve the link to his instance if (permissions.included?.length > 0) { setCurrentMemberInstance(permissions.included[0].attributes?.instance) } } catch { logger.warn('Cannot fetch permissions') } } if (isPublic && !isFromPublicFolder) { fetch() } }, [client, isPublic, isFromPublicFolder]) /** * We search for redirectLink using two methods because * the searchParam differs depending on the position in the url : * - for /#hash?searchParam, you need useSearchParams * - for /?searchParam#hash, you need location.search */ const redirectLink = searchParams.get('redirectLink') || params.get('redirectLink') const redirectBack = () => { if (!redirectLink) { return logger.warn('Cannot find a redirect link') } const { slug, pathname, hash } = deconstructRedirectLink(redirectLink) // As we navigate in the same instance, we can use the react-router-dom navigate if (!isPublic || isFromPublicFolder) { return navigate(hash) } // If the file is open on the instance of the share owner, we can redirect the user to his instance if (currentMemberInstance) { try { const { subdomain: subDomainType } = client.getInstanceOptions() const link = generateWebLink({ cozyUrl: currentMemberInstance, subDomainType, slug, pathname, hash }) return changeLocation(link) } catch (e) { logger.error(`Cannot generate a web link : ${e}`) } } /** * If file is not open in new tab, we can redirect the user to the previous page * There is a double redirection for public file : * 1. To know that the file is a share, the other * 2. To open it on the host instance * so there is an additional entry in the history to skip to access the previous page */ if (window.history.length > 2) { return navigate(-2) } // We do nothing because we don't know where to redirect the user } const canRedirect = !!redirectLink && (!isPublic || isFromPublicFolder || !!currentMemberInstance || window.history.length > 2) return { redirectLink, redirectBack, canRedirect } } export { useRedirectLink } ================================================ FILE: src/hooks/useRedirectLink.spec.jsx ================================================ import { renderHook, act } from '@testing-library/react' import { useSearchParams, useNavigate } from 'react-router-dom' import { useClient } from 'cozy-client' import * as helpers from './helpers' import { useRedirectLink } from './useRedirectLink' jest.mock('cozy-client', () => ({ ...jest.requireActual('cozy-client'), useClient: jest.fn() })) jest.mock('react-router-dom', () => ({ useSearchParams: jest.fn(), useNavigate: jest.fn() })) const originalHistory = window.history describe('useRedirectLink', () => { const mockClient = { collection: jest.fn().mockReturnValue({ fetchOwnPermissions: jest.fn().mockResolvedValue({ included: [] }) }), getStackClient: jest.fn().mockReturnValue({ uri: 'https://my.cozy.cloud' }), getInstanceOptions: jest.fn().mockReturnValue({ subdomain: 'flat' }) } const mockNavigate = jest.fn() beforeEach(() => { useClient.mockReturnValue(mockClient) useSearchParams.mockReturnValue([ new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123') ]) useNavigate.mockReturnValue(mockNavigate) }) afterEach(() => { window.history = originalHistory jest.clearAllMocks() }) it('should redirect with navigate when is not public', async () => { let render await act(async () => { render = renderHook(() => useRedirectLink()) }) render.result.current.redirectBack() expect(mockNavigate).toHaveBeenCalledWith('/folder/id123') expect(render.result.current.redirectLink).toBe('drive#/folder/id123') expect(render.result.current.canRedirect).toBe(true) }) it('should redirect with navigate when is from a public folder', async () => { useSearchParams.mockReturnValue([ new URLSearchParams( '?redirectLink=drive%23%2Ffolder%2Fid123&fromPublicFolder=true' ) ]) let render await act(async () => { render = renderHook(() => useRedirectLink({ isPublic: true })) }) render.result.current.redirectBack() expect(mockNavigate).toHaveBeenCalledWith('/folder/id123') expect(render.result.current.redirectLink).toBe('drive#/folder/id123') expect(render.result.current.canRedirect).toBe(true) }) it('should redirect with window.location in public when instance is known', async () => { const spyChangeLocation = jest .spyOn(helpers, 'changeLocation') .mockImplementationOnce(() => {}) mockClient.collection().fetchOwnPermissions.mockResolvedValueOnce({ included: [ { attributes: { instance: 'https://other.cozy.cloud' } } ] }) useSearchParams.mockReturnValue([ new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123') ]) let render await act(async () => { render = renderHook(() => useRedirectLink({ isPublic: true })) }) render.result.current.redirectBack() expect(mockNavigate).toHaveBeenCalledTimes(0) expect(spyChangeLocation).toHaveBeenCalledWith( 'https://other-drive.cozy.cloud/#/folder/id123' ) expect(render.result.current.redirectLink).toBe('drive#/folder/id123') expect(render.result.current.canRedirect).toBe(true) }) it('should redirect with navigate(-2) in public when the instance is unknown', async () => { delete window.history window.history = Object.defineProperties( {}, { ...Object.getOwnPropertyDescriptors(originalHistory), length: { configurable: true, value: 3 } } ) mockClient.collection().fetchOwnPermissions.mockResolvedValueOnce({ included: [ { attributes: {} } ] }) useSearchParams.mockReturnValue([ new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123') ]) let render await act(async () => { render = renderHook(() => useRedirectLink({ isPublic: true })) }) render.result.current.redirectBack() expect(mockNavigate).toHaveBeenCalledWith(-2) expect(render.result.current.redirectLink).toBe('drive#/folder/id123') expect(render.result.current.canRedirect).toBe(true) }) it('should do nothing when the instance is unknown and the page is opened in new tab', async () => { mockClient.collection().fetchOwnPermissions.mockResolvedValueOnce({ included: [ { attributes: {} } ] }) useSearchParams.mockReturnValue([ new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123') ]) let render await act(async () => { render = renderHook(() => useRedirectLink({ isPublic: true })) }) render.result.current.redirectBack() expect(mockNavigate).toHaveBeenCalledTimes(0) expect(render.result.current.redirectLink).toBe('drive#/folder/id123') expect(render.result.current.canRedirect).toBe(false) }) }) ================================================ FILE: src/hooks/useShiftSelection/helpers.spec.ts ================================================ import { IOCozyFile } from 'cozy-client/types/types' import { handleShiftArrow, handleShiftClick, FORWARD_DIRECTION, BACKWARD_DIRECTION, HandleShiftArrowParams, HandleShiftClickParams } from './helpers' import { SelectedItems } from '@/modules/selection/types' const createMockFile = (id: string, name = `file-${id}`): IOCozyFile => ({ _id: id, _type: 'io.cozy.files', name, type: 'file', dir_id: 'root', created_at: '2023-01-01T00:00:00.000Z', updated_at: '2023-01-01T00:00:00.000Z', size: 1000, mime: 'text/plain', class: 'text', executable: false }) as IOCozyFile const mockFiles: IOCozyFile[] = [ createMockFile('1', 'file1.txt'), createMockFile('2', 'file2.txt'), createMockFile('3', 'file3.txt'), createMockFile('4', 'file4.txt'), createMockFile('5', 'file5.txt') ] describe('handleShiftArrow', () => { let mockIsItemSelected: jest.Mock beforeEach(() => { mockIsItemSelected = jest.fn() }) afterEach(() => { jest.clearAllMocks() }) describe('when no items are selected', () => { it('should select the first item when moving forward', () => { const params: HandleShiftArrowParams = { direction: FORWARD_DIRECTION, items: mockFiles, selectedItems: {}, lastInteractedIdx: 0, isItemSelected: mockIsItemSelected } const result = handleShiftArrow(params) expect(result).toEqual({ newSelectedItems: { '1': mockFiles[0] }, lastInteractedItemId: '1' }) }) it('should select the last item when moving backward', () => { const params: HandleShiftArrowParams = { direction: BACKWARD_DIRECTION, items: mockFiles, selectedItems: {}, lastInteractedIdx: 0, isItemSelected: mockIsItemSelected } const result = handleShiftArrow(params) expect(result).toEqual({ newSelectedItems: { '5': mockFiles[4] }, lastInteractedItemId: '5' }) }) }) describe('when items are already selected', () => { it('should extend selection forward when moving from selected to unselected item', () => { const selectedItems: SelectedItems = { '2': mockFiles[1] } mockIsItemSelected.mockImplementation((id: string) => { if (id === '2') return true // Previous item is selected if (id === '3') return false // Current item is not selected return false }) const params: HandleShiftArrowParams = { direction: FORWARD_DIRECTION, items: mockFiles, selectedItems, lastInteractedIdx: 1, isItemSelected: mockIsItemSelected } const result = handleShiftArrow(params) expect(result.newSelectedItems).toEqual({ '2': mockFiles[1], '3': mockFiles[2] }) expect(result.lastInteractedItemId).toBe('3') }) it('should contract selection when moving from selected to selected item', () => { const selectedItems: SelectedItems = { '1': mockFiles[0], '2': mockFiles[1], '3': mockFiles[2] } mockIsItemSelected.mockImplementation((id: string) => { return ['1', '2', '3'].includes(id) }) const params: HandleShiftArrowParams = { direction: BACKWARD_DIRECTION, items: mockFiles, selectedItems, lastInteractedIdx: 2, isItemSelected: mockIsItemSelected } const result = handleShiftArrow(params) expect(result.newSelectedItems).toEqual({ '1': mockFiles[0], '2': mockFiles[1] }) expect(result.lastInteractedItemId).toBe('2') }) it('should handle boundary conditions at the start of the list', () => { const selectedItems: SelectedItems = { '1': mockFiles[0] } mockIsItemSelected.mockImplementation((id: string) => id === '1') const params: HandleShiftArrowParams = { direction: BACKWARD_DIRECTION, items: mockFiles, selectedItems, lastInteractedIdx: 0, isItemSelected: mockIsItemSelected } const result = handleShiftArrow(params) expect(result.newSelectedItems).toEqual({}) expect(result.lastInteractedItemId).toBe('1') }) it('should handle boundary conditions at the end of the list', () => { const selectedItems: SelectedItems = { '5': mockFiles[4] } mockIsItemSelected.mockImplementation((id: string) => id === '5') const params: HandleShiftArrowParams = { direction: FORWARD_DIRECTION, items: mockFiles, selectedItems, lastInteractedIdx: 4, isItemSelected: mockIsItemSelected } const result = handleShiftArrow(params) expect(result.newSelectedItems).toEqual({}) expect(result.lastInteractedItemId).toBe('5') }) }) }) describe('handleShiftClick', () => { beforeEach(() => { jest.clearAllMocks() }) describe('range selection behavior', () => { it('should select all items in range when end item is not selected', () => { const selectedItems: SelectedItems = {} const params: HandleShiftClickParams = { startIdx: 1, endIdx: 3, selectedItems, items: mockFiles } const result = handleShiftClick(params) expect(result).toEqual({ newSelectedItems: { '2': mockFiles[1], '3': mockFiles[2], '4': mockFiles[3] }, lastInteractedItemId: '4' }) }) it('should deselect all items in range when end item is selected', () => { const selectedItems: SelectedItems = { '1': mockFiles[0], '2': mockFiles[1], '3': mockFiles[2], '4': mockFiles[3], '5': mockFiles[4] } const params: HandleShiftClickParams = { startIdx: 1, endIdx: 3, selectedItems, items: mockFiles } const result = handleShiftClick(params) expect(result).toEqual({ newSelectedItems: { '1': mockFiles[0], '5': mockFiles[4] }, lastInteractedItemId: '4' }) }) it('should handle reverse range selection (endIdx < startIdx)', () => { const selectedItems: SelectedItems = {} const params: HandleShiftClickParams = { startIdx: 3, endIdx: 1, selectedItems, items: mockFiles } const result = handleShiftClick(params) expect(result).toEqual({ newSelectedItems: { '2': mockFiles[1], '3': mockFiles[2], '4': mockFiles[3] }, lastInteractedItemId: '2' }) }) it('should handle single item selection (startIdx === endIdx)', () => { const selectedItems: SelectedItems = {} const params: HandleShiftClickParams = { startIdx: 2, endIdx: 2, selectedItems, items: mockFiles } const result = handleShiftClick(params) expect(result).toEqual({ newSelectedItems: { '3': mockFiles[2] }, lastInteractedItemId: '3' }) }) }) describe('boundary conditions', () => { it('should handle selection at the beginning of the list', () => { const selectedItems: SelectedItems = {} const params: HandleShiftClickParams = { startIdx: 0, endIdx: 2, selectedItems, items: mockFiles } const result = handleShiftClick(params) expect(result).toEqual({ newSelectedItems: { '1': mockFiles[0], '2': mockFiles[1], '3': mockFiles[2] }, lastInteractedItemId: '3' }) }) it('should handle selection at the end of the list', () => { const selectedItems: SelectedItems = {} const params: HandleShiftClickParams = { startIdx: 2, endIdx: 4, selectedItems, items: mockFiles } const result = handleShiftClick(params) expect(result).toEqual({ newSelectedItems: { '3': mockFiles[2], '4': mockFiles[3], '5': mockFiles[4] }, lastInteractedItemId: '5' }) }) it('should handle full list selection', () => { const selectedItems: SelectedItems = {} const params: HandleShiftClickParams = { startIdx: 0, endIdx: 4, selectedItems, items: mockFiles } const result = handleShiftClick(params) expect(result).toEqual({ newSelectedItems: { '1': mockFiles[0], '2': mockFiles[1], '3': mockFiles[2], '4': mockFiles[3], '5': mockFiles[4] }, lastInteractedItemId: '5' }) }) }) describe('mixed selection scenarios', () => { it('should handle partial existing selection', () => { const selectedItems: SelectedItems = { '1': mockFiles[0], '5': mockFiles[4] } const params: HandleShiftClickParams = { startIdx: 1, endIdx: 3, selectedItems, items: mockFiles } const result = handleShiftClick(params) expect(result).toEqual({ newSelectedItems: { '1': mockFiles[0], '2': mockFiles[1], '3': mockFiles[2], '4': mockFiles[3], '5': mockFiles[4] }, lastInteractedItemId: '4' }) }) it('should preserve items outside the range when deselecting', () => { const selectedItems: SelectedItems = { '1': mockFiles[0], '2': mockFiles[1], '3': mockFiles[2], '4': mockFiles[3], '5': mockFiles[4] } const params: HandleShiftClickParams = { startIdx: 1, endIdx: 2, selectedItems, items: mockFiles } const result = handleShiftClick(params) expect(result).toEqual({ newSelectedItems: { '1': mockFiles[0], '4': mockFiles[3], '5': mockFiles[4] }, lastInteractedItemId: '3' }) }) }) }) ================================================ FILE: src/hooks/useShiftSelection/helpers.ts ================================================ import cloneDeep from 'lodash/cloneDeep' import { IOCozyFile } from 'cozy-client/types/types' import type { SelectedItems } from '@/modules/selection/types' export const FORWARD_DIRECTION = 1 as const export const BACKWARD_DIRECTION = -1 as const interface HandleShiftSelectionResponse { newSelectedItems: SelectedItems lastInteractedItemId: string } interface FindNextBoundaryIndexParams { items: IOCozyFile[] startIdx: number direction: typeof FORWARD_DIRECTION | typeof BACKWARD_DIRECTION isMovingToSelect: boolean isReturnCurrent: boolean isItemSelected: (id: string) => boolean } interface ToggleSelectionParams { items: IOCozyFile[] selectedItems: SelectedItems currentIdx: number lastInteractedIdx: number isMovingToSelect: boolean isItemSelected: (id: string) => boolean } export interface HandleShiftArrowParams { direction: typeof FORWARD_DIRECTION | typeof BACKWARD_DIRECTION items: IOCozyFile[] selectedItems: SelectedItems lastInteractedIdx: number isItemSelected: (id: string) => boolean } export interface HandleShiftClickParams { startIdx: number endIdx: number selectedItems: SelectedItems items: IOCozyFile[] } /** * Clamps an index value to be within valid array bounds. * @param {number} maxLength The maximum length of the array * @param {number} index The index to clamp * * @returns {number} The clamped index value between 0 and maxLength-1 */ const clamp = (maxLength: number, index: number): number => Math.max(0, Math.min(maxLength - 1, index)) /** * Find the next index (in given direction) where selection state flips. * This defines the next "boundary" for select/deselect operations. * Used to determine where to stop when selecting or deselecting. * * @param {FindNextBoundaryIndexParams} params The parameters object * @param {IOCozyFile[]} params.items Array of all available items * @param {number} params.startIdx Starting index to search from * @param {number} params.direction Direction to search (1 for forward, -1 for backward) * @param {boolean} params.isMovingToSelect Whether we're moving to select or deselect items * @param {boolean} params.isReturnCurrent Determine if we have to find next index or not * @param {function} params.isItemSelected Function to check if an item is selected * * @returns {number} The index of the next boundary where selection state changes */ const findNextBoundaryIndex = ({ items, startIdx, direction, isMovingToSelect, isReturnCurrent, isItemSelected }: FindNextBoundaryIndexParams): number => { if (isReturnCurrent) return startIdx let idx = startIdx + direction while ( idx >= 0 && idx < items.length && isMovingToSelect === isItemSelected(items[idx]?._id) ) { idx += direction } return clamp(items.length, idx - direction) } /** * Toggles the selection state of items based on keyboard navigation. * Handles the complex logic of selection or deselecting selections during Shift+Arrow operations. * * @param {ToggleSelectionParams} params The parameters object * @param {IOCozyFile[]} params.items Array of all available items * @param {SelectedItems} params.selectedItems Current selected items object * @param {number} params.currentIdx Current index being navigated to * @param {number} params.lastInteractedIdx Index of the last interacted item * @param {boolean} params.isMovingToSelect Whether we're moving to select or deselect items * @param {function} params.isItemSelected Function to check if an item is selected * * @returns {SelectedItems} */ const toggleSelection = ({ items, selectedItems, currentIdx, lastInteractedIdx, isMovingToSelect, isItemSelected }: ToggleSelectionParams): SelectedItems => { // Identify which item to modify (depends on selection direction) const targetItem = isMovingToSelect ? items[currentIdx] : isItemSelected(items[lastInteractedIdx]._id) ? items[lastInteractedIdx] : items[currentIdx] return Object.entries(selectedItems).reduce( (acc, [key, value]) => { if (isMovingToSelect) { acc[key] = value acc[targetItem._id] = targetItem } else { if (key !== targetItem._id) { acc[key] = value } } return acc }, {} ) } /** * Handle Shift + Arrow keyboard selection. * - If no items are selected, selects the first/last item based on direction * - Return selected items based on selection state * - Return focus position for continued navigation * * @param {HandleShiftArrowParams} params The parameters object * @param {number} params.direction Direction of arrow key (FORWARD_DIRECTION or BACKWARD_DIRECTION) * @param {IOCozyFile[]} [params.items] Array of all available items (defaults to empty array) * @param {SelectedItems} [params.selectedItems] Current selected items object (defaults to empty object) * @param {number} params.lastInteractedIdx Index of the last interacted item * @param {function} params.isItemSelected Function to check if an item is selected by _id * * @returns {HandleShiftSelectionResponse} */ export const handleShiftArrow = ({ direction, items, selectedItems = {}, lastInteractedIdx, isItemSelected }: HandleShiftArrowParams): HandleShiftSelectionResponse => { if (Object.keys(selectedItems).length === 0) { const autoSelectedItem = direction === FORWARD_DIRECTION ? items[0] : items[items.length - 1] return { newSelectedItems: { [autoSelectedItem._id]: autoSelectedItem }, lastInteractedItemId: autoSelectedItem._id } } const nextIdx = lastInteractedIdx + direction const currentIdx = clamp(items.length, nextIdx) const prevSelected = isItemSelected(items[lastInteractedIdx]?._id) const currSelected = isItemSelected(items[currentIdx]?._id) const isMovingToSelect = prevSelected && !currSelected const newSelectedItems = toggleSelection({ items, selectedItems, currentIdx, lastInteractedIdx, isMovingToSelect, isItemSelected }) // Updates focus position for continued navigation const finalIdx = findNextBoundaryIndex({ items, startIdx: currentIdx, direction, isMovingToSelect, isItemSelected, isReturnCurrent: Object.keys(newSelectedItems).length <= 1 }) return { newSelectedItems, lastInteractedItemId: items[finalIdx]._id } } /** * Handle Shift + Click range selection. * - Selects all items in range if end item is not selected * - Deselects all items in range if end item is already selected * - Handles reverse ranges (endIdx < startIdx) automatically * - Return the last interacted item to the clicked item and new selections * * @param {HandleShiftClickParams} params The parameters object * @param {number} params.startIdx Starting index of the selection range (last interacted item) * @param {number} params.endIdx Ending index of the selection range (last clicked item) * @param {SelectedItems} params.selectedItems Current selected items object * @param {IOCozyFile[]} params.items Array of all available items * * @returns {HandleShiftSelectionResponse} */ export const handleShiftClick = ({ startIdx, endIdx, selectedItems, items }: HandleShiftClickParams): HandleShiftSelectionResponse => { const endItem = items[endIdx] const isMovingToSelect = !Object.hasOwn(selectedItems, endItem._id) const start = Math.min(startIdx, endIdx) const end = Math.max(startIdx, endIdx) const newSelectedItems = Array.from( { length: end - start + 1 }, (_, i) => start + i ).reduce((acc, i) => { const item = items[i] if (isMovingToSelect) { acc[item._id] = item } else { const { [item._id]: _, ...rest } = acc return rest } return acc }, cloneDeep(selectedItems)) return { newSelectedItems, lastInteractedItemId: items[endIdx]._id } } ================================================ FILE: src/hooks/useShiftSelection/index.spec.tsx ================================================ import { renderHook, act } from '@testing-library/react' import { RefObject } from 'react' import { IOCozyFile } from 'cozy-client/types/types' import * as helpers from './helpers' import { useShiftSelection } from './index' jest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({ __esModule: true, default: (): { isMobile: boolean } => ({ isMobile: false }) })) jest.mock('@/modules/selection/SelectionProvider', () => ({ useSelectionContext: jest.fn() })) jest.mock('./helpers', () => ({ handleShiftClick: jest.fn().mockReturnValue({ newSelectedItems: {}, lastInteractedItemId: '1' }), handleShiftArrow: jest.fn().mockReturnValue({ newSelectedItems: {}, lastInteractedItemId: '1' }), FORWARD_DIRECTION: 1, BACKWARD_DIRECTION: -1 })) import { useSelectionContext } from '@/modules/selection/SelectionProvider' const mockUseSelectionContext = useSelectionContext as jest.Mock // Get references to mocked functions const mockHandleShiftArrow = helpers.handleShiftArrow as jest.Mock const mockHandleShiftClick = helpers.handleShiftClick as jest.Mock const createMockFile = (id: string): IOCozyFile => ({ _id: id, name: `file-${id}`, type: 'file' }) as IOCozyFile const mockFiles = [ createMockFile('1'), createMockFile('2'), createMockFile('3') ] describe('useShiftSelection', () => { let mockSetSelectedItems: jest.Mock let mockIsItemSelected: jest.Mock let mockRef: RefObject let mockElement: HTMLElement beforeEach(() => { mockSetSelectedItems = jest.fn() mockIsItemSelected = jest.fn() mockElement = { focus: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn() } as unknown as HTMLElement mockRef = { current: mockElement } mockUseSelectionContext.mockReturnValue({ selectedItems: [], setSelectedItems: mockSetSelectedItems, isItemSelected: mockIsItemSelected, setIsSelectAll: jest.fn() }) jest.clearAllMocks() }) describe('initialization', () => { it('should return correct interface', () => { const { result } = renderHook(() => useShiftSelection({ items: mockFiles }, mockRef) ) expect(result.current).toHaveProperty('setLastInteractedItem') expect(result.current).toHaveProperty('onShiftClick') expect(typeof result.current.onShiftClick).toBe('function') expect(typeof result.current.setLastInteractedItem).toBe('function') }) }) describe('keyboard event handling - list view', () => { it('should call handleShiftArrow on Shift+ArrowDown in list view', () => { renderHook(() => useShiftSelection({ items: mockFiles, viewType: 'list' }, mockRef) ) const keydownHandler = ( (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[] )[1] as (event: KeyboardEvent) => void const mockEvent = { shiftKey: true, key: 'ArrowDown', preventDefault: jest.fn() } as unknown as KeyboardEvent act(() => { keydownHandler(mockEvent) }) expect(mockHandleShiftArrow).toHaveBeenCalledWith({ direction: 1, items: mockFiles, selectedItems: {}, lastInteractedIdx: 0, isItemSelected: mockIsItemSelected }) }) it('should call handleShiftArrow on Shift+ArrowUp in list view', () => { renderHook(() => useShiftSelection({ items: mockFiles, viewType: 'list' }, mockRef) ) const keydownHandler = ( (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[] )[1] as (event: KeyboardEvent) => void const mockEvent = { shiftKey: true, key: 'ArrowUp', preventDefault: jest.fn() } as unknown as KeyboardEvent act(() => { keydownHandler(mockEvent) }) expect(mockHandleShiftArrow).toHaveBeenCalledWith( expect.objectContaining({ direction: -1 }) ) }) }) describe('keyboard event handling - grid view', () => { it('should call handleShiftArrow on Shift+ArrowRight in grid view', () => { renderHook(() => useShiftSelection({ items: mockFiles, viewType: 'grid' }, mockRef) ) const keydownHandler = ( (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[] )[1] as (event: KeyboardEvent) => void const mockEvent = { shiftKey: true, key: 'ArrowRight', preventDefault: jest.fn() } as unknown as KeyboardEvent act(() => { keydownHandler(mockEvent) }) expect(mockHandleShiftArrow).toHaveBeenCalledWith( expect.objectContaining({ direction: 1 }) ) }) it('should call handleShiftArrow on Shift+ArrowLeft in grid view', () => { renderHook(() => useShiftSelection({ items: mockFiles, viewType: 'grid' }, mockRef) ) const keydownHandler = ( (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[] )[1] as (event: KeyboardEvent) => void const mockEvent = { shiftKey: true, key: 'ArrowLeft', preventDefault: jest.fn() } as unknown as KeyboardEvent act(() => { keydownHandler(mockEvent) }) expect(mockHandleShiftArrow).toHaveBeenCalledWith( expect.objectContaining({ direction: -1 }) ) }) }) describe('onShiftClick', () => { it('should call handleShiftClick when shift key is pressed', () => { const { result } = renderHook(() => useShiftSelection({ items: mockFiles }, mockRef) ) const mockEvent = { shiftKey: true, stopPropagation: jest.fn() } as unknown as KeyboardEvent act(() => { result.current.onShiftClick('2', mockEvent) }) expect(mockHandleShiftClick).toHaveBeenCalledWith({ startIdx: 0, endIdx: 1, selectedItems: {}, items: mockFiles }) }) it('should not call handleShiftClick when shift key is not pressed', () => { const { result } = renderHook(() => useShiftSelection({ items: mockFiles }, mockRef) ) const mockEvent = { shiftKey: false, stopPropagation: jest.fn() } as unknown as KeyboardEvent act(() => { result.current.onShiftClick('2', mockEvent) }) expect(mockHandleShiftClick).not.toHaveBeenCalled() }) }) }) ================================================ FILE: src/hooks/useShiftSelection/index.tsx ================================================ /* eslint-disable react-hooks/refs */ import { useCallback, useEffect, useMemo, useRef, useState, RefObject } from 'react' import { IOCozyFile } from 'cozy-client/types/types' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import { handleShiftClick, handleShiftArrow, BACKWARD_DIRECTION, FORWARD_DIRECTION } from './helpers' import { isEditableTarget } from '@/hooks/helpers' import { useSelectionContext } from '@/modules/selection/SelectionProvider' import { SelectedItems } from '@/modules/selection/types' type ViewType = 'list' | 'grid' interface UseShiftSelectionParams { items: IOCozyFile[] viewType?: ViewType } interface UseShiftSelectionReturn { setLastInteractedItem: (id: string | null) => void onShiftClick: (clickedItemId: string, event: KeyboardEvent) => void } /** * Custom hook that provides shift-based range selection functionality for file/folder lists. * * This hook enables users to: * - Select ranges of items using Shift+Click (from last interacted item to clicked item) * - Navigate and extend selection using Shift+Arrow keys (direction depends on viewType) * * @param {UseShiftSelectionParams} params - Configuration object containing items and view type * @param {IOCozyFile[]} params.items - Array of IOCozyFile objects to enable selection on * @param {ViewType} params.viewType - View type ('list' or 'grid') that determines keyboard navigation behavior * @param ref - React ref to the container element that should receive keyboard events * * @returns {UseShiftSelectionReturn} */ const useShiftSelection = ( { items, viewType = 'list' }: UseShiftSelectionParams, ref: RefObject ): UseShiftSelectionReturn => { const { isMobile } = useBreakpoints() const itemsRef = useRef([]) itemsRef.current = useMemo(() => items, [items]) const { selectedItems, setSelectedItems, isItemSelected, setIsSelectAll } = useSelectionContext() const [lastInteractedItem, setLastInteractedItem] = useState( null ) const lastInteractedIdx = useMemo(() => { return lastInteractedItem ? itemsRef.current.findIndex(item => item._id === lastInteractedItem) : 0 }, [lastInteractedItem]) const selectedItemMap: SelectedItems = useMemo(() => { return selectedItems.reduce( (prev: SelectedItems, cur: IOCozyFile) => ({ ...prev, [cur._id]: cur }), {} ) }, [selectedItems]) /** * Handles shift+click events for range selection. * * When shift key is held and an item is clicked, selects or deselects all items * between the last interacted item and the clicked item (inclusive). * * @param {string} clickedItemId - ID of the item that was clicked * @param {KeyboardEvent} event - The keyboard event (must have shiftKey = true) */ const onShiftClick = useCallback( (clickedItemId: string, event: KeyboardEvent) => { if (!event.shiftKey) return event.stopPropagation() const endIdx = items.findIndex(item => item._id === clickedItemId) const { newSelectedItems, lastInteractedItemId } = handleShiftClick({ startIdx: lastInteractedIdx, endIdx, selectedItems: selectedItemMap, items }) setSelectedItems(newSelectedItems) setLastInteractedItem(lastInteractedItemId) setIsSelectAll( Object.keys(newSelectedItems).length === itemsRef.current.length ) }, [ items, lastInteractedIdx, selectedItemMap, setSelectedItems, setIsSelectAll, setLastInteractedItem ] ) /** * Handles keyboard events for shift+arrow navigation. * * Listens for shift+arrow key combinations and extends/contracts selection * based on the navigation direction. The specific arrow keys depend on viewType: * - List view: ArrowUp (backward) / ArrowDown (forward) * - Grid view: ArrowLeft (backward) / ArrowRight (forward) * * @param {KeyboardEvent} event - The keyboard event to handle */ const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (!event.shiftKey) return const key = event.key const isListKey = viewType === 'list' && ['ArrowUp', 'ArrowDown'].includes(key) const isGridKey = viewType === 'grid' && ['ArrowLeft', 'ArrowRight'].includes(key) if (!isListKey && !isGridKey) return event.preventDefault() const direction = key === 'ArrowUp' || key === 'ArrowLeft' ? BACKWARD_DIRECTION : FORWARD_DIRECTION const { newSelectedItems, lastInteractedItemId } = handleShiftArrow({ direction, items: itemsRef.current, selectedItems: selectedItemMap, lastInteractedIdx, isItemSelected }) setSelectedItems(newSelectedItems) setLastInteractedItem(lastInteractedItemId) setIsSelectAll(selectedItems.length === itemsRef.current.length) }, [ viewType, selectedItemMap, lastInteractedIdx, selectedItems.length, setSelectedItems, isItemSelected, setIsSelectAll, setLastInteractedItem ] ) /** * Sets up keyboard event listeners on the container element. * * - Focuses the container to ensure it can receive keyboard events * - Adds keydown event listener for shift+arrow navigation * - Skips setup on mobile devices or when no items/container available */ useEffect(() => { if (isMobile || !itemsRef.current.length || !ref.current) return const container = ref.current if (!isEditableTarget(document.activeElement)) { container.focus() } container.addEventListener('keydown', handleKeyDown) return (): void => { container.removeEventListener('keydown', handleKeyDown) } }, [isMobile, ref, handleKeyDown]) return { setLastInteractedItem, onShiftClick } } export { useShiftSelection } ================================================ FILE: src/hooks/useTransformFolderListHasSharedDriveShortcuts/index.spec.jsx ================================================ import { renderHook } from '@testing-library/react' import { useTransformFolderListHasSharedDriveShortcuts } from './index' import { SHARED_DRIVES_DIR_ID } from '@/constants/config' jest.mock('cozy-sharing', () => ({ useSharingContext: jest.fn() })) jest.mock('@/modules/nextcloud/helpers', () => ({ isNextcloudShortcut: jest.fn() })) jest.mock('@/modules/shareddrives/hooks/useSharedDrives', () => ({ useSharedDrives: jest.fn() })) const mockUseSharingContext = require('cozy-sharing').useSharingContext const mockIsNextcloudShortcut = require('@/modules/nextcloud/helpers').isNextcloudShortcut const mockUseSharedDrives = require('@/modules/shareddrives/hooks/useSharedDrives').useSharedDrives describe('useTransformFolderListHasSharedDriveShortcuts', () => { beforeEach(() => { jest.resetAllMocks() mockUseSharingContext.mockReturnValue({ isOwner: jest.fn(() => false) }) mockUseSharedDrives.mockReturnValue({ sharedDrives: [] }) mockIsNextcloudShortcut.mockReturnValue(false) }) describe('transformedSharedDrives', () => { it('should transform shared drives into directory-like objects', () => { const mockSharedDrives = [ { id: 'sharing-1', rules: [ { values: ['folder-1'], title: 'Shared Drive 1' } ] } ] mockUseSharedDrives.mockReturnValue({ sharedDrives: mockSharedDrives }) const { result } = renderHook(() => useTransformFolderListHasSharedDriveShortcuts([]) ) expect(result.current.sharedDrives).toHaveLength(1) expect(result.current.sharedDrives[0]).toMatchObject({ _id: 'folder-1', id: 'folder-1', _type: 'io.cozy.files', type: 'directory', name: 'Shared Drive 1', dir_id: SHARED_DRIVES_DIR_ID, driveId: 'sharing-1', path: '/Drives/Shared Drive 1' }) }) it('should return existing file when user is owner', () => { const mockSharedDrives = [ { id: 'sharing-1', rules: [ { values: ['folder-1'], title: 'Shared Drive 1' } ] } ] const mockFolderList = [ { _id: 'file-1', id: 'file-1', name: 'Existing File', relationships: { referenced_by: { data: [{ id: 'sharing-1' }] } } } ] mockUseSharedDrives.mockReturnValue({ sharedDrives: mockSharedDrives }) mockUseSharingContext.mockReturnValue({ isOwner: jest.fn(() => true) }) const { result } = renderHook(() => useTransformFolderListHasSharedDriveShortcuts(mockFolderList) ) expect(result.current.sharedDrives).toHaveLength(1) expect(result.current.sharedDrives[0]).toMatchObject({ _id: 'file-1', id: 'file-1', name: 'Existing File' }) }) it('should filter out nextcloud shortcuts', () => { const mockSharedDrives = [ { id: 'sharing-1', rules: [ { values: ['folder-1'], title: 'Regular Drive' } ] }, { id: 'sharing-2', rules: [ { values: ['folder-2'], title: 'Nextcloud Drive' } ] } ] mockUseSharedDrives.mockReturnValue({ sharedDrives: mockSharedDrives }) // Mock first drive as regular, second as nextcloud mockIsNextcloudShortcut .mockReturnValueOnce(false) .mockReturnValueOnce(true) const { result } = renderHook(() => useTransformFolderListHasSharedDriveShortcuts([]) ) expect(result.current.sharedDrives).toHaveLength(1) expect(result.current.sharedDrives[0].name).toBe('Regular Drive') }) }) describe('nonSharedDriveList', () => { it('should filter out shared drives from folder list', () => { const mockFolderList = [ { _id: 'file-1', name: 'Regular File', dir_id: 'regular-folder' }, { _id: 'file-2', name: 'Shared Drive File', dir_id: SHARED_DRIVES_DIR_ID } ] const { result } = renderHook(() => useTransformFolderListHasSharedDriveShortcuts(mockFolderList) ) expect(result.current.nonSharedDriveList).toHaveLength(1) expect(result.current.nonSharedDriveList[0].name).toBe('Regular File') }) it('should include nextcloud shortcuts when showNextcloudFolder is true', () => { const mockFolderList = [ { _id: 'file-1', name: 'Regular File', dir_id: 'regular-folder' }, { _id: 'file-2', name: 'Nextcloud File', dir_id: 'regular-folder' } ] mockIsNextcloudShortcut .mockReturnValueOnce(false) .mockReturnValueOnce(true) const { result } = renderHook(() => useTransformFolderListHasSharedDriveShortcuts(mockFolderList, true) ) expect(result.current.nonSharedDriveList).toHaveLength(2) expect(result.current.nonSharedDriveList.map(f => f.name)).toEqual([ 'Regular File', 'Nextcloud File' ]) }) it('should exclude files referenced by shared drives to avoid duplicates', () => { const mockSharedDrives = [ { id: 'sharing-1', rules: [ { values: ['folder-1'], title: 'Shared Drive 1' } ] } ] const mockFolderList = [ { _id: 'file-1', id: 'file-1', name: 'Regular File', dir_id: 'regular-folder', relationships: {} }, { _id: 'file-2', id: 'file-2', name: 'Shared Drive File', dir_id: 'regular-folder', relationships: { referenced_by: { data: [{ id: 'sharing-1' }] } } } ] mockUseSharedDrives.mockReturnValue({ sharedDrives: mockSharedDrives, isLoaded: true }) mockUseSharingContext.mockReturnValue({ isOwner: jest.fn(id => id === 'file-2') }) const { result } = renderHook(() => useTransformFolderListHasSharedDriveShortcuts(mockFolderList, true) ) // The file referenced by the shared drive should not be in nonSharedDriveList expect(result.current.nonSharedDriveList).toHaveLength(1) expect(result.current.nonSharedDriveList[0].name).toBe('Regular File') // But it should be in transformedSharedDrives expect(result.current.sharedDrives).toHaveLength(1) expect(result.current.sharedDrives[0].name).toBe('Shared Drive File') }) }) }) ================================================ FILE: src/hooks/useTransformFolderListHasSharedDriveShortcuts/index.tsx ================================================ import { useMemo } from 'react' import { IOCozyFile } from 'cozy-client/types/types' import { useSharingContext } from 'cozy-sharing' import { SHARED_DRIVES_DIR_ID, TRASH_DIR_PATH } from '@/constants/config' import { isNextcloudShortcut } from '@/modules/nextcloud/helpers' import { useSharedDrives } from '@/modules/shareddrives/hooks/useSharedDrives' interface SharingRule { values?: string[] title?: string } interface SharedDrive { id: string rules: SharingRule[] } interface TransformedSharedDrive extends IOCozyFile { driveId: string } interface UseTransformFolderListReturn { sharedDrives: TransformedSharedDrive[] nonSharedDriveList: IOCozyFile[] sharedDrivesLoaded: boolean } const useTransformFolderListHasSharedDriveShortcuts = ( folderList?: IOCozyFile[], showNextcloudFolder = false ): UseTransformFolderListReturn => { const { isOwner } = useSharingContext() as unknown as { isOwner: (fileId: string) => boolean } const { sharedDrives, isLoaded: sharedDrivesLoaded } = useSharedDrives() /** * Filter out Nextcloud shortcuts from shared drives. */ const filteredSharedDrives = useMemo( () => sharedDrives.filter( sharing => !isNextcloudShortcut(sharing as unknown as IOCozyFile) ), [sharedDrives] ) /** * The recipient's shared drives are displayed as shortcuts which cannot accessible * In some cases (like open shared drive from folder picker or sharing section...), * we want to access to shared drives as directories for both owner and recipient * The codes below help us to transform the shared drives shortcuts into directory-like objects */ const transformedSharedDrives = useMemo( () => filteredSharedDrives.map((sharing: SharedDrive) => { const [rootFolderId, driveName] = [ sharing.rules[0]?.values?.[0], sharing.rules[0]?.title ?? '' ] const fileInSharingSection = folderList?.find(item => item.relationships?.referenced_by?.data?.some( ref => ref.id === sharing.id ) ) if (fileInSharingSection && isOwner(fileInSharingSection.id ?? '')) return fileInSharingSection as TransformedSharedDrive const directoryData = { type: 'directory' as const, name: driveName, dir_id: SHARED_DRIVES_DIR_ID, driveId: sharing.id } return { ...fileInSharingSection, _id: rootFolderId, id: rootFolderId, _type: 'io.cozy.files' as const, path: `/Drives/${driveName}`, ...directoryData, attributes: directoryData } as TransformedSharedDrive }), [filteredSharedDrives, folderList, isOwner] ) /** * Create a Set of shared drive IDs for efficient lookup */ const sharedDriveIds = useMemo( () => new Set(filteredSharedDrives.map((drive: SharedDrive) => drive.id)), [filteredSharedDrives] ) /** * Exclude shared drives from the folderList, * since it will be replaced with transformed ones above. * Also exclude files that are referenced by a shared drive to avoid duplicates. */ const nonSharedDriveList = useMemo( () => folderList?.filter(item => { const referencedByData = item.relationships?.referenced_by?.data ?? [] const isReferencedBySharedDrive = referencedByData.some(ref => sharedDriveIds.has(ref.id) ) return ( item.dir_id !== SHARED_DRIVES_DIR_ID && !item.path?.startsWith(TRASH_DIR_PATH) && !isReferencedBySharedDrive && (!showNextcloudFolder ? !isNextcloudShortcut(item) : true) ) }) ?? [], [folderList, sharedDriveIds, showNextcloudFolder] ) return { sharedDrives: transformedSharedDrives, nonSharedDriveList, sharedDrivesLoaded } } export { useTransformFolderListHasSharedDriveShortcuts } ================================================ FILE: src/hooks/useUpdateFavicon/constants.ts ================================================ export const FAVICON_BY_MIMETYPE: Record = { text: '/favicons/icon-onlyoffice-text.ico', sheet: '/favicons/icon-onlyoffice-sheet.ico', slide: '/favicons/icon-onlyoffice-slide.ico' } ================================================ FILE: src/hooks/useUpdateFavicon/helpers.spec.js ================================================ import { updateFavicon } from './helpers' const mockQuerySelectorAll = jest.fn() const mockAppendChild = jest.fn() const mockCreateElement = jest.fn() const createMockLinkElement = (href = '/assets/favicon.ico') => ({ rel: '', type: '', href, setAttribute: jest.fn() }) describe('updateFavicon', () => { beforeEach(() => { jest.clearAllMocks() Object.defineProperty(document, 'querySelectorAll', { value: mockQuerySelectorAll, writable: true }) Object.defineProperty(document, 'createElement', { value: mockCreateElement, writable: true }) Object.defineProperty(document.head, 'appendChild', { value: mockAppendChild, writable: true }) mockCreateElement.mockReturnValue(createMockLinkElement()) }) it('should return early when faviconUrl is empty', () => { updateFavicon('') expect(mockQuerySelectorAll).not.toHaveBeenCalled() expect(mockAppendChild).not.toHaveBeenCalled() }) it('should create new favicon link when no links exist in DOM', () => { const mockNewLink = createMockLinkElement() mockCreateElement.mockReturnValue(mockNewLink) mockQuerySelectorAll.mockReturnValue([]) updateFavicon('/favicons/icon-onlyoffice-text.ico') expect(mockCreateElement).toHaveBeenCalledWith('link') expect(mockNewLink.rel).toBe('icon') expect(mockNewLink.type).toBe('image/svg+xml') expect(mockNewLink.href).toBe('/favicons/icon-onlyoffice-text.ico') expect(mockAppendChild).toHaveBeenCalledWith(mockNewLink) }) it('should not update favicon when correct favicon is already applied', () => { const mockLink = createMockLinkElement('/favicons/icon-onlyoffice-text.ico') mockQuerySelectorAll.mockReturnValue([mockLink]) updateFavicon('/favicons/icon-onlyoffice-text.ico') expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico') }) it('should update favicon when current favicon differs from target', () => { const mockLink = createMockLinkElement( '/favicons/icon-onlyoffice-sheet.ico' ) mockQuerySelectorAll.mockReturnValue([mockLink]) updateFavicon('/favicons/icon-onlyoffice-text.ico') expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico') }) it('should update all favicon links when multiple exist', () => { const mockLink1 = createMockLinkElement('/assets/favicon.ico') const mockLink2 = createMockLinkElement('/assets/favicon.ico') mockQuerySelectorAll.mockReturnValue([mockLink1, mockLink2]) updateFavicon('/favicons/icon-onlyoffice-text.ico') expect(mockLink1.href).toBe('/favicons/icon-onlyoffice-text.ico') expect(mockLink2.href).toBe('/favicons/icon-onlyoffice-text.ico') }) it('should restore original favicon', () => { const mockLink = createMockLinkElement('/favicons/icon-onlyoffice-text.ico') mockQuerySelectorAll.mockReturnValue([mockLink]) updateFavicon('/assets/favicon.ico') expect(mockLink.href).toBe('/assets/favicon.ico') }) }) ================================================ FILE: src/hooks/useUpdateFavicon/helpers.ts ================================================ /** * Updates all favicon link elements in the document head * * @param {string}faviconUrl - The URL of the favicon to set */ export const updateFavicon = (faviconUrl: string): void => { if (!faviconUrl) return const links = document.querySelectorAll("link[rel~='icon']") if (!links.length) { const link = document.createElement('link') link.rel = 'icon' link.type = 'image/svg+xml' link.href = faviconUrl document.head.appendChild(link) return } const currentFavicon = links[0].href if (currentFavicon === faviconUrl) { return } links.forEach(link => { link.href = faviconUrl }) } ================================================ FILE: src/hooks/useUpdateFavicon/index.spec.jsx ================================================ import { renderHook } from '@testing-library/react' import flag from 'cozy-flags' import useUpdateFavicon from '.' jest.mock('cozy-flags') jest.mock('@/lib/getFileMimetype', () => ({ getFileMimetype: jest.fn() })) const mockFlag = flag const mockQuerySelector = jest.fn() const mockQuerySelectorAll = jest.fn() const createMockLinkElement = (href = '/assets/favicon.ico') => ({ rel: '', type: '', href }) describe('useUpdateFavicon', () => { beforeEach(() => { jest.clearAllMocks() Object.defineProperty(document, 'querySelector', { value: mockQuerySelector, writable: true }) Object.defineProperty(document, 'querySelectorAll', { value: mockQuerySelectorAll, writable: true }) mockFlag.mockReturnValue(true) mockQuerySelector.mockReturnValue(createMockLinkElement()) }) it('should update favicon for OnlyOffice text documents', () => { const file = { _id: '1', name: 'document.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } const { getFileMimetype } = require('@/lib/getFileMimetype') getFileMimetype.mockReturnValue(() => 'text') const originalLink = createMockLinkElement('/assets/favicon.ico') const mockLink = createMockLinkElement('/assets/favicon.ico') mockQuerySelector.mockReturnValue(originalLink) mockQuerySelectorAll.mockReturnValue([mockLink]) renderHook(() => useUpdateFavicon(file, 'loaded')) expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico') }) it('should update favicon for OnlyOffice spreadsheet documents', () => { const file = { _id: '1', name: 'spreadsheet.xlsx', mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' } const { getFileMimetype } = require('@/lib/getFileMimetype') getFileMimetype.mockReturnValue(() => 'sheet') const originalLink = createMockLinkElement('/assets/favicon.ico') const mockLink = createMockLinkElement('/assets/favicon.ico') mockQuerySelector.mockReturnValue(originalLink) mockQuerySelectorAll.mockReturnValue([mockLink]) renderHook(() => useUpdateFavicon(file, 'loaded')) expect(mockLink.href).toBe('/favicons/icon-onlyoffice-sheet.ico') }) it('should update favicon for OnlyOffice presentation documents', () => { const file = { _id: '1', name: 'presentation.pptx', mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' } const { getFileMimetype } = require('@/lib/getFileMimetype') getFileMimetype.mockReturnValue(() => 'slide') const originalLink = createMockLinkElement('/assets/favicon.ico') const mockLink = createMockLinkElement('/assets/favicon.ico') mockQuerySelector.mockReturnValue(originalLink) mockQuerySelectorAll.mockReturnValue([mockLink]) renderHook(() => useUpdateFavicon(file, 'loaded')) expect(mockLink.href).toBe('/favicons/icon-onlyoffice-slide.ico') }) it('should use original favicon for non-OnlyOffice files', () => { const file = { _id: '1', name: 'image.jpg', mime: 'image/jpeg' } const { getFileMimetype } = require('@/lib/getFileMimetype') getFileMimetype.mockReturnValue(() => 'image') const originalFaviconLink = createMockLinkElement('/custom/favicon.ico') const mockLink = createMockLinkElement('/custom/favicon.ico') mockQuerySelector.mockReturnValue(originalFaviconLink) mockQuerySelectorAll.mockReturnValue([mockLink]) renderHook(() => useUpdateFavicon(file, 'loaded')) expect(mockLink.href).toBe('/custom/favicon.ico') }) it('should restore original favicon on cleanup', () => { const file = { _id: '1', name: 'document.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } const { getFileMimetype } = require('@/lib/getFileMimetype') getFileMimetype.mockReturnValue(() => 'text') const originalFaviconLink = createMockLinkElement('/original/favicon.ico') const mockLink = createMockLinkElement('/original/favicon.ico') mockQuerySelector.mockReturnValue(originalFaviconLink) mockQuerySelectorAll.mockReturnValue([mockLink]) const { unmount } = renderHook(() => useUpdateFavicon(file, 'loaded')) // Favicon should be updated to OnlyOffice icon expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico') unmount() // Favicon should be restored to original expect(mockLink.href).toBe('/original/favicon.ico') }) it('should not update favicon when fetchStatus is not loaded', () => { const file = { _id: '1', name: 'document.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' } const { getFileMimetype } = require('@/lib/getFileMimetype') getFileMimetype.mockReturnValue(() => 'text') const mockLink = createMockLinkElement('/assets/favicon.ico') mockQuerySelectorAll.mockReturnValue([mockLink]) renderHook(() => useUpdateFavicon(file, 'loading')) expect(mockLink.href).toBe('/assets/favicon.ico') }) it('should not update favicon when file is undefined', () => { const mockLink = createMockLinkElement('/assets/favicon.ico') mockQuerySelectorAll.mockReturnValue([mockLink]) renderHook(() => useUpdateFavicon(undefined, 'loaded')) expect(mockLink.href).toBe('/assets/favicon.ico') }) }) ================================================ FILE: src/hooks/useUpdateFavicon/index.tsx ================================================ import { useEffect, useRef } from 'react' import { IOCozyFile } from 'cozy-client/types/types' import { updateFavicon } from './helpers' import { FAVICON_BY_MIMETYPE } from '@/hooks/useUpdateFavicon/constants' import { getFileMimetype } from '@/lib/getFileMimetype' const useUpdateFavicon = ( file: IOCozyFile | undefined, fetchStatus: string ): void => { const originalFaviconUrlRef = useRef() useEffect(() => { const originalFavicon = document.querySelector("link[rel~='icon']") if (originalFavicon) { originalFaviconUrlRef.current = originalFavicon.href } return (): void => { const originalUrl = originalFaviconUrlRef.current if (originalUrl) { updateFavicon(originalUrl) } } }, []) useEffect(() => { if (fetchStatus !== 'loaded' || !file) { return } const type = getFileMimetype(FAVICON_BY_MIMETYPE)( file.mime, file.name ) as string const faviconUrl = FAVICON_BY_MIMETYPE[type] ?? originalFaviconUrlRef.current if (faviconUrl) { updateFavicon(faviconUrl) } }, [file, fetchStatus]) } export default useUpdateFavicon ================================================ FILE: src/lib/AcceptingSharingContext.jsx ================================================ import React, { createContext, useState } from 'react' const AcceptingSharingContext = createContext() const AcceptingSharingProvider = ({ children }) => { const [sharingsValue, setSharingsValue] = useState({}) const [fileValue, setFileValue] = useState() const contextValue = { sharingsValue, setSharingsValue, fileValue, setFileValue } return ( {children} ) } export default AcceptingSharingContext export { AcceptingSharingProvider } ================================================ FILE: src/lib/DriveProvider.jsx ================================================ import PropTypes from 'prop-types' import React from 'react' import { CozyProvider } from 'cozy-client' import { DataProxyProvider } from 'cozy-dataproxy-lib' import { VaultUnlockProvider, VaultProvider, VaultUnlockPlaceholder } from 'cozy-keys-lib' import SharingProvider, { NativeFileSharingProvider } from 'cozy-sharing' import AlertProvider from 'cozy-ui/transpiled/react/providers/Alert' import { BreakpointsProvider } from 'cozy-ui/transpiled/react/providers/Breakpoints' import CozyTheme from 'cozy-ui-plus/dist/providers/CozyTheme' import { I18n } from 'twake-i18n' import RightClickProvider from '@/components/RightClick/RightClickProvider' import FabProvider from '@/lib/FabProvider' import { DOCTYPE_APPS, DOCTYPE_CONTACTS, DOCTYPE_FILES } from '@/lib/doctypes' import { usePublicContext } from '@/modules/public/PublicProvider' const DriveProvider = ({ client, lang, polyglot, dictRequire, children }) => { const { isPublic } = usePublicContext() return ( {children} ) } const DataProxyWrapper = ({ children, isPublic }) => { if (isPublic) { // Do not include DataProxy for public sharings return children } return ( {children} ) } DriveProvider.propTypes = { client: PropTypes.object.isRequired, lang: PropTypes.string.isRequired, polyglot: PropTypes.object, dictRequire: PropTypes.func } export default DriveProvider ================================================ FILE: src/lib/FabProvider.jsx ================================================ import React, { createContext, useState } from 'react' export const FabContext = createContext() const FabProvider = ({ children }) => { const [isFabDisplayed, setIsFabDisplayed] = useState(false) return ( {children} ) } export default FabProvider ================================================ FILE: src/lib/FuzzyPathSearch.js ================================================ import { remove as removeDiacritics } from 'diacritics' // Search for keywords inside a list of files and folders, while being permissive regardig the order of words class FuzzyPathSearch { constructor(files = []) { // files must have a `path` and `name` property this.files = files this.previousQuery = [] this.previousSuggestions = files } search(query) { if (!query) return [] const queryArray = removeDiacritics( query.replace(/\//g, ' ').trim().toLowerCase() ).split(' ') const preparedQuery = queryArray.map(word => ({ word, isAugmentedWord: false, isNewWord: true })) const isQueryAugmented = this.isAugmentingPreviousQuery(preparedQuery) const sortedQuery = isQueryAugmented ? this.sortQueryByRevelance(preparedQuery) : this.sortQuerybyLength(preparedQuery) let suggestions if (isQueryAugmented && this.previousSuggestions.length !== 0) { // the new query is just a more selective version of the previous one, so we narrow down the existing list suggestions = this.filterAndScore( this.previousSuggestions, sortedQuery.map(segment => segment.word) ) } else { suggestions = this.filterAndScore( this.files, sortedQuery.map(segment => segment.word) ) } this.previousQuery = sortedQuery this.previousSuggestions = suggestions return suggestions } isAugmentingPreviousQuery(query) { for (let currentQuerySegment of query) { let isInPreviousQuery = false for (let previousQuerySegment of this.previousQuery) { if (currentQuerySegment.word.includes(previousQuerySegment.word)) { isInPreviousQuery = true break } } // we found a word in the current query that was not included in the previous query, so we consider it a completely new query if (isInPreviousQuery === false) return false } // all words are in the previous query return true } sortQueryByRevelance(query) { // query terms are sorted in two categories: those that are new or have changed, and therefore may further reduce the set of results, are prioritzed. Those that were there and have not changed come second. // finally, longer words are placed first to allow discarding files earlier in the scoring loop let priorizedWords = [] let wordsFromPreviousQuery = [] for (let currentQuerySegment of query) { let wasInPreviousQuery = false for (let previousQuerySegment of this.previousQuery) { if (currentQuerySegment.word.includes(previousQuerySegment.word)) { if (currentQuerySegment.word !== previousQuerySegment.word) { currentQuerySegment.isAugmentedWord = true priorizedWords.push(currentQuerySegment) } else { currentQuerySegment.isNewWord = false wordsFromPreviousQuery.push(currentQuerySegment) } wasInPreviousQuery = true continue } } // this segment wasn't included in any previous query segment so it's a new word and we prioritize it if (!wasInPreviousQuery) priorizedWords.push(currentQuerySegment) } return this.sortQuerybyLength(priorizedWords).concat( this.sortQuerybyLength(wordsFromPreviousQuery) ) } sortQuerybyLength(query) { return query.sort((a, b) => b.word.length - a.word.length) } filterAndScore(files, words) { const suggestions = [] files.forEach(file => { let fileScore = 0 const pathArray = removeDiacritics( (file.path + '/' + file.name).toLowerCase() ) .split('/') .filter(pathChunk => !!pathChunk) for (let word of words) { // let the magic begin... // essentialy, matched words that are at the end of the path get better scores let wordScore = 0 let wordOccurenceValue = 10000 let firstOccurence = true const maxDepth = pathArray.length for (let depth = 0; depth < maxDepth; ++depth) { let dirName = pathArray[depth] if (dirName.includes(word)) { if (firstOccurence) { wordOccurenceValue = 52428800 // that's 2^19 * 100 wordScore += (wordOccurenceValue / 2) * (1 + word.length / dirName.length) firstOccurence = false } else { wordScore -= wordOccurenceValue * (1 - word.length / dirName.length) } wordOccurenceValue /= 2 } else { wordScore -= wordOccurenceValue wordOccurenceValue /= 2 if (depth === maxDepth - 1) { // make the penality bigger if the last part of the path doesn't include the word at all wordScore /= 2 } } wordOccurenceValue /= 2 } if (wordScore < 0) return fileScore += wordScore } if (fileScore > 0) { suggestions.push({ file, score: fileScore }) } }) suggestions.sort((a, b) => { const score = b.score - a.score return score !== 0 ? score : a.file.path.localeCompare(b.file.path) }) return suggestions.map(suggestion => suggestion.file) } } export default FuzzyPathSearch ================================================ FILE: src/lib/FuzzyPathSearch.spec.js ================================================ import FuzzyPathSearch from './FuzzyPathSearch' describe('simple search', () => { const guitars = [ { name: 'fender stratocaster', path: '' }, { name: 'fender telecaster', path: '' }, { name: 'gibson SG', path: '' } ] let fps beforeEach(() => { fps = new FuzzyPathSearch(guitars) }) it('should return an exact match', () => { const query = 'fender telecaster' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(1) expect(result[0]).toBe(guitars[1]) }) it('should return all possible matches', () => { const query = 'fender' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(2) expect(result.includes(guitars[0])).toBe(true) expect(result.includes(guitars[1])).toBe(true) }) }) describe('search with path', () => { const guitars = [ { name: 'stratocaster', path: '/fender/' }, { name: 'telecaster', path: '/fender/' }, { name: 'SG', path: '/Gibson/' } ] let fps beforeEach(() => { fps = new FuzzyPathSearch(guitars) }) it('should return an exact match', () => { const query = 'fender telecaster' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(1) expect(result[0]).toBe(guitars[1]) }) it('should return all possible matches', () => { const query = 'fender' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(2) expect(result.includes(guitars[0])).toBe(true) expect(result.includes(guitars[1])).toBe(true) }) }) describe('malformed queries', () => { const guitars = [ { name: 'fender stratocaster', path: '' }, { name: 'fender telecaster', path: '' }, { name: 'gibson SG', path: '' } ] let fps beforeEach(() => { fps = new FuzzyPathSearch(guitars) }) it('should handle different orders', () => { const query1 = 'telecaster fender' const result1 = fps.search(query1) const query2 = 'fender telecaster' const result2 = fps.search(query2) expect(result1).toEqual(result2) }) it('should handle diacritics', () => { const query = 'télécaster fender' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(1) expect(result[0]).toBe(guitars[1]) }) it('should handle extra spaces', () => { const query = 'fender telecaster' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(1) expect(result[0]).toBe(guitars[1]) }) it('should not care about casing', () => { const query = 'FENDER TeLeCASTER' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(1) expect(result[0]).toBe(guitars[1]) }) }) describe('result ordering', () => { it('should favor names over pathes', () => { const guitars = [ { name: 'telecaster', path: '/fender' }, { name: 'fender', path: '/telecaster' } ] const fps = new FuzzyPathSearch(guitars) const query = 'tele' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(2) expect(result[0]).toBe(guitars[0]) expect(result[1]).toBe(guitars[1]) }) it('should not care about the input order', () => { const guitars = [ { name: 'telecaster', path: '/fender' }, { name: 'fender', path: '/telecaster' } ] const fps = new FuzzyPathSearch(guitars) const query = 'tele' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(2) expect(result[0]).toBe(guitars[0]) expect(result[1]).toBe(guitars[1]) const reversed = guitars.reverse() const reversedFps = new FuzzyPathSearch(reversed) const reversedResult = reversedFps.search(query) expect(reversedResult).toBeInstanceOf(Array) expect(reversedResult.length).toEqual(2) expect(reversedResult[0]).toBe(guitars[1]) expect(reversedResult[1]).toBe(guitars[0]) }) it('should favor matches nearer the start of the path', () => { const guitars = [ { name: '2015', path: '/fender/telecaster/stratocaster/' }, { name: '2015', path: '/fender/stratocaster/' } ] const fps = new FuzzyPathSearch(guitars) const query = 'stratocaster' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(2) expect(result[0]).toBe(guitars[1]) expect(result[1]).toBe(guitars[0]) }) it('should fallback to the shortest path', () => { const guitars = [ { name: '2015', path: '/fender/telecaster/stratocaster/' }, { name: '2015', path: '/fender/stratocaster/' } ] const fps = new FuzzyPathSearch(guitars) const query = '2015' const result = fps.search(query) expect(result).toBeInstanceOf(Array) expect(result.length).toEqual(2) expect(result[0]).toBe(guitars[1]) expect(result[1]).toBe(guitars[0]) }) }) describe('successive searches', () => { it('should filter results as the query gets longer', () => { const guitars = [ { name: 'fender stratocaster', path: '' }, { name: 'fender telecaster', path: '' }, { name: 'gibson SG', path: '' } ] const fps = new FuzzyPathSearch(guitars) const result1 = fps.search('caster') expect(result1).toBeInstanceOf(Array) expect(result1.length).toEqual(2) expect(result1.includes(guitars[0])).toBe(true) expect(result1.includes(guitars[1])).toBe(true) const result2 = fps.search('caster tele') expect(result2).toBeInstanceOf(Array) expect(result2.length).toEqual(1) expect(result2.includes(guitars[1])).toBe(true) }) it('should reset when queries backtrack', () => { const guitars = [ { name: 'fender stratocaster', path: '' }, { name: 'fender telecaster', path: '' }, { name: 'gibson SG', path: '' } ] const fps = new FuzzyPathSearch(guitars) const result1 = fps.search('telecaster') expect(result1).toBeInstanceOf(Array) expect(result1.length).toEqual(1) expect(result1.includes(guitars[1])).toBe(true) const result2 = fps.search('caster') expect(result2).toBeInstanceOf(Array) expect(result2.length).toEqual(2) expect(result2.includes(guitars[0])).toBe(true) expect(result2.includes(guitars[1])).toBe(true) }) }) ================================================ FILE: src/lib/ModalContext.tsx ================================================ import React, { useState, useCallback, useContext, ReactNode } from 'react' interface TModalContext { modalStack: JSX.Element[] pushModal: (modal: JSX.Element) => void popModal: () => void } export const ModalContext = React.createContext( undefined ) interface ModalContextProviderProps { children: ReactNode } export const ModalContextProvider: React.FC = ({ children }) => { const [modalStack, setModalStack] = useState([]) const pushModal = useCallback((modal: JSX.Element) => { setModalStack(prevStack => [...prevStack, modal]) }, []) const popModal = useCallback(() => { setModalStack(prevStack => prevStack.slice(0, prevStack.length - 1)) }, []) return ( {children} ) } export const useModalContext = (): TModalContext => { const context = useContext(ModalContext) if (!context) { throw new Error( 'useModalContext must be used within a ModalContextProvider' ) } return context } export const ModalStack = (): JSX.Element | null => { const { modalStack } = useModalContext() if (modalStack.length === 0) return null else return modalStack[modalStack.length - 1] } ================================================ FILE: src/lib/ThumbnailSizeContext.tsx ================================================ import React, { useState, useCallback, useContext, createContext } from 'react' interface ThumbnailSizeContextProps { isBigThumbnail: boolean toggleThumbnailSize: () => void } const ThumbnailSizeContext = createContext({ isBigThumbnail: false, toggleThumbnailSize: () => {} }) const ThumbnailSizeContextProvider: React.FC = ({ children }) => { const [isBigThumbnail, setIsBigThumbnail] = useState(false) const toggleThumbnailSize = useCallback( () => setIsBigThumbnail(!isBigThumbnail), [isBigThumbnail, setIsBigThumbnail] ) return ( {children} ) } const useThumbnailSizeContext = (): ThumbnailSizeContextProps => useContext(ThumbnailSizeContext) export { ThumbnailSizeContext, ThumbnailSizeContextProvider, useThumbnailSizeContext } ================================================ FILE: src/lib/ViewSwitcherContext.tsx ================================================ import React, { useState, useContext, createContext, useEffect } from 'react' import { useClient, Q } from 'cozy-client' import logger from './logger' import { DOCTYPE_FILES_SETTINGS } from '@/lib/doctypes' interface QueryResult { data: [ { attributes: { preferredDriveViewType: string } } ] } // Constants const DEFAULT_VIEW_TYPE = 'list' interface ViewSwitcherContextProps { viewType: string switchView: (viewTypeParam: string) => Promise } const ViewSwitcherContext = createContext({ viewType: DEFAULT_VIEW_TYPE, switchView: async () => Promise.resolve() }) const ViewSwitcherContextProvider: React.FC = ({ children }) => { const client = useClient() const [viewType, setViewType] = useState(DEFAULT_VIEW_TYPE) useEffect(() => { const load = async (): Promise => { if (!client) return try { const result = (await client.query( Q(DOCTYPE_FILES_SETTINGS) )) as QueryResult if (!result?.data) return const preferred = result?.data?.[0]?.attributes?.preferredDriveViewType setViewType(preferred || DEFAULT_VIEW_TYPE) } catch (error) { logger.error('Failed to load settings:', error) setViewType(DEFAULT_VIEW_TYPE) } } void load() }, [client]) const switchView = async (viewTypeParam: string): Promise => { setViewType(viewTypeParam) if (!client) { logger.warn('Client not available') return } try { const { data } = (await client.query( Q(DOCTYPE_FILES_SETTINGS) )) as QueryResult if (!data) { logger.warn('Settings not found') return } const existing = data[0] await client.save({ ...(existing || { _type: DOCTYPE_FILES_SETTINGS }), attributes: { ...(existing?.attributes || {}), preferredDriveViewType: viewTypeParam } }) } catch (error) { logger.error('Failed to save view preference:', error) } } return ( {children} ) } const useViewSwitcherContext = (): ViewSwitcherContextProps => useContext(ViewSwitcherContext) export { ViewSwitcherContext, ViewSwitcherContextProvider, useViewSwitcherContext } ================================================ FILE: src/lib/appMetadata.js ================================================ import manifest from '../../manifest.webapp' const appMetadata = { slug: manifest.slug, version: manifest.version, name: manifest.name, prefix: manifest.name_prefix } export default appMetadata ================================================ FILE: src/lib/dacc/dacc-run.js ================================================ import endOfMonth from 'date-fns/endOfMonth' import format from 'date-fns/format' import startOfMonth from 'date-fns/startOfMonth' import subMonths from 'date-fns/subMonths' import CozyClient from 'cozy-client' import flag from 'cozy-flags' import log from 'cozy-logger' import { aggregateFilesSize, sendToRemoteDoctype } from '@/lib/dacc/dacc' import { schema } from '@/lib/doctypes' /** * This service aggregates files size by createdByApps slug and send them to the DACC. * See https://github.com/cozy/DACC for more insights about the DACC. * The service relies on a flag that contains the following information: * - measureName: the name of the dacc measure * - remoteDoctype: the remote doctype to use * - nonExcludedGroupLabel: when set, it is used to aggregate all the slugs not maching the excludedSlug * - excludedSlug: used to exclude a slug from the total aggregation */ export const run = async () => { log('info', 'Start dacc service') const client = CozyClient.fromEnv(process.env, { schema }) await flag.initialize(client) const daccFileSizeFlag = flag('drive.dacc-files-size-by-slug') if (!daccFileSizeFlag) { return } const { excludedSlug, nonExcludedGroupLabel, measureName, remoteDoctype, maxFileDateQuery } = daccFileSizeFlag const aggregationDate = new Date( maxFileDateQuery || endOfMonth(subMonths(new Date(), 1)) ) const sizesBySlug = await aggregateFilesSize(client, aggregationDate, { excludedSlug, nonExcludedGroupLabel }) if (Object.keys(sizesBySlug).length < 1) { log( 'info', `No files found to aggregate with date ${aggregationDate.toISOString()}` ) } const startDateMeasure = format(startOfMonth(aggregationDate), 'yyyy-LL-dd') await sendToRemoteDoctype(client, remoteDoctype, sizesBySlug, { measureName, startDate: startDateMeasure }) } ================================================ FILE: src/lib/dacc/dacc-run.spec.js ================================================ import endOfMonth from 'date-fns/endOfMonth' import subMonths from 'date-fns/subMonths' import CozyClient from 'cozy-client' import flag from 'cozy-flags' import log from 'cozy-logger' import { run } from './dacc-run' import { aggregateFilesSize } from '@/lib/dacc/dacc' jest.mock('cozy-flags') jest.mock('cozy-client') jest.mock('cozy-logger') jest.mock('lib/dacc/dacc') describe('dacc', () => { const maxGivenDate = '2022-01-01' const maxDate = new Date(maxGivenDate) beforeEach(() => { flag.mockReturnValue({ excludedSlug: 'excludedSlug', nonExcludedGroupLabel: 'nonExcludedGroupLabel', measureName: 'measureName', remoteDoctype: 'remoteDoctype', maxFileDateQuery: maxGivenDate }) }) afterEach(() => { jest.resetAllMocks() }) it('should do nothing when no flag is set', async () => { // Given flag.mockReturnValueOnce(null) // When await run() // Then expect(aggregateFilesSize).toHaveBeenCalledTimes(0) }) it('should aggregateFilesSize with max file date query', async () => { // Given const client = 'client' CozyClient.fromEnv.mockReturnValue(client) aggregateFilesSize.mockResolvedValueOnce([]) // When await run() // Then expect(aggregateFilesSize).toHaveBeenCalledWith(client, maxDate, { excludedSlug: 'excludedSlug', nonExcludedGroupLabel: 'nonExcludedGroupLabel' }) }) it('should aggregateFilesSize with end date of this month when max file date query not found', async () => { // Given const client = 'client' CozyClient.fromEnv.mockReturnValue(client) aggregateFilesSize.mockResolvedValueOnce([]) flag.mockReturnValue({ excludedSlug: 'excludedSlug', nonExcludedGroupLabel: 'nonExcludedGroupLabel', measureName: 'measureName', remoteDoctype: 'remoteDoctype' }) const endOfThisMonth = new Date(endOfMonth(subMonths(new Date(), 1))) // When await run() // Then expect(aggregateFilesSize).toHaveBeenCalledWith(client, endOfThisMonth, { excludedSlug: 'excludedSlug', nonExcludedGroupLabel: 'nonExcludedGroupLabel' }) }) it('should log when there is no sizes by slug', async () => { // Given aggregateFilesSize.mockResolvedValueOnce([]) // When await run() const date = new Date(maxDate).toISOString() // Then expect(log).toHaveBeenNthCalledWith( 2, 'info', `No files found to aggregate with date ${date}` ) }) it('should not log when there are sizes by slug', async () => { // Given aggregateFilesSize.mockResolvedValueOnce([{}]) // When await run() // Then expect(log).toHaveBeenCalledTimes(1) }) }) ================================================ FILE: src/lib/dacc/dacc.js ================================================ // @ts-check import log from 'cozy-logger' import { queryAllDocsWithFields } from '@/lib/dacc/query' /** * @typedef {object} Measure * See https://github.com/cozy/DACC for more insights * * @property {string} [createdBy] - The app slug that created the measure * @property {string} [measureName] - The measure name * @property {string} [startDate] - The startDate of the aggregation * @property {number} [value] - The measure value * @property {Array} [groups] - The measure groups */ const sendMeasureToDACC = async (client, remoteDoctype, measure) => { try { log('info', `Send ${JSON.stringify(measure)} to ${remoteDoctype}`) await client .getStackClient() .fetchJSON('POST', `/remote/${remoteDoctype}`, { data: JSON.stringify(measure), path: 'measure' }) } catch (error) { log( 'error', `Error while sending measure to remote doctype: ${error.message}` ) throw error } } /** * Send measures to a remote doctype * * @param {object} client - The CozyClient instance * @param {string} remoteDoctype - The remote doctype to use * @param {object} sizesBySlug - The hash table of values by slug * @param {{startDate, measureName}} params - The measure params */ export const sendToRemoteDoctype = async ( client, remoteDoctype, sizesBySlug, { startDate, measureName } ) => { const slugs = Object.keys(sizesBySlug) log( 'info', `Send ${slugs.length} measures ${measureName} on ${startDate} to ${remoteDoctype}...` ) for (const slug of slugs) { const measure = { createdBy: 'drive', measureName, startDate, value: sizesBySlug[slug], groups: [{ slug: slug }] } await sendMeasureToDACC(client, remoteDoctype, measure) } } const convertFileSizeInMB = file => { // The size is converted in MB to avoid too large values return parseInt(file.size) / (1000 * 1000) // Size in million of Bytes (MB) } /** * Aggregate file size values by slug * * @param {object} client - The CozyClient instance * @param {Date} endDate - The max file date to query * @returns {Promise} The hash table of values by slug */ export const aggregateFilesSize = async ( client, endDate, { excludedSlug = '', nonExcludedGroupLabel = '' } = {} ) => { const sizesBySlug = { trashed: 0 } const resp = await queryAllDocsWithFields(client) for (const entry of resp) { const file = entry.doc const uploadedAt = new Date(file?.cozyMetadata?.uploadedAt || Date.now()) if (file.type !== 'file' || uploadedAt > endDate) { // Skip this doc continue } const slug = file.cozyMetadata?.createdByApp || 'unknown' const sizeMB = convertFileSizeInMB(file) if (file.trashed) { // Special case for trashed files sizesBySlug.trashed += sizeMB } else { if (slug in sizesBySlug) { sizesBySlug[slug] += sizeMB } else { sizesBySlug[slug] = sizeMB } } } if (excludedSlug && nonExcludedGroupLabel) { // Aggregate values const totalNonExcluded = aggregateNonExcludedSlugs( sizesBySlug, excludedSlug ) sizesBySlug[nonExcludedGroupLabel] = totalNonExcluded } // Round values for (const slug of Object.keys(sizesBySlug)) { sizesBySlug[slug] = Math.round(sizesBySlug[slug] * 1000) / 1000 } return sizesBySlug } /** * Aggregate all values except for excluded slug * * @param {object} sizesBySlug - The hash table of values by slug * @param {string} exclusionSlug - The slug to exclude */ export const aggregateNonExcludedSlugs = (sizesBySlug, exclusionSlug) => { let totalSize = 0 for (const slug of Object.keys(sizesBySlug)) { if (!slug.includes(exclusionSlug) && slug !== 'trashed') { totalSize += sizesBySlug[slug] } } return totalSize } ================================================ FILE: src/lib/dacc/dacc.spec.js ================================================ import { aggregateFilesSize, aggregateNonExcludedSlugs } from '@/lib/dacc/dacc' import { queryAllDocsWithFields } from '@/lib/dacc/query' jest.mock('lib/dacc/query') const mockedFilesQueryResponse = [ { doc: { type: 'file', size: 1048576, cozyMetadata: { createdByApp: 'drive', uploadedAt: '2021-01-01' } } }, { doc: { type: 'file', size: 3145728, cozyMetadata: { createdByApp: 'drive', uploadedAt: '2021-01-01' } } }, { doc: { type: 'file', size: 4567892, cozyMetadata: { createdByApp: 'drive' } } }, { doc: { type: 'file', size: 2097152, cozyMetadata: { createdByApp: 'edf', uploadedAt: '2021-01-01' } } }, { doc: { type: 'file', size: 8388608, cozyMetadata: { createdByApp: 'maif', uploadedAt: '2021-01-01' } } }, { doc: { type: 'file', size: 6291456, cozyMetadata: { createdByApp: 'maif-vie', uploadedAt: '2021-01-01' } } }, { doc: { type: 'file', trashed: true, size: 2290000, cozyMetadata: { createdByApp: 'maif-vie', uploadedAt: '2021-01-01' } } } ] describe('aggregateFilesSize', () => { beforeEach(() => { queryAllDocsWithFields.mockResolvedValue(mockedFilesQueryResponse) }) it('should aggregate sizes by slug', async () => { const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01')) expect(Object.keys(sizesBySlug)).toEqual([ 'trashed', 'drive', 'edf', 'maif', 'maif-vie' ]) expect(sizesBySlug['drive']).toEqual(4.194) expect(sizesBySlug['edf']).toEqual(2.097) expect(sizesBySlug['maif']).toEqual(8.389) expect(sizesBySlug['maif-vie']).toEqual(6.291) expect(sizesBySlug['trashed']).toEqual(2.29) }) it('should aggregate all sizes but excluded slug', async () => { const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'), { excludedSlug: 'maif', nonExcludedGroupLabel: 'not-maif' }) const expectedValue = Math.round((sizesBySlug['drive'] + sizesBySlug['edf']) * 1000) / 1000 expect(sizesBySlug['not-maif']).toEqual(expectedValue) }) it('should skip docs not file or without uploadedAt', async () => { queryAllDocsWithFields.mockResolvedValueOnce([ { doc: { type: 'file', size: 4567892, cozyMetadata: { createdByApp: 'drive' } } }, { doc: { type: 'directory' } } ]) const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01')) expect(sizesBySlug).toEqual({ trashed: 0 }) }) }) describe('aggregateNonExcludedSlugs', () => { it('should aggregate all sizes but excluded slug', async () => { const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01')) const totalSize = aggregateNonExcludedSlugs(sizesBySlug, 'maif') expect(totalSize).toEqual(sizesBySlug['drive'] + sizesBySlug['edf']) }) it('should aggregate nothing when excluded slug is empty', async () => { const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01')) const totalSize = aggregateNonExcludedSlugs(sizesBySlug, '') expect(totalSize).toEqual(0) }) }) ================================================ FILE: src/lib/dacc/query.js ================================================ import { DOCTYPE_FILES } from '@/lib/doctypes' /** * Query all files by filtering on required fields * * @param {object} client - The CozyClient instance * @returns {Promise} The files array */ export const queryAllDocsWithFields = async client => { const resp = await client .getStackClient() .fetchJSON( 'GET', `/data/${DOCTYPE_FILES}/_all_docs?Fields=_id,trashed,name,size,type,cozyMetadata&DesignDocs=false&include_docs=true` ) return resp.rows } ================================================ FILE: src/lib/doctypes.js ================================================ import extraDoctypes from '@/lib/extraDoctypes' import { Contact, Group } from '@/models' export const DOCTYPE_FILES = 'io.cozy.files' export const DOCTYPE_FILES_SETTINGS = 'io.cozy.files.settings' export const DOCTYPE_DRIVE_SETTINGS = 'io.cozy.drive.settings' export const DOCTYPE_FILES_ENCRYPTION = 'io.cozy.files.encryption' export const DOCTYPE_FILES_SHORTCUT = 'io.cozy.files.shortcuts' export const DOCTYPE_ALBUMS = 'io.cozy.photos.albums' export const DOCTYPE_PHOTOS_SETTINGS = 'io.cozy.photos.settings' export const DOCTYPE_APPS = 'io.cozy.apps' export const DOCTYPE_CONTACTS = 'io.cozy.contacts' export const DOCTYPE_KONNECTORS = 'io.cozy.konnectors' export const NEXTCLOUD_MIGRATIONS_DOCTYPE = 'io.cozy.nextcloud.migrations' export const DOCTYPE_CONTACTS_VERSION = 2 export const schema = { files: { doctype: DOCTYPE_FILES, relationships: { old_versions: { type: 'has-many', doctype: 'io.cozy.files.versions' }, encryption: { type: 'io.cozy.files:has-many', doctype: DOCTYPE_FILES_ENCRYPTION } } }, contacts: { doctype: Contact.doctype, doctypeVersion: DOCTYPE_CONTACTS_VERSION }, groups: { doctype: Group.doctype }, versions: { doctype: 'io.cozy.files.versions' }, ...extraDoctypes } ================================================ FILE: src/lib/entries.js ================================================ /** * Get type from the entries * @param {IOCozyFile[]} entries - List of files moved * @returns {string} - Type from the entries */ export const getEntriesType = entries => { const types = entries.reduce((acc, entry) => { acc.add(entry.type) return acc }, new Set()) if (types.size === 1 && types.has('directory')) { return 'directory' } if (types.size === 1 && types.has('file')) { return 'file' } return 'element' } /** * Get translated type from the entries * @param {IOCozyFile[]} entries - List of files * @param {Function} t - Translation function * @returns {string} - Translated type from the entries */ export const getEntriesTypeTranslated = (t, entries) => { const type = getEntriesType(entries) return t(`EntriesType.${type}`, entries.length) } ================================================ FILE: src/lib/entries.spec.js ================================================ import { getEntriesType } from '@/lib/entries' describe('getEntriesType', () => { it('should return file for entries only file', () => { const res = getEntriesType([ { type: 'file' }, { type: 'file' }, { type: 'file' } ]) expect(res).toBe('file') }) it('should return folder for entries only folder', () => { const res = getEntriesType([ { type: 'directory' }, { type: 'directory' }, { type: 'directory' } ]) expect(res).toBe('directory') }) it('should return element for entries with multiples types', () => { const res = getEntriesType([ { type: 'file' }, { type: 'directory' }, { type: 'file' } ]) expect(res).toBe('element') }) it('should return element if something else from file or directory', () => { const res = getEntriesType([ { type: 'something' }, { type: 'something' }, { type: 'something' } ]) expect(res).toBe('element') }) }) ================================================ FILE: src/lib/extraDoctypes.js ================================================ export default {} ================================================ FILE: src/lib/flags.js ================================================ import flag from 'cozy-flags' export const initFlags = () => { let activateFlags = flag('switcher') === true ? true : false if (process.env.NODE_ENV !== 'production' && flag('switcher') === null) { activateFlags = true } const searchParams = new URL(window.location).searchParams if (!activateFlags && searchParams.get('flags') !== null) { activateFlags = true } if (activateFlags) { flagsList() } } // flagName should use kebab case const flagsList = () => { flag('switcher', true) flag('debug') flag('drive.onlyoffice.editorToolbarHeight') // flagName should use kebab case flag('drive.logger') flag('drive.dacc-files-size-by-slug') flag('drive.breadcrumb.showCompleteBreadcrumbOnPublicPage') // flagName should use kebab case flag('drive.hide-nextcloud-dev') flag('sharing.auto-open-settings.enabled') flag('sharing.generate-link-button.enabled') } ================================================ FILE: src/lib/getFileMimetype.js ================================================ import mime from 'mime-types' const getMimetypeFromFilename = name => { return mime.lookup(name) || 'application/octet-stream' } const mappingMimetypeSubtype = { word: 'text', text: 'text', zip: 'zip', pdf: 'pdf', spreadsheet: 'sheet', excel: 'sheet', sheet: 'sheet', presentation: 'slide', powerpoint: 'slide' } export const getFileMimetype = collection => (mime = '', name = '') => { const mimetype = mime === 'application/octet-stream' ? getMimetypeFromFilename(name.toLowerCase()) : mime const [type, subtype] = mimetype.split('/') if (collection[type]) { return type } if (type === 'application') { const existingType = subtype.match( Object.keys(mappingMimetypeSubtype).join('|') ) return existingType ? mappingMimetypeSubtype[existingType[0]] : undefined } return undefined } ================================================ FILE: src/lib/getMimeTypeIcon.js ================================================ import get from 'lodash/get' import IconAudio from 'cozy-ui/transpiled/react/Icons/FileTypeAudio' import IconBin from 'cozy-ui/transpiled/react/Icons/FileTypeBin' import IconCode from 'cozy-ui/transpiled/react/Icons/FileTypeCode' import IconFiles from 'cozy-ui/transpiled/react/Icons/FileTypeFiles' import IconFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder' import IconImage from 'cozy-ui/transpiled/react/Icons/FileTypeImage' import IconNote from 'cozy-ui/transpiled/react/Icons/FileTypeNote' import IconPdf from 'cozy-ui/transpiled/react/Icons/FileTypePdf' import IconSheet from 'cozy-ui/transpiled/react/Icons/FileTypeSheet' import IconSlide from 'cozy-ui/transpiled/react/Icons/FileTypeSlide' import IconText from 'cozy-ui/transpiled/react/Icons/FileTypeText' import IconVideo from 'cozy-ui/transpiled/react/Icons/FileTypeVideo' import IconZip from 'cozy-ui/transpiled/react/Icons/FileTypeZip' import IconDocs from '@/assets/icons/icon-docs.svg' import { getFileMimetype } from '@/lib/getFileMimetype' /** * Returns the appropriate icon for a given file based on its mime type. * * @param {boolean} isDirectory * @param {string} name * @param {string} mime * @returns {import('react').ReactNode} */ const getMimeTypeIcon = (isDirectory, name, mime) => { if (isDirectory) { return IconFolder } else if (/\.cozy-note$/.test(name)) { return IconNote } else if (/\.docs-note$/.test(name)) { return IconDocs } else { const iconsByMimeType = { audio: IconAudio, bin: IconBin, code: IconCode, image: IconImage, pdf: IconPdf, slide: IconSlide, sheet: IconSheet, text: IconText, video: IconVideo, zip: IconZip } const type = getFileMimetype(iconsByMimeType)(mime, name) return get(iconsByMimeType, type, IconFiles) } } export default getMimeTypeIcon ================================================ FILE: src/lib/konnectors.js ================================================ import { getReferencedBy } from 'cozy-client' import { DOCTYPE_KONNECTORS } from '@/lib/doctypes' /** * Returns the slug of the konnector that produced the given file, or null * if the file is not referenced by any konnector. * * Konnector-created files carry an explicit `io.cozy.konnectors/` * entry in their `referenced_by` list. We read the first such reference * and strip the doctype prefix to recover the bare slug (e.g. "edf"). * * `cozyMetadata.createdByApp` is intentionally not used: it is set by any * app or konnector that creates files (drive, notes, ...), so its value * can be an app slug that does not exist as a konnector and would 404 * against `GET /konnectors/` on cozy-stack. * * @param {import('cozy-client/types/types').IOCozyFile} file - A file doc with its references hydrated. * @returns {string|null} The konnector slug, or null when the file has no konnector reference. */ export const getKonnectorSlugFromFile = file => { const ref = getReferencedBy(file, DOCTYPE_KONNECTORS)[0] return ref?.id?.replace(`${DOCTYPE_KONNECTORS}/`, '') ?? null } ================================================ FILE: src/lib/logger.js ================================================ import minilog from 'cozy-minilog' const logger = minilog(`cozy-drive`) minilog.enable() minilog.suggest.allow(`cozy-drive`, 'log') minilog.suggest.allow(`cozy-drive`, 'info') export default logger ================================================ FILE: src/lib/migration/qualification.js ================================================ import { get, has, isEmpty, omit, sortBy } from 'lodash' import { models, Q } from 'cozy-client' import log from 'cozy-logger' const { Qualification } = models.document const { saveFileQualification } = models.file /** * Query the files indexed on their updatedAt date. * * @param {object} client - The CozyClient instance * @param {string} date - The starting date to query * @param {number} limit - The maximum number of files to return */ export const queryFilesFromDate = async (client, date, limit) => { const query = Q('io.cozy.files') .where({ type: 'file', 'cozyMetadata.updatedAt': { $gt: date }, trashed: false }) .indexFields(['type', 'cozyMetadata.updatedAt']) .limitBy(limit) .sortBy([{ type: 'asc' }, { 'cozyMetadata.updatedAt': 'asc' }]) return client.query(query) } /** * From a list of files, find the most recent updatedAt value * * @param {object} files - The unsorted files * @returns {string} The most recent updatedAt value */ export const getMostRecentUpdatedDate = files => { const filesWithDate = files.filter(file => get(file, 'data.attributes.cozyMetadata.updatedAt') ) const sortedFiles = sortBy(filesWithDate, [ 'data.attributes.cozyMetadata.updatedAt' ]) return sortedFiles.length > 0 ? get( sortedFiles[sortedFiles.length - 1], 'data.attributes.cozyMetadata.updatedAt' ) : null } /** * Extract the old qualification attributes from a file. * * @param {object} file - The file to extract old attributes from * @returns {object} The old qualification attributes */ const oldQualificationAttributes = file => { const oldQualification = {} Object.assign( oldQualification, has(file, 'metadata.id') ? { id: file.metadata.id } : null, has(file, 'metadata.label') ? { label: file.metadata.label } : null, has(file, 'metadata.classification') ? { classification: file.metadata.classification } : null, has(file, 'metadata.subClassification') ? { subClassification: file.metadata.subClassification } : null, has(file, 'metadata.categorie') ? { categorie: file.metadata.categorie } : null, has(file, 'metadata.category') ? { category: file.metadata.category } : null, has(file, 'metadata.categories') ? { categories: file.metadata.categories } : null, has(file, 'metadata.subject') ? { subject: file.metadata.subject } : null, has(file, 'metadata.subjects') ? { subjects: file.metadata.subjects } : null ) return isEmpty(oldQualification) ? null : oldQualification } /** * Keep only the files with old qualification attributes * * @param {Array} files - The files to process * @returns {Array} The list of files having old qualification attributes */ export const extractFilesToMigrate = files => { return files.filter(file => { const oldAttributes = oldQualificationAttributes(file) // This case can happen when a file was previously migrated, as we keep // the id for retro-compatibility if (has(oldAttributes, 'id') && !has(oldAttributes, 'label')) { return false } return oldAttributes }) } /** * We changed some labels set by cozy-scanner: this method * transform them with the new one. * * @param {string} oldLabel - The old qualification label * @returns {string} The new qualification label */ const getNewLabelSetFromCozyScanner = oldLabel => { if (oldLabel === 'registration') { return 'vehicle_registration' } if (oldLabel === 'insurance_card') { return 'national_health_insurance_card' } return oldLabel } /** * Remove the old qualification attributes from a file. * * @param {object} file - The file with old attributes * @returns {object} The file without the old attributes */ export const removeOldQualificationAttributes = file => { const oldAttributes = oldQualificationAttributes(file) // keep the id for retro-compatibility: it is used by cozy-scanner to display the label if (has(oldAttributes, 'id')) { delete oldAttributes.id } if (oldAttributes) { const attributesPath = Object.keys(oldAttributes).map(oldAttribute => { return `metadata.${oldAttribute}` }) return omit(file, attributesPath) } return file } /** * Takes a file with an old qualification set by cozy-scanner and * returns the new qualification, by the label. * * @param {object} file - The file qualified by cozy-scanner * @returns {Qualification} The new qualification */ const getNewQualificationSetFromCozyScanner = file => { const qualificationLabel = get(file, 'metadata.label') const label = getNewLabelSetFromCozyScanner(qualificationLabel) return Qualification.getByLabel(label) } /** * Takes a file with an old qualification set by a konnector and * returns the new qualification. * The qualification is fixed by a set of rules primarily based on the * contentAuthor and old attributes in certain cases. * * @param {object} file - The file qualified by a konnector * @returns {Qualification} The new qualification */ const getNewQualificationSetFromKonnector = file => { const contentAuthor = get(file, 'metadata.contentAuthor') const classification = get(file, 'metadata.classification') const categories = get(file, 'metadata.categories') // See https://github.com/konnectors/cozy-konnector-digiposte/blob/master/src/index.js // See https://github.com/konnectors/orangeapi/blob/master/src/index.js if (contentAuthor === 'orange') { if (classification === 'invoicing') { if (categories && categories.length > 0) { if (categories[0] === 'phone') { return Qualification.getByLabel('phone_invoice') } else if (categories[0] === 'isp') { return Qualification.getByLabel('telecom_invoice') // it might be both isp and phone } } } else if (classification === 'payslip') { return Qualification.getByLabel('pay_sheet') } } // See https://github.com/konnectors/cozy-konnector-sncf/blob/master/src/index.js else if (contentAuthor === 'sncf') { return Qualification.getByLabel('transport_invoice') } // See https://github.com/konnectors/cozy-konnector-bouyguestelecom/blob/src/index.js // See https://github.com/konnectors/cozy-konnector-bouyguesbox/blob/src/index.js else if (contentAuthor === 'bouygues') { return Qualification.getByLabel('telecom_invoice') } // See https://github.com/konnectors/cozy-konnector-free-mobile/blob/master/src/index.js // See https://github.com/konnectors/cozy-konnector-free/blob/master/src/index.js if (contentAuthor === 'free') { if (categories && categories.length > 0) { if (categories[0] === 'isp') { return Qualification.getByLabel('isp_invoice') } else if (categories[0] === 'phone') { return Qualification.getByLabel('phone_invoice') } } } // See https://github.com/konnectors/edf/blob/master/src/index.js if (contentAuthor === 'edf') { return Qualification.getByLabel('energy_invoice') } // https://github.com/konnectors/cozy-konnector-ameli/blob/master/src/index.js if (contentAuthor === 'ameli') { return Qualification.getByLabel('health_invoice') } // https://github.com/konnectors/impots/blob/master/src/metadata.js if (contentAuthor === 'impots.gouv') { if (classification === 'tax_notice') { return Qualification.getByLabel('tax_notice') } else if (classification === 'tax_return') { return Qualification.getByLabel('tax_return') } else if (classification === 'tax_timetable') { return Qualification.getByLabel('tax_timetable') } else if (classification === 'mail') { return Qualification.getByLabel('receipt') .setSourceCategory('gov') .setSourceSubCategory('tax') .setSubjects(['tax']) } } return null } /** * Get the new qualification from a file with old qualification attributes. * * @param {object} file - The file to requalify * @returns {object} The new qualification */ export const getFileRequalification = file => { try { const hasQualificationLabel = has(file, 'metadata.label') // cozy-scanner stores the qualification label but konnectors don't return hasQualificationLabel ? getNewQualificationSetFromCozyScanner(file) : getNewQualificationSetFromKonnector(file) } catch (e) { log('error', `The file cannot be migrated. ${e}`) return null } } /** * Migrate files by removing old qualification attributes and * setting the new qualification. * * @param {object} client - The CozyClient instance * @param {Array} files - The files to migrate * @returns {Array} The saved files */ export const migrateQualifiedFiles = async (client, files) => { let updatedFiles = [] for (const file of files) { const newQualification = getFileRequalification(file) if (newQualification) { const cleanedFile = removeOldQualificationAttributes(file) const newFile = await saveFileQualification( client, cleanedFile, newQualification ) updatedFiles.push(newFile) } else { log('warn', `No migration case found for the file ${file._id}`) } } return updatedFiles } ================================================ FILE: src/lib/migration/qualification.spec.js ================================================ import log from 'cozy-logger' import { extractFilesToMigrate, getFileRequalification, getMostRecentUpdatedDate, removeOldQualificationAttributes } from '@/lib/migration/qualification' jest.mock('cozy-logger', () => jest.fn()) describe('qualification migration', () => { it('should extract files to migrate based on qualification attributes', () => { const fileNoQualif = { metadata: { datetime: '2020-01-01' } } const fileFullQualif = { metadata: { id: '1', label: 'dummy', classification: 'dummy', subClassification: 'dummy', categorie: 'dummy', category: 'dummy', categories: ['dummies'], subject: 'dummy', subjects: ['dummy'] } } const files = [fileNoQualif, fileFullQualif] const filesToMigrate = extractFilesToMigrate(files) expect(filesToMigrate).toHaveLength(1) expect(filesToMigrate[0]).toEqual(fileFullQualif) }) it('should not extract files with id but not label attributes', () => { const file = { metadata: { id: '1', qualification: {} } } expect(extractFilesToMigrate([file])).toHaveLength(0) }) it('should get the new qualification for a file qualified by cozy-client', () => { const file = { metadata: { id: '22', classification: 'invoicing', categorie: 'health', label: 'health_invoice' } } const qualif = getFileRequalification(file) expect(qualif).toEqual({ icon: 'heart', label: 'health_invoice', purpose: 'invoice', sourceCategory: 'health' }) }) it('should get the new qualification for a file qualified by a konnector', () => { const file = { metadata: { contentAuthor: 'ameli', classification: 'invoicing', categorie: 'health', label: 'health_invoice' } } const qualif = getFileRequalification(file) expect(qualif).toEqual({ icon: 'heart', label: 'health_invoice', purpose: 'invoice', sourceCategory: 'health' }) }) it('should log an error null when no qualification is possible', () => { const file = { metadata: { label: 'fake_label' } } expect(getFileRequalification(file)).toBeNull() expect(log).toHaveBeenCalledWith('error', expect.anything()) }) it('should remove old qualification attributes', () => { const file = { metadata: { id: 1, label: 'label', classification: 'classification', subClassification: 'subClassification', categorie: 'categorie', category: 'category', categories: 'categories', subject: 'subject', subjects: 'subjects', datetime: '2020-10-10' } } expect(removeOldQualificationAttributes(file)).toEqual({ metadata: { id: 1, datetime: '2020-10-10' } }) file.metadata = {} expect(removeOldQualificationAttributes(file)).toEqual(file) }) it('should find the most recent date in a list of files', () => { let files = [] expect(getMostRecentUpdatedDate(files)).toBeNull() files = [{}, {}] expect(getMostRecentUpdatedDate(files)).toBeNull() files = [ {}, { _id: '456', data: { attributes: { cozyMetadata: { updatedAt: '2020-01-01' } } } } ] expect(getMostRecentUpdatedDate(files)).toEqual('2020-01-01') files = [ {}, {}, { _id: '123', data: { attributes: { cozyMetadata: { updatedAt: '2020-01-01' } } } } ] expect(getMostRecentUpdatedDate(files)).toEqual('2020-01-01') files = [ { _id: '123', data: { attributes: { cozyMetadata: { updatedAt: '2020-01-02' } } } }, {}, {}, { _id: '456', data: { attributes: { cozyMetadata: { updatedAt: '2020-01-01' } } } } ] expect(getMostRecentUpdatedDate(files)).toEqual('2020-01-02') }) }) ================================================ FILE: src/lib/path.js ================================================ /** * Join two paths together ensuring there is only one slash between them * @param {string} start * @param {string} end * @returns */ export function joinPath(start, end) { return `${start}${start.endsWith('/') ? '' : '/'}${end}` } /** * Get the parent folder path from a given path * @param {string} path The path to get the parent folder from * @returns {string|undefined} The path of the parent folder or undefined if the path is the root folder */ export const getParentPath = path => { if (path === '/') return undefined const parts = path.split('/') parts.pop() return parts.length === 1 ? '/' : parts.join('/') } ================================================ FILE: src/lib/path.spec.js ================================================ import { getParentPath } from './path' it('getParentPath', () => { expect(getParentPath('/')).toBeUndefined() expect(getParentPath('/folder1')).toEqual('/') expect(getParentPath('/folder1/folder2/folder3')).toEqual('/folder1/folder2') expect(getParentPath('/folder1/folder2/file1.png')).toEqual( '/folder1/folder2' ) expect(getParentPath('/folder1/folder2')).toEqual('/folder1') }) ================================================ FILE: src/lib/queries.js ================================================ import { hasQueryBeenLoaded } from 'cozy-client' /** * Check if the query has been loaded and if it has data * * @param {import('cozy-client/types/types').UseQueryReturnValue} queryResult * @returns {boolean} */ export const hasDataLoaded = queryResult => { return hasQueryBeenLoaded(queryResult) && queryResult.data } export const parseFolderQueryId = maybeFolderQueryId => { const splitted = maybeFolderQueryId.split(' ') if (splitted.length !== 4) { return null } return { type: splitted[0], folderId: splitted[1], sortAttribute: splitted[2], sortOrder: splitted[3] } } export const formatFolderQueryId = ( type, folderId, sortAttribute, sortOrder, driveId = '' ) => { return `${type} ${folderId} ${sortAttribute} ${sortOrder} ${driveId}`.trim() } /** * Get the query for folder if given the query for files * and vice versa. * * If given the queryId `directory id123 name desc`, will return * the query `files id123 name desc`. */ export const getMirrorQueryId = queryId => { const { type, folderId, sortAttribute, sortOrder } = parseFolderQueryId(queryId) const otherType = type === 'directory' ? 'file' : 'directory' const otherQueryId = formatFolderQueryId( otherType, folderId, sortAttribute, sortOrder ) return otherQueryId } ================================================ FILE: src/lib/react-cozy-helpers/ModalManager.jsx ================================================ import React from 'react' import { connect } from 'react-redux' const SHOW_MODAL = 'SHOW_MODAL' const HIDE_MODAL = 'HIDE_MODAL' const reducer = (state = { show: false, component: null }, action) => { switch (action.type) { case SHOW_MODAL: return { show: true, component: action.component } case HIDE_MODAL: return { show: false, component: null } default: return state } } export default reducer export const showModal = component => ({ type: SHOW_MODAL, component, meta: { hideActionMenu: true } }) const hideModal = (meta = {}) => ({ type: HIDE_MODAL, meta }) export const ModalManager = connect(state => ({ ...state.ui.modal }))(({ show, component, dispatch }) => { if (!show) return null return React.cloneElement(component, { onClose: meta => dispatch(hideModal(meta)) }) }) ================================================ FILE: src/lib/react-cozy-helpers/QueryParameter.js ================================================ const arrToObj = (obj = {}, [key, val = true]) => { obj[key] = decodeURIComponent(val) return obj } const getQueryParameter = () => window.location.search .substring(1) .split('&') .map(varval => varval.split('=')) .reduce(arrToObj, {}) export default getQueryParameter ================================================ FILE: src/lib/react-cozy-helpers/QueryParameter.spec.js ================================================ import getQueryParameter from './QueryParameter' describe('getQueryParameter', () => { afterEach(() => { window.history.replaceState({}, '', '/') }) it('should decode URI string', () => { window.history.replaceState({}, '', '?username=N%C3%B6%C3%A9') const { username } = getQueryParameter() expect(username).toBe('Nöé') }) it('should keep string with accent unchanged', () => { window.history.replaceState({}, '', '?username=N%C3%B6%C3%A9') const { username } = getQueryParameter() expect(username).toBe('Nöé') }) it('should not modify string with special characters', () => { window.history.replaceState( {}, '', '?sharecode=eyJ_hbGc%2FiOiJ.S3mJz-B90iu.8D0%23JwCK' ) const { sharecode } = getQueryParameter() expect(sharecode).toBe('eyJ_hbGc/iOiJ.S3mJz-B90iu.8D0#JwCK') }) }) ================================================ FILE: src/lib/react-cozy-helpers/index.js ================================================ import { combineReducers } from 'redux' import modalReducer from './ModalManager' export default combineReducers({ modal: modalReducer }) export { ModalManager, showModal } from './ModalManager' export { default as getQueryParameter } from './QueryParameter' ================================================ FILE: src/lib/registerClientPlugins.js ================================================ import flag from 'cozy-flags' import { RealtimePlugin } from 'cozy-realtime' const registerClientPlugins = client => { client.registerPlugin(RealtimePlugin) client.registerPlugin(flag.plugin) } export default registerClientPlugins ================================================ FILE: src/lib/sentry.js ================================================ import * as Sentry from '@sentry/react' import { useEffect } from 'react' import { Routes, useLocation, useNavigationType, createRoutesFromChildren, matchRoutes } from 'react-router-dom' import appMetadata from '@/lib/appMetadata' Sentry.init({ dsn: 'https://05f3392b39bb4504a179c95aa5b0e8f6@errors.cozycloud.cc/41', environment: process.env.NODE_ENV, release: appMetadata.version, integrations: [ // We also want to capture the `console.error` to, among other things, // report the logs present in the `try/catch Sentry.captureConsoleIntegration({ levels: ['error'] }), Sentry.reactRouterV6BrowserTracingIntegration({ useEffect, useLocation, useNavigationType, createRoutesFromChildren, matchRoutes }) ], tracesSampleRate: 0.1, // React log these warnings(bad Proptypes), in a console.error, // it is not relevant to report this type of information to Sentry ignoreErrors: [/^Warning: /] }) export const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes) ================================================ FILE: src/locales/ar.json ================================================ { "Nav": { "item_drive": "القرص", "item_recent": "الحديثة", "item_activity": "النشاط", "item_settings": "الإعدادات", "btn-client-web": "تحصّل على كوزي", "btn-client-mobile": "تحصّل على كوزي لجهازك المحمول !", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile", "link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8" }, "breadcrumb": { "title_drive": "القرص", "title_recent": "الحديثة", "title_shared": "التي شاركتها", "title_activity": "النشاط" }, "Toolbar": { "more": "المزيد" }, "toolbar": { "item_more": "المزيد", "menu_select": "تحديد العناصر", "menu_download_folder": "مُجلّد التنزيل", "share": "شارك", "select_all": "تحديد الكل", "select_all_mobile": "الكل", "clear_selection": "مسح التحديد", "clear_selection_mobile": "مسح", "delete_shared_drive": "حذف محرك الأقراص المشترك", "sharings_tab_all": "الكل", "sharings_tab_drives": "محركات الأقراص" }, "Files": { "share": { "cta": "شارك", "details": { "title": "تفاصيل المشاركة" }, "sharedWithMe": "مُشارَك معي", "shareByEmail": { "subtitle": "عبر البريد الإلكتروني", "email": "إلى :", "send": "إرسل" }, "sharingLink": { "title": "رابط المشاركة", "copy": "نسخ", "copied": "تم نسخه" }, "protectedShare": { "title": "قريبًا !" }, "close": "غلق", "gettingLink": "جارٍ جلب رابطك …" } }, "table": { "head_name": "الإسم", "head_update": "آخر تحديث", "head_size": "الحجم", "row_size_symbols": { "B": "ب", "KB": "كب", "MB": "مب", "GB": "جب", "TB": "تب" }, "load_more": "عرض المزيد" }, "Storage": { "title": "التخزين", "availability": "متاح %{smart_count} جيجابايت", "increase": "زيادة مساحتك" }, "SelectionBar": { "share": "مشاركة", "download": "تنزيل", "trash": "حذف", "rename": "تعديل التسمية", "restore": "إسترجاع", "close": "غلق" }, "DeleteConfirm": { "cancel": "إلغاء", "delete": "حذف" }, "emptytrashconfirmation": { "cancel": "إلغاء", "delete": "حذف الكل" }, "DestroyConfirm": { "cancel": "إلغاء" }, "quotaalert": { "confirm": "نعم" }, "loading": { "message": "تحميل" }, "error": { "download_file": { "offline": "يتوجب أن تكون متصلا لتنزيل هذا الملف", "missing": "إنّ الملف مفقود" } }, "alert": { "could_not_open_file": "لقد تعذّر فتح هذا الملف", "item_copied": "تم نسخ عنصر واحد", "items_copied": "تم نسخ %{count} عنصر", "item_cut": "تم قص عنصر واحد", "items_cut": "تم قص %{count} عنصر", "item_moved": "تم نقل عنصر واحد", "items_moved": "تم نقل %{count} عنصر", "item_pasted": "تم نقل عنصر واحد", "items_pasted": "تم نقل %{count} عنصر", "copy_files_only": "لا يمكن نسخ المجلدات", "copy_not_allowed": "عملية النسخ غير مسموحة في هذا العرض.", "cut_not_allowed": "عملية القص غير مسموحة في هذا العرض.", "paste_error": "حدث خطأ أثناء لصق الملفات", "paste_failed": "فشل في لصق الملفات", "paste_sharing_error": "لا يمكن لصق الملفات بسبب قيود المشاركة. يرجى استخدام إجراء النقل بدلاً من ذلك.", "paste_same_folder_skipped": "لا يمكن نقل العناصر إلى نفس المجلد الذي توجد فيه بالفعل.", "paste_not_allowed": "لا يمكنك اللصق في هذا المجلد", "cannot_move_shared_drive": "لا يمكنك نقل مجلد القرص المشترك", "cannot_copy_shared_drive": "لا يمكنك نسخ مجلد محرك الأقراص المشترك" }, "UploadQueue": { "close": "غلق", "item": { "pending": "معلق" } }, "Viewer": { "close": "إغلاق", "noviewer": { "download": "نزِّل هذا الملف" }, "actions": { "download": "تنزيل" }, "loading": { "retry": "إعادة المحاولة" } }, "actions": { "details": "تفاصيل", "personalizeFolder": { "label": "تخصيص المجلد" }, "summariseByAI": "تلخيص" }, "FolderCustomizer": { "title": "تخصيص المجلد", "description": "اختر لونًا محددًا لمجلدك", "cancel": "إلغاء", "apply": "تطبيق", "error": "حدث خطأ، يرجى المحاولة مرة أخرى.", "tabs": { "colors": "الألوان", "icons": "الأيقونات" }, "iconPicker": { "recents": "المستخدمة مؤخراً", "chooseCustomIcon": "اختر أيقونة مخصصة" } } } ================================================ FILE: src/locales/de.json ================================================ { "Nav": { "item_drive": "Laufwerk", "item_recent": "Zuletzt", "item_sharings": "Freigaben", "item_shared": "Von mir geteilt", "item_activity": "Aktivität", "item_trash": "Papierkorb", "item_settings": "Einstellungen", "item_collect": "Verwaltung", "btn-client": "Hol' dir Twake für den Desktop!", "btn-client-web": "Hol' dir Twake!", "btn-client-mobile": "Hol' dir %{name} auf dein Handy!", "banner-txt-client": "Hol' dir %{name} für den Desktop und synchronisiere deine Dateien sicher, um jederzeit auf sie zuzugreifen.", "banner-btn-client": "Herunterladen", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile", "link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8", "link-client-web": "https://cozy.io/try-it" }, "breadcrumb": { "title_drive": "Laufwerk", "title_recent": "Neueste", "title_sharings": "Freigaben", "title_shared": "Von mir geteilt", "title_activity": "Aktivität", "title_trash": "Papierkorb" }, "Toolbar": { "more": "Mehr" }, "toolbar": { "menu_upload": "Dateien hochladen", "item_more": "Mehr", "menu_new_folder": "Ordner", "menu_select": "Elemente auswählen", "menu_share_folder": "Ordner freigeben", "menu_download": "Herunterladen", "menu_sync_cozy": "In mein Twake synchronisieren", "add_to_mine": "Meinem Twake hinzufügen", "menu_download_folder": "Ordner herunterladen", "menu_download_file": "Diese Datei herunterladen", "menu_create_note": "Notizen", "menu_create_shortcut": "Abkürzung", "empty_trash": "Papierkorb leeren", "share": "Freigeben", "trash": "Entfernen", "delete_shared_drive": "Gemeinsames Laufwerk löschen", "leave": "Geteilten Ordner verlassen & löschen", "menu_add": "Hinzufügen", "menu_create": "Erstellen", "menu_onlyOffice": { "text": "Textdokument", "spreadsheet": "Tabellenkalkulation", "slide": "Präsentation" }, "select_all": "Alle auswählen", "clear_selection": "Auswahl aufheben", "sharings_tab_all": "Alle", "sharings_tab_drives": "Laufwerke" }, "Share": { "create-cozy": "Meinen Twake erstellen" }, "Files": { "share": { "cta": "Freigeben", "title": "Freigeben", "details": { "title": "Details freigeben", "createdAt": "Am %{date}", "ro": "Kann lesen", "rw": "Darf ändern", "desc": { "ro": "Du kannst diesen Inhalt sehen, herunterladen und deinem Twake hinzufügen. Du wirst Aktualisierungen des Besitzers erhalten, selbst jedoch keine Änderungen vornehmen können.", "rw": "Du kannst diesen Inhalt sehen, ändern, löschen und deinem Twake hinzufügen. Deine Änderungen sind auf anderen Cozies sichtbar." } }, "sharedByMe": "Von mir geteilt", "sharedWithMe": "Mit mir geteilt", "sharedBy": "Geteilt von %{name}", "shareByLink": { "subtitle": "Über öffentlichen Link", "desc": "Jeder der den Link kennt, kann deine Dateien sehen und herunterladen.", "creating": "Erstellen deines Links...", "copy": "Link kopieren", "copied": "Der Link wurde in deine Zwischenablage kopiert", "failed": "Unfähig in deine Zwischenablage zu kopieren" }, "shareByEmail": { "subtitle": "Per E-Mail", "email": "An:", "emailPlaceholder": "Gib die E-Mail-Adresse oder den Namen des Empfängers ein", "send": "Senden", "genericSuccess": "Du hast eine Einladung an %{count} Kontakte gesendet.", "success": "Du hast eine Einladung an %{email} gesendet.", "comingsoon": "Bald verfügbar: Du wirst mit nur einem Klick Fotos und Dokumente deiner Familie, deinen Freunden und sogar deinen Kollegen freigeben können. Keine Sorge, wir benachrichtigen dich sobald es soweit ist!", "onlyByLink": "Dieser %{type} kann nur als Link geteilt werden, da", "type": { "file": "Datei", "folder": "Ordner" }, "hasSharedParent": "hat einen geteilten Ursprung", "hasSharedChild": "enthält ein geteiltes Element" }, "revoke": { "title": "Freigabe aufheben", "desc": "Dieser Kontakt behält eine Kopie. Änderungen werden jedoch nicht mehr synchronisiert.", "success": "Du hast diese geteilte Datei von %{email} entfernt." }, "revokeSelf": { "title": "Entferne mich von der Freigabe", "desc": "Du behältst den Inhalt. Er wird aber nicht mehr in deinem Twake erneuert.", "success": "Du wurdest von dieser Freigabe entfernt." }, "sharingLink": { "title": "Link zum Freigeben", "copy": "Kopieren", "copied": "Kopiert" }, "whoHasAccess": { "title": "1 Person hat Zugriff |||| %{smart_count} Personen haben Zugriff" }, "protectedShare": { "title": "Bald verfügbar!", "desc": "Teile alles per E-Mail mit deiner Familie und Freunden!" }, "close": "Schließen", "gettingLink": "Erstelle deinen Link ...", "error": { "generic": "Beim Erstellen des Dateifreigabelinks ist ein Fehler aufgetreten, bitte versuche es erneut.", "revoke": "Hoppla, das hat nicht geklappt. Bitte kontaktiere uns, damit wir den Fehler so schnell wie möglich beheben können." }, "specialCase": { "base": "Dieser %{type} kann nur als Link geteilt werden, da", "isInSharedFolder": "ist in einem geteilten Ordner", "hasSharedFolder": "enthält einen geteilten Ordner" } }, "viewer-fallback": "Sobald der Download begonnen hat, kannst du mich schließen.", "dropzone": { "teaser": "Ziehe Dateien hierher um sie hochzuladen:", "noFolderSupport": "Ordner drag&drop wird von deinem Browser derzeit nicht unterstützt. Lade deine Dateien bitte manuell hoch." } }, "table": { "head_name": "Name", "head_update": "Letzte Änderung", "head_size": "Größe", "head_status": "Teilen", "head_thumbnail_size": "Wechsele die Größe des Vorschaubildes", "row_update_format": "dd.LL.yyyy", "row_update_format_full": "dd.LL.yyyy", "row_read_only": "Freigeben (nur Lesen)", "row_read_write": "Freigeben (Lesen & Schreiben)", "row_size_symbols": { "B": "Byte", "KB": "Kilobyte", "MB": "Megabyte", "GB": "Gigabyte", "TB": "Terabyte", "PB": "Petabyte", "EB": "Exabyte", "ZB": "Zettabyte", "YB": "Yottabyte" }, "load_more": "Mehr laden", "mobile": { "head_name_asc": "A-Z", "head_name_desc": "Z-A", "head_updated_at_asc": "Älteste zuerst", "head_updated_at_desc": "Neueste zuerst", "head_size_asc": "Kleinste zuerst", "head_size_desc": "Größte zuerst" }, "tooltip": { "carbonCopy": { "title": "Durchschlag", "caption": "Zeigt an, ob das Dokument von Twake Workplace, dem Host Ihrer Twake, als \"authentisch und original\" definiert wird, da es behaupten kann, dass es direkt von einem Drittanbieterdienst stammt, ohne dass es verändert wurde." }, "electronicSafe": { "title": "Elektronischer Tresor", "caption": "Gibt an, ob das Originaldokument in Ihrem persönlichen digitalen Tresor mit den Zertifizierungen, die ihm Beweiskraft verleihen, und einer 50-jährigen Aufbewahrungsgarantie über die Hinterlegung hinaus gesichert ist." } } }, "Storage": { "title": "Speicher", "availability": "%{smart_count} GB verfügbar", "increase": "Speicherplatz erweitern" }, "SelectionBar": { "selected_count": "Element ausgewählt |||| Elemente ausgewählt", "share": "Freigeben", "download": "Herunterladen", "trash": "Entfernen", "destroy": "Dauerhaft löschen", "rename": "Umbenennen", "restore": "Wiederherstellen", "close": "Schließen", "openWith": "Öffnen mit...", "applePreview": "Apple Vorschau", "forward": "Weiterleiten", "forwardTo": "Weiterleiten an...", "moveto": "Verschieben nach...", "moveto_mobile": "Verschieben", "phone-download": "Offline verfügbar machen", "qualify": "Kategorisieren", "history": "Verlauf" }, "DeleteConfirm": { "title": "Dieses Element löschen? |||| Diese Elemente löschen?", "trash": "Es wird in den Papierkorb verschoben. |||| Sie werden in den Papierkorb verschoben.", "restore": "Du kannst es jederzeit wiederherstellen. |||| Du kannst sie jederzeit wiederherstellen.", "link": "Link Freigabe wird nicht länger aktiv sein", "referenced": "Einige der Dateien innerhalb der Auswahl beziehen sich auf ein Fotoalbum. Sie werden aus ihm entfernt, wenn du sie in den Müll verschiebst.", "cancel": "Abbrechen", "delete": "Entfernen" }, "emptytrashconfirmation": { "title": " Dauerhaft löschen? ", "forbidden": "Du kannst nicht mehr auf diese Dateien zugreifen.", "restore": "Du kannst diese Dateien nicht wiederherstellen, wenn du keine Sicherung gemacht hast.", "cancel": "Abbrechen", "delete": "Alles löschen" }, "DestroyConfirm": { "title": "Dauerhaft löschen?", "forbidden": "Du kannst nicht mehr auf diese Datei zugreifen. |||| Du kannst nicht mehr auf diese Dateien zugreifen.", "restore": "Du kannst diese Datei nicht wiederherstellen, wenn du keine Sicherung gemacht hast. |||| Du kannst diese Dateien nicht wiederherstellen, wenn du keine Sicherung gemacht hast.", "cancel": "Abbrechen", "delete": "Dauerhaft löschen" }, "quotaalert": { "title": "Dein Speicherplatz ist voll :(", "desc": "Bitte entferne Dateien, leere deinen Mülleiemer oder erhöhe dein Speicherkontingent bevor du wieder Dateien hochlädtst.", "confirm": "OK", "increase": "Erhöhe dein Speicherkontingent" }, "loading": { "message": "Lädt", "onlyOfficeCreateInProgress": "Erstellen der aktuellen Datei..." }, "empty": { "title": "Du hast keine Dateien in diesem Ordner.", "text": "Wählen Sie Dateien auf Ihrem Computer aus oder ziehen Sie sie hierher.", "mobile_text": "Wählen Sie Dateien auf Ihrem Gerät aus.", "trash_title": "Du hast keine gelöschten Dateien.", "trash_text": "Verschiebe Dateien, die du nicht länger benötigst in den Papierkorb und lösche Elemente dauerhaft, um Speicherplatz freizumachen." }, "error": { "open_folder": "Beim Öffnen des Ordners ist etwas schief gelaufen.", "open_file": "Beim Öffnen der Datei ist etwas schief gelaufen.", "button": { "reload": "Jetzt aktualisieren" }, "download_file": { "offline": "Du solltest verbunden sein, um diese Datei herunterzuladen.", "missing": "Diese Datei fehlt" } }, "Error": { "public_unshared_title": "Entschuldige, dieser Links ist nicht länger verfügbar.", "public_unshared_text": "Dieser Link ist abgelaufen oder vom Besitzer entfernt worden. Lass' es ihn wissen, dass du ihn verpasst hast.", "generic": "Etwas ist schiefgelaufen. Warte ein paar Minuten und versuche es erneut." }, "alert": { "could_not_open_file": "Diese Datei konnte nicht geöffnet werden", "try_again": "Ein Fehler ist aufgetreten, bitte versuche es gleich noch einmal.", "restore_file_success": "Die Auswahl wurde erfolgreich wiederhergestellt.", "trash_file_success": "Die Auswahl wurde in den Papierkorb verschoben.", "destroy_file_success": "Die Auswahl wurde endgültig gelöscht.", "empty_trash_progress": "Dein Papierkorb wird entleert. Dies kann einen Augenblick dauern.", "empty_trash_success": "Der Papierkorb wurde entleert.", "folder_name": "Das Element %{folderName} existiert bereits, bitte wähle einen neuen Namen.", "file_name": "Das Element %{fileName} existiert bereits, bitte wähle einen neuen Namen.", "file_name_missing": "Der Dateiname ist falsch, bitte geben Sie einen neuen Namen ein.", "file_name_illegal_name": "Der Name %{fileName} ist ungültig, bitte wählen Sie einen neuen Namen.", "file_name_illegal_characters": "Das Element %{fileName} enthält ungültige Zeichen: %{characters}", "folder_generic": " Ein Fehler ist aufgetreten, bitte versuche es noch einmal.", "folder_abort": "Du musst deinem neuen Ordner einen Namen hinzufügen, wenn du ihn speichern möchtest. Deine Daten wurden nicht gespeichert.", "offline": "Diese Funktion ist offline nicht verfügbar.", "preparing": "Deine Dateien werden vorbereitet...", "item_copied": "1 Element kopiert", "items_copied": "%{count} Elemente kopiert", "item_cut": "1 Element ausgeschnitten", "items_cut": "%{count} Elemente ausgeschnitten", "item_moved": "1 Element wurde verschoben", "items_moved": "%{count} Elemente wurden verschoben", "item_pasted": "1 Element wurde verschoben", "items_pasted": "%{count} Elemente wurden verschoben", "copy_files_only": "Ordner können nicht kopiert werden", "copy_not_allowed": "Der Kopiervorgang ist in dieser Ansicht nicht erlaubt.", "cut_not_allowed": "Der Ausschneiden-Vorgang ist in dieser Ansicht nicht erlaubt.", "paste_error": "Beim Einfügen der Dateien ist ein Fehler aufgetreten", "paste_failed": "Einfügen der Dateien fehlgeschlagen", "paste_sharing_error": "Dateien können aufgrund von Freigabebeschränkungen nicht eingefügt werden. Bitte verwenden Sie stattdessen die Verschieben-Aktion.", "paste_same_folder_skipped": "Elemente können nicht in denselben Ordner verschoben werden, in dem sie sich bereits befinden.", "paste_not_allowed": "Sie können nicht in diesen Ordner einfügen", "cannot_move_shared_drive": "Sie können den freigegebenen Laufwerksordner nicht verschieben", "cannot_copy_shared_drive": "Du kannst keinen freigegebenen Laufwerksordner kopieren" }, "upload": { "label": "Hochladen", "alert": { "network": "Du bist zurzeit offline. Bitte versuche es erneut, sobald du wieder verbunden bist." } }, "intents": { "alert": { "error": "Unfähig, die Datei automatisch hochzuladen, bitte lade sie manuell über das Hochlademenü hoch." }, "picker": { "select": "Auswählen", "cancel": "Abbrechen", "new_folder": "Neuer Ordner", "instructions": "Wähle ein Ziel" } }, "UploadQueue": { "header": "Hochladen von %{smart_count} Foto in dein Twake Drive |||| Hochladen von %{smart_count} Fotos in dein Twake Drive", "header_mobile": "Hochladen %{done} von %{total}", "header_done": "Hochladen %{done} aus %{total} erfolgreich", "close": "Schließen", "item": { "pending": "Ausstehend" } }, "Viewer": { "close": "Schließen", "noviewer": { "download": "Diese Datei herunterladen", "openWith": "Öffnen mit...", "openInOnlyOffice": "Öffnen mit Only Office", "cta": { "saveTime": "Spare etwas Zeit!", "installDesktop": "Installiere das Synchronisationstool für deinen Computer", "accessFiles": "Greife direkt von deinem Computer auf deine Datein zu" } }, "actions": { "download": "Herunterladen", "forward": "Weiterleiten" }, "loading": { "error": "Diese Datei konnte nicht geladen werden. Hast du eine funktionierende Internetverbindung?", "retry": "Wiederholen" }, "error": { "noapp": "Keine Anwendung auf Ihrem Gerät kann diese Datei verarbeiten.", "generic": "Ein Fehler ist beim Öffnen dieser Datei aufgetreten, bitte versuche es erneut.", "noNetwork": "Du bist derzeit offline." }, "panel": { "title": "Nützliche Informationen" } }, "Move": { "to": "Verschiebe zu:", "action": "Verschieben", "cancel": "Abbrechen", "modalTitle": "Verschieben", "title": "%{smart_count} Element |||| %{smart_count} Elemente", "success": "%{subject} wurde in %{target} verschoben. |||| %{smart_count} Elemente wurden in %{target} verschoben.", "error": "Etwas ist beim Verschieben dieses Elements schiefgelaufen, bitte versuche es später erneut. |||| Etwas ist beim Verschieben dieser Elemente schiefgelaufen, bitte versuche es später erneut.", "cancelled": "%{subject} wurde zurück an seinen Ursprungsort geschoben. |||| %{smart_count} Elemente wurden zurück an ihren Ursprungsort geschoben.", "cancelledWithRestoreErrors": "%{subject} wurde zurück an seinen Ursprungsort geschoben, aber es gab einen Fehler beim Wiederherstellen der Datei aus dem Papierkorb. |||| %{smart_count} Elemente wurden zurück an ihren Ursprungsort geschoben, aber es gab %{restoreErrorsCount} Fehler beim Wiederherstellen der Datei(en) aus dem Papierkorb.", "cancelled_error": "Entschuldige, es gab einen Fehler beim Zurückschieben dieses Elements. |||| Entschuldige, es gab einen Fehler beim Zurückschieben dieser Elemente." }, "ImportToDrive": { "title": "%{smart_count} Element |||| %{smart_count} Elemente", "to": "Speichern in:", "action": "Speichern", "cancel": "Abbrechen", "success": "%{smart_count} gesicherte Datei |||| %{smart_count} gesicherte Dateien", "error": "Etwas ist schiefgelaufen. Bitte versuche es erneut" }, "FileOpenerExternal": { "fileNotFoundError": "Fehler: Datei nicht gefunden" }, "TOS": { "updated": { "title": "GDPR wird Realität!", "detail": "Im Rahmen der General Data Protection Regulation (GDPR), [wurden unsere Nutzungsbedingungen aktualisiert](%{link}) und werden ab dem 25. März 2018 auf alle unsere Nutzer angewandt.", "cta": "TOS akzeptieren und fortfahren", "disconnect": "Ablehnen und trennen", "error": "Etwas ist schiefgelaufen. Bitte versuche es später erneut" } }, "manifest": { "permissions": { "contacts": { "description": "Erforderlich, um deinen Kontakten Dateien freizugeben" }, "groups": { "description": "Erforderlich, um deinen Gruppen Dateien freizugeben" } } }, "models": { "contact": { "defaultDisplayName": "Anonym" } }, "Scan": { "scan_a_doc": "Scanne ein Dokument", "save_doc": "Speichere das Dokument", "filename": "Dateiname", "save": "Speichern", "cancel": "Abbrechen", "qualify": "Kategorisieren", "apply": "Anwenden", "error": { "offline": "Du bist derzeit offline und kannst diese Funktion nicht nutzen. Versuche es später erneut", "uploading": "Du lädst bereits eine Datei hoch. Warte bis zur Fertigstellung und versuche es erneut.", "generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut" }, "successful": { "qualified_ok": "Du hast die Datei erfolgreich kategorisiert!" } }, "History": { "description": "Die letzten 20 Versionen deiner Dateien werden automatisch behalten. Wähle eine Version aus, um sie herunterzuladen.", "current_version": "Aktuelle Version", "loading": "Lädt...", "noFileVersionEnabled": "Dein Twake wird bald dazu in der Lage sein, deine letzten Dateiänderungen zu archivieren, um einem Verlust vorzubeugen" }, "External": { "redirection": { "title": "Weiterleitung", "text": "Du wirst gleich weitergeleitet...", "error": "Fehler während der Weiterleitung. Im Allgemeinen deutet dies auf ein falsches Format deines Inhalts hin." } }, "RenameModal": { "title": "Umbenennen", "description": "Du bist dabei, die Dateiendung zu ändern. Möchtest du fortfahren?", "continue": "Fortsetzen", "cancel": "Abbrechen" }, "Shortcut": { "title_modal": "Erstelle eine Verknüpfung", "filename": "Dateiname", "url": "URL", "cancel": "Abbrechen", "create": "Erstellen", "created": "Deine Verknüpfung wurde erstellt", "errored": "Ein Fehler ist aufgetreten", "filename_error_ends": "Der Name sollte mit .url enden", "needs_info": "Die Verknüpfung benötigt mindestens eine URL und einen Dateinamen", "url_badformat": "Deine URL hat nicht das richtige Format" }, "OnlyOffice": { "Error": { "title": "Etwas geht schief", "text": "Bitte versuchen Sie, die Seite neu zu laden" }, "readOnly": { "title": "Nur lesen", "tooltip": "Sie sind nur berechtigt, dieses Dokument anzusehen. Kontaktieren Sie den Eigentümer, um Schreibrechte zu erhalten." }, "createFileName": { "text": "Neues Textdokument", "spreadsheet": " Neue Tabellenkalkulation", "slide": "Neue Präsentation" } }, "Migration": { "title": "Aktualisierte Twake Drive", "content": "Twake Drive muss aktualisiert werden, um seine Leistung zu verbessern. Dies kann bis zu mehreren Minuten dauern, während derer Sie Ihre App nicht nutzen können. Möchten Sie es jetzt tun? Wenn Sie sich weigern, werden wir Sie beim nächsten Mal wieder fragen", "confirm": "Okay, los geht's!", "cancel": "Nein, nicht jetzt" }, "searchbar": { "placeholder": "Alle Dateien durchsuchen", "empty": "Es wurde kein Ergebnis für die Suche \"%{query}\" gefunden" }, "actions": { "details": "Details", "personalizeFolder": { "label": "Ordner personalisieren" }, "summariseByAI": "Zusammenfassen" }, "FolderCustomizer": { "title": "Ordner personalisieren", "description": "Wählen Sie eine bestimmte Farbe für Ihren Ordner", "cancel": "Abbrechen", "apply": "Anwenden", "error": "Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut.", "tabs": { "colors": "Farben", "icons": "Symbole" }, "iconPicker": { "recents": "Zuletzt verwendet", "chooseCustomIcon": "Wählen Sie ein benutzerdefiniertes Symbol" } } } ================================================ FILE: src/locales/en.json ================================================ { "Nav": { "item_drive": "My Drive", "item_recent": "Recents", "item_sharings": "Sharings", "item_shared": "Shared by me", "item_activity": "Activity", "item_trash": "Bin", "item_migration": "Migration", "item_settings": "Settings", "item_collect": "Administrative", "item_shared_drives": "Shared drives", "item_favorites": "Favorites", "item_external_drives": "External drives", "item_my_drive": "My Drive", "btn-client": "Get Twake Drive for desktop", "btn-client-web": "Get Twake", "btn-client-mobile": "Take your personnal cloud with you: install %{name} on all your devices!", "banner-txt-client": "Get %{name} for Desktop and synchronise your files safely to make them accessible at all times.", "banner-btn-client": "Download", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile", "link-client-ios": "https://apps.apple.com/app/cloud-personnel-cozy/id1600636174", "link-client-web": "https://cozy.io/try-it", "view_more": "View more", "view_less": "View less", "item_nextcloud": "Nextcloud" }, "breadcrumb": { "title_drive": "Files", "title_recent": "Recent", "title_sharings": "Sharings", "title_shared": "Shared by me", "title_activity": "Activity", "title_trash": "Trash", "label": "Show path", "title_shared_drives": "Drives", "title_favorites": "Favorites" }, "Toolbar": { "more": "More" }, "toolbar": { "menu_manage_access": "Manage access", "menu_leave_shared_drive": "Leave shared folder", "menu_upload": "Upload files", "item_more": "More", "menu_new_folder": "Folder", "menu_new_shared_drive": "Shared drive", "menu_select": "Select items", "menu_share_folder": "Share folder", "menu_download": "Download", "menu_sync_cozy": "Synchronise to my Twake", "add_to_mine": "Add to my Twake", "menu_download_folder": "Download folder", "menu_download_file": "Download this file", "menu_create_note": "Note", "menu_create_docs": "Docs", "menu_create_shortcut": "Shortcut", "share": "Share", "trash": "Remove", "delete_shared_drive": "Delete shared drive", "leave": "Leave shared folder & delete it", "menu_add": "Add", "menu_create": "Create", "menu_add_item": "Add an item", "menu_onlyOffice": { "text": "Text document", "spreadsheet": "Spreadsheet", "slide": "Presentation" }, "select_all": "Select all", "select_all_mobile": "all", "clear_selection": "Clear Selection", "clear_selection_mobile": "Clear", "sharings_tab_all": "All", "sharings_tab_drives": "Drives" }, "Share": { "create-cozy": "Create my Twake" }, "Files": { "share": { "cta": "Share", "title": "Share", "details": { "title": "Sharing details", "createdAt": "On %{date}", "ro": "Can read", "rw": "Can change", "desc": { "ro": "You can view, download, and add this content to your Twake. You will get updates by the owner, but you won't be able to update this content yourself.", "rw": "You can view, update, delete and add this content to your Twake. Updates you make will be seen on other Cozies." } }, "shared": "Shared", "sharedByMe": "Shared by me", "sharedWithMe": "Shared with me", "sharedBy": "Shared by %{name}", "shareByLink": { "subtitle": "By public link", "desc": "Anyone with the provided link can see and download your files.", "creating": "Creating your link...", "copy": "Copy link", "copied": "Link has been copied to clipboard", "failed": "Unable to copy to clipboard" }, "shareByEmail": { "subtitle": "By email", "email": "To:", "emailPlaceholder": "Enter the email address or name of the recipient", "send": "Send", "genericSuccess": "You sent an invite to %{count} contacts.", "success": "You sent an invite to %{email}.", "comingsoon": "Coming soon! You will be able to share documents and photos in a single click with your family, your friends, and even your coworkers. Don't worry, we'll let you know when it's ready!", "onlyByLink": "This %{type} can only be shared by link, because", "type": { "file": "file", "folder": "folder" }, "hasSharedParent": "it has a shared parent", "hasSharedChild": "it contains a shared element" }, "revoke": { "title": "Remove from sharing", "desc": "This contact will keep a copy but the changes won't be synchrnoized anymore.", "success": "You removed this shared file from %{email}." }, "revokeSelf": { "title": "Remove me from sharing", "desc": "You keep the content but it won't be updated between your Twake anymore.", "success": "You were removed from this sharing." }, "sharingLink": { "title": "Link to share", "copy": "Copy", "copied": "Copied" }, "whoHasAccess": { "title": "1 person has access |||| %{smart_count} people have access" }, "protectedShare": { "title": "Coming soon!", "desc": "Share anything by email with your family and friends!" }, "close": "Close", "gettingLink": "Getting your link...", "error": { "generic": "An error occurred when creating the file share link, please try again.", "revoke": "Woops, an error occurred. Please contact us so we can fix this issue as soon as possible." }, "specialCase": { "base": "This %{type} cannot be shared but with a link as it", "isInSharedFolder": "is in a shared folder", "hasSharedFolder": "contains a shared folder" } }, "viewer-fallback": "If the file has started downloading, you can close this.", "dropzone": { "teaser": "Drop files to upload them to:", "noFolderSupport": "Folder drag&drop is currently not supported by your browser. Please upload your files manually." } }, "table": { "head_name": "Name", "head_update": "Last update", "head_size": "Size", "head_status": "Share", "head_thumbnail_size": "Switch thumbnail size", "head_view_mode": "View mode", "head_view_list": "List view", "head_view_grid": "Grid view", "row_update_format": "LLL d, yyyy", "row_update_format_full": "LLLL d, yyyy", "row_read_only": "Share (Read only)", "row_read_write": "Share (Read & Write)", "row_size_symbols": { "B": "B", "KB": "KB", "MB": "MB", "GB": "GB", "TB": "TB", "PB": "PB", "EB": "EB", "ZB": "ZB", "YB": "YB" }, "row_sharing_shortcut_aria_label": "New sharing shortcut", "load_more": "Load More", "mobile": { "head_name_asc": "A-Z", "head_name_desc": "Z-A", "head_updated_at_asc": "Oldest first", "head_updated_at_desc": "Most recent first", "head_size_asc": "Lightest first", "head_size_desc": "Heavier first" }, "tooltip": { "carbonCopy": { "title": "Carbon Copy", "caption": "Indicates whether the document is defined as \"authentic and original\" by Twake Workplace, the host of your Twake, as it can claim that it comes directly from a third-party service, without having undergone any modification." }, "electronicSafe": { "title": "Electronic Safe", "caption": "Indicates whether the original document is secured by your personal digital safe with the certifications that give it probative value and a 50-year retention guarantee beyond its deposit." } } }, "Storage": { "title": "Storage", "availability": "%{smart_count} GB available", "increase": "Increase the space" }, "SelectionBar": { "selected_count": "item selected |||| items selected", "share": "Share", "download": "Download", "copy": "Copy", "cut": "Cut", "paste": "Paste", "trash": "Remove", "trash_all": "Remove all", "destroy": "Delete permanently", "rename": "Rename", "restore": "Restore", "close": "Close", "openWith": "Open with...", "applePreview": "Apple preview", "forward": "Forward", "forwardTo": "Forward to...", "moveto": "Move to…", "moveto_mobile": "Move", "phone-download": "Make available offline", "qualify": "Categorize", "history": "History", "more": "More", "openWithinNextcloud": "Open within Nextcloud" }, "DeleteConfirm": { "title": "Delete %{filename}? |||| Delete %{smart_count} %{type}?", "trash": "It will be moved to the Trash. |||| They will be moved to the Trash.", "restore": "You can still restore it whenever you want. |||| You can still restore them whenever you want.", "share_accepted": "Sharing will be stopped. The following contacts will keep a copy, but your changes will no longer be synchronised:", "share_waiting": "Sharing will be stopped. The following contacts will no longer be able to accept sharing and will no longer be able to access shared content:", "share_both": "Sharing will be stopped. This means that contacts who have stored files in their Twake will keep a copy, while other contacts will no longer be able to access shared content:", "link": "Link sharing will no longer be active", "referenced": "Some of the files within the selection are related to a photo album. They will be removed from it if you proceed to trash them.", "cancel": "Cancel", "delete": "Remove" }, "EmptyTrashConfirm": { "title": "Permanently delete?", "forbidden": "You won't be able to access these files anymore.", "restore": "You won't be able to restore these files if you didn't make a backup.", "cancel": "Cancel", "delete": "Delete all", "processing": "Your trash is being emptied. This might take a few moments.", "success": "The trash has been emptied.", "error": "An error occurred, please try again." }, "DestroyConfirm": { "title": "Delete %{filename}? |||| Delete %{smart_count} %{type}?", "forbidden": "You won't be able to access this %{type} anymore. |||| You won't be able to access these %{type} anymore.", "restore": "You won't be able to restore this %{type} if you didn't make a backup. |||| You won't be able to restore these %{type} if you didn't make a backup.", "cancel": "Cancel", "delete": "Delete permanently", "success": "The %{type} has been deleted permanently. |||| %{smart_count} %{type} have been deleted permanently.", "error": "An error occurred, please try again.", "processing": "The deletion is in progress. This might take a few moments." }, "quotaalert": { "title": "Your disk space is full :(", "desc": "Please remove files, empty your trash or increase your disk space before uploading files again.", "confirm": "OK", "increase": "Increase your disk space" }, "loading": { "message": "Loading", "onlyOfficeCreateInProgress": "Creating the current file..." }, "empty": { "title": "You don’t have any files in this folder.", "text": "Select files on your computer or drag them here.", "mobile_text": "Select files on your device.", "trash_title": "You don’t have any deleted files.", "trash_text": "Move files you don't need anymore to the Trash and permanently delete items to free up storage page.", "shared-drive_text": "Create and share your first drive." }, "error": { "open_folder": "Something went wrong when opening the folder.", "open_file": "Something went wrong when opening the file.", "button": { "reload": "Refresh now" }, "download_file": { "offline": "You should be connected to download this file", "missing": "This file is missing" }, "paste_failed": "Failed to paste files. Please try again." }, "Error": { "public_unshared_title": "Sorry, this link is no longer available.", "public_unshared_text": "This link has expired, or it was removed by its owner. Let him or her know that you missed it!", "generic": "Something went wrong. Wait a few minutes and retry." }, "alert": { "could_not_open_file": "The file could not be opened", "try_again": "An error has occurred, please try again in a moment.", "restore_file_success": "The selection has been successfully restored.", "trash_file_success": "The selection has been moved to the Trash.", "trash_file_processing": "The move to Trash is in progress...", "trash_shared_drive_success": "The shared drive has been moved to the Trash.", "destroy_file_success": "The selection has been deleted permanently.", "folder_name": "The element %{folderName} already exists, please choose a new name.", "file_name": "The element %{fileName} already exists, please choose a new name.", "file_name_missing": "The file name is missing, please choose a new name.", "file_name_illegal_name": "The name %{fileName} is invalid, please choose a new name.", "file_name_illegal_characters": "The element %{fileName} contains invalid characters: %{characters}", "folder_generic": "An error occurred, please try again.", "folder_abort": "You need to add a name to your new folder if you would like to save it. Your information has not been saved.", "offline": "This feature is not available offline.", "preparing": "Preparing your files…", "item_copied": "1 item copied", "items_copied": "%{count} items copied", "item_cut": "1 item cut", "items_cut": "%{count} items cut", "item_moved": "1 item was moved", "items_moved": "%{count} items were moved", "item_pasted": "1 item was moved", "items_pasted": "%{count} items were moved", "copy_files_only": "Cannot copy folders", "copy_not_allowed": "Copy operation is not allowed in this view.", "cut_not_allowed": "Cut operation is not allowed in this view.", "delete_not_allowed": "Delete operation is not allowed in this view.", "paste_error": "An error occurred while pasting files", "paste_failed": "Failed to paste files", "paste_sharing_error": "Cannot paste files due to sharing restrictions. Please use the Move action instead.", "paste_same_folder_skipped": "Cannot move items to the same folder they are already in.", "paste_not_allowed": "You cannot paste into this folder", "cannot_move_shared_drive": "You cannot move shared drive folder", "cannot_copy_shared_drive": "You cannot copy shared drive folder" }, "upload": { "label": "Upload", "documentType": { "file": "file", "directory": "folder", "element": "element" }, "alert": { "success": "%{smart_count} %{type} uploaded with success. |||| %{smart_count} %{type} uploaded with success.", "success_conflicts": "%{smart_count} %{type} uploaded with %{conflictNumber} conflict(s). |||| %{smart_count} %{type} uploaded with %{conflictNumber} conflict(s).", "success_updated": "%{smart_count} %{type} uploaded and %{updatedCount} updated. |||| %{smart_count} %{type} uploaded and %{updatedCount} updated.", "success_updated_conflicts": "%{smart_count} %{type} uploaded, %{updatedCount} updated and %{conflictCount} conflict(s). |||| %{smart_count} %{type} uploaded, %{updatedCount} updated and %{conflictCount} conflict(s).", "updated": "%{smart_count} %{type} updated. |||| %{smart_count} %{type} updated.", "updated_conflicts": "%{smart_count} %{type} updated with %{conflictCount} conflict(s). |||| %{smart_count} %{type} updated with %{conflictCount} conflict(s).", "errors": "Errors occurred during the %{type} upload.", "network": "You are currenly offline. Please try again once you're connected.", "fileTooLargeErrors": "File too large. Maximum file size: %{max_size_value} GB", "unreadable_files": "Some files could not be read. The file path may be too long or the folder was modified during the transfer." }, "limit": { "title": "You cannot upload more than %{limit} files at a time.", "content": "Need to upload more? Consider downloading the synchronization tool to your computer", "content_public": "Please reduce the number of files and try again.", "cancel": "Cancel", "close": "Close", "download_desktop": "Download on Desktop" } }, "intents": { "alert": { "error": "Unable to automatically upload the file, please upload it manually with the upload menu." }, "picker": { "select": "Select", "cancel": "Cancel", "new_folder": "New folder", "instructions": "Select a target" } }, "UploadQueue": { "header": "Uploading %{smart_count} item to Twake Drive |||| Uploading %{smart_count} items to Twake Drive", "header_preparing": "Preparing %{smart_count} item for upload |||| Preparing %{smart_count} items for upload", "header_mobile": "Uploading %{done} of %{total}", "header_done": "Uploaded %{done} out of %{total} successfully", "success_flagship": "%{smart_count} file uploaded with success. |||| %{smart_count} files uploaded with success.", "close": "close", "item": { "pending": "Pending", "preparing": "Preparing" } }, "Viewer": { "close": "Close", "noviewer": { "download": "Download this file", "openWith": "Open with...", "openInOnlyOffice": "Open with Only Office", "cta": { "saveTime": "Save some time!", "installDesktop": "Install the synchronization tool for your computer", "accessFiles": "Access your files directly on your computer" } }, "actions": { "download": "Download", "forward": "Forward" }, "loading": { "error": "This file could not be loaded. Do you have a working internet connection right now?", "retry": "Retry" }, "error": { "noapp": "No application on your device can handle this file.", "generic": "An error occurred when opening this file, please try again.", "noNetwork": "You're currently offline." }, "panel": { "title": "Useful information" } }, "Move": { "to": "Move to:", "action": "Move", "cancel": "Cancel", "modalTitle": "Move", "title": "%{smart_count} element |||| %{smart_count} elements", "success": "%{subject} has been moved to %{target}. |||| %{smart_count} elements have been moved to %{target}.", "error": "Something went wrong while moving this element, please try again later. |||| Something went wrong while moving these elements, please try again later.", "cancelled": "%{subject} has been moved back to it's original location. |||| %{smart_count} elements have been moved back to their original location.", "cancelledWithRestoreErrors": "%{subject} has been moved back to it's original location but there was an error while restoring the file from trash. |||| %{smart_count} elements have been moved back to their original location but there was %{restoreErrorsCount} error(s) while restoring the file(s) from trash.", "cancelled_error": "Sorry, there was an error while moving the element back. |||| Sorry, there was an error while moving these elements back.", "multipleEntries": "%{smart_count} element |||| %{smart_count} elements", "addFolder": "Add a folder", "outsideSharedFolder": { "title": "Moving outside the %{sharedFolder} folder", "content_1": "Warning, you want to move %{name} out of the shared %{sharedFolder} folder. |||| Warning, you want to move %{smart_count} %{type} out of the shared %{sharedFolder} folder.", "content_2": "This move, will remove the %{type} %{name} from the share. This %{type} will therefore be trashed for all members of the share. |||| This move, will remove %{smart_count} %{type} from the share. These %{type} will therefore be trashed for all members of the share.", "cancel": "Cancel", "confirm": "I understand" }, "insideSharedFolder": { "title": "Move to a shared folder?", "content": "All members with access to %{destination} will also have access to %{source}. |||| All members with access to %{destination} will also have access to the selected %{type}.", "cancel": "Cancel", "confirm": "Ok" }, "sharedFolderInsideAnother": { "title": "Cannot be moved", "content_1": "You want to move a shared element into a shared folder. This type of move is not allowed.", "content_2": "If you still wish to move %{source} to %{destination}, please stop sharing :", "cancel": "Cancel move", "confirm": "Stop sharing" } }, "ImportToDrive": { "title": "%{smart_count} element |||| %{smart_count} elements", "to": "Save in:", "action": "Save", "cancel": "Cancel", "success": "%{smart_count} saved file |||| %{smart_count} saved files", "error": "Something went wrong. Please try again" }, "FileOpenerExternal": { "fileNotFoundError": "Error: file not found" }, "TOS": { "updated": { "title": "GDPR comes into reality !", "detail": "In the context of the General Data Protection Regulation, [our Terms of Service have been updated](%{link}) and will apply to all our Twake users on May 25, 2018.", "cta": "Accept TOS and continue", "disconnect": "Refuse and disconnect", "error": "Something went wrong, please try again later" } }, "manifest": { "permissions": { "contacts": { "description": "Required to share files with your contacts" }, "groups": { "description": "Required to share files with your groups" } } }, "models": { "contact": { "defaultDisplayName": "Anonymous" } }, "Scan": { "none": "Nothing", "scan_a_doc": "Scan a doc", "save_doc": "Save the doc", "filename": "Filename", "save": "Save", "cancel": "Cancel", "qualify": "Categorize", "requalify": "Re-categorize", "apply": "Apply", "error": { "offline": "You are currently offline and you can't use this functionnality. Try it later", "uploading": "You are already uploading a file. Wait until the end of this upload and try again.", "generic": "Something went wrong. Please try again." }, "successful": { "qualified_ok": "You just have successfully categorized your file! " } }, "History": { "description": "The last 20 versions of your files are automatically kept. Select a version to download it.", "current_version": "Current version", "loading": "Loading...", "noFileVersionEnabled": "Your Twake will soon be able to archive the last modifications of a file to never risk losing them again" }, "External": { "redirection": { "title": "Redirection", "text": "You're about to be redirected…", "error": "Error during the redirection. Generally, this means that the content of the file is not in the correct format." } }, "RenameModal": { "title": "Rename", "description": "You're about to change the file's extension. Do you want to continue?", "continue": "Continue", "cancel": "Cancel" }, "Shortcut": { "title_modal": "Create a shortcut", "filename": "Filename", "url": "URL", "cancel": "Cancel", "create": "Create", "created": "Your shortcut has been created", "errored": "An error occured", "filename_error_ends": "The name should end with .url", "needs_info": "Shorcut needs at least an url and a filename", "url_badformat": "Your url is not in the right format" }, "OnlyOffice": { "Error": { "title": "Something goes wrong", "text": "Please try to reload the page" }, "readOnly": { "title": "Read only", "tooltip": "You are only authorized to view this document. Contact the owner to obtain writing privileges." }, "createFileName": { "text": "New text document", "spreadsheet": "New spreadsheet", "slide": "New presentation" }, "toolbar": { "goToHome": "Go to home" }, "actions": { "edit": "Edit", "validate": "Validate" }, "tooltip": { "title": "Edit document", "text": "The document is currently read-only. You can modify it by clicking here.", "actions": { "ok": "Ok", "hide": "Do not display" } } }, "Migration": { "title": "Update Twake Drive", "content": "Twake Drive needs to update in order to improve its performances. This might take up to several minutes during which you cannot use your app. Do you want to do it now? If you refuse, we will ask you again next time", "confirm": "Ok, let's do it!", "cancel": "No, not now" }, "searchbar": { "placeholder": "Search anything", "empty": "No result has been found for the query “%{query}”" }, "button": { "back": "Back", "add": "Add", "create": "Create" }, "search": { "action": "Search", "empty": { "title": "No result", "subtitle": "No result has been found for the query “%{query}”" } }, "PushBanner": { "quota": { "text": "You've almost run out of storage space. If you reach the limit, you won't be able to add any more files. You can delete files, empty your bin or change your offer.", "actions": { "first": "I understand", "second": "Check our plans" } } }, "FileDivergedModal": { "title": "Someone has modified this file", "content": "Someone has modified the file outside Twake while you were editing it, you can retrieve their modifications instead of yours or continue your editing in a new file.", "confirm": "Continue editing", "cancel": "See its changes", "error": "An error occurred, please try again.", "confirmReload": { "title": "See the changes", "content": "When you access the new file, your changes will be cancelled.", "cancel": "Cancel", "confirm": "Ok, I get it" }, "viewMode": { "title": "Someone has modified this file", "content": "Someone has changed the contents of this file. You can retrieve these changes.", "confirm": "See the changes" } }, "FileDeletedModal": { "title": "Someone has deleted this file", "content": "Someone has deleted this file while you were editing it. You can stop editing or restore the file to continue editing.", "confirm": "Restore file", "cancel": "Undo changes", "error": "An error occurred, please try again." }, "TrashedBanner": { "text": "The item is in your trash", "destroy": "Delete permanently", "restore": "Restore", "restoreSuccess": "The item has been restored", "restoreError": "An error has occurred, please try again.", "destroySuccess": "The item has been deleted" }, "MigrationProgressBanner": { "title": "Migration from Nextcloud in progress", "percent": "%{percent}% complete", "importing": "Importing %{count} files from Nextcloud...", "cancel": "Cancel", "done": { "title": "Migration Complete!", "body": "Successfully imported %{count} files from Nextcloud" } }, "EntriesType": { "file": "file |||| files", "directory": "folder |||| folders", "element": "element |||| elements" }, "NotFound": { "title": "The element cannot be found", "text": "We have not found anything at this address. This may be a typing error." }, "NextcloudBreadcrumb": { "root": "Shared Drives", "trash": "Trash" }, "NextcloudToolbar": { "share": "Share" }, "NextcloudDeleteConfirm": { "title": "Delete %{filename}? |||| Delete %{smart_count} %{type}?", "trash": "This item will be moved to the Nextcloud trash. |||| These items will be moved to the Nextcloud trash.", "restore": "You can always restore it whenever you want from Nextcloud.", "error": "An error occurred, please try again.", "cancel": "Cancel", "delete": "Delete" }, "FileName": { "sharedDrive": "Drives", "trash": "Trash" }, "NextcloudBanner": { "title": "The items below are displayed from a NextCloud drive and are not stored in your Twake." }, "favorites": { "label": { "add": "Add to favorites", "addMobile": "Favorites", "remove": "Remove from favorites" }, "error": "An error occurred, please try again.", "success": { "add": "%{filename} has been added to favorites |||| These items have been added to favorites", "remove": "%{filename} has been removed from favorites |||| These items have been removed from favorites" } }, "TrashToolbar": { "emptyTrash": "Empty trash" }, "RestoreNextcloudFile": { "label": "Restore", "success": "The item has been restored", "error": "An error occurred, please try again." }, "actions": { "details": "Details", "infos": "Details and qualification", "infosMobile": "Details", "duplicateTo": { "label": "Duplicate to…" }, "duplicateToMobile": { "label": "Duplicate" }, "personalizeFolder": { "label": "Personalize folder" }, "summariseByAI": "Summarise" }, "DuplicateModal": { "subTitle": "Duplicate to:", "confirmLabel": "Duplicate here", "success": "%{fileName} has been duplicated to %{destinationName}. |||| %{smart_count} elements have been duplicated to %{destinationName}.", "error": "An error occurred, please try again." }, "OpenFolderButton": { "label": "Open directory" }, "LastUpdate": { "titleFormat": "LLLL dd, yyyy, HH:MM" }, "AddMenu": { "readOnlyFolder": "This is a read-only folder. You cannot perform this action." }, "PublicNoteRedirect": { "error": { "title": "Unable to access document", "subtitle": "The share link appears to be missing or invalid. Please ask the document owner to check access" } }, "FolderCustomizer": { "title": "Personalize folder", "description": "Choose a specific color for your folder", "cancel": "Cancel", "apply": "Apply", "error": "An error occurred, please try again.", "tabs": { "colors": "Colors", "icons": "Icons" }, "iconPicker": { "recents": "Recents", "chooseCustomIcon": "Choose a custom icon" } }, "antivirus": { "infectedFile": "This file is infected with a virus", "popover": { "title": "Downloading and sharing is blocked for security reasons", "description": "Twake system detected a virus" } } } ================================================ FILE: src/locales/es.json ================================================ { "Nav": { "item_drive": "Drive", "item_recent": "Recientes", "item_sharings": "Compartidos", "item_shared": "Compartido por mí", "item_activity": "Actividad", "item_trash": "Papelera", "item_settings": "Parámetros", "item_collect": "Administración", "btn-client": "Descargar Twake Drive para ordenador", "btn-client-web": "Descargar Twake", "btn-client-mobile": "Descargar %{name} en su celular", "banner-txt-client": "Descargue %{name} para ordenador y sincronice sus archivos con toda seguridad para que les puedan ser accesibles todo el tiempo.", "banner-btn-client": "Descargar", "link-client": "https://cozy.io/es/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile", "link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8", "link-client-web": "https://cozy.io/try-it" }, "breadcrumb": { "title_drive": "Drive", "title_recent": "Recientes", "title_sharings": "Compartidos", "title_shared": "Mis archivos compartidos", "title_activity": "Actividad", "title_trash": "Papelera" }, "Toolbar": { "more": "Más" }, "toolbar": { "menu_upload": "Cargar archivos", "item_more": "Más", "menu_new_folder": "Carpeta", "menu_select": "Seleccionar los items", "menu_share_folder": "Compartir carpeta", "menu_download": "Descargar", "menu_sync_cozy": "Sincronizar con mi Twake", "add_to_mine": "Añadir a mi Twake", "menu_download_folder": "Descargar carpeta", "menu_download_file": "Descargar este archivo", "menu_create_note": "Nota", "empty_trash": "Vaciar la papelera", "share": "Compartir", "trash": "Suprimir", "delete_shared_drive": "Eliminar unidad compartida", "leave": "Salir de la carpeta compartida & borrarla", "select_all": "Seleccionar todo", "select_all_mobile": "todos", "clear_selection": "Borrar selección", "clear_selection_mobile": "Cancelar", "sharings_tab_all": "Todo", "sharings_tab_drives": "Unidades" }, "Share": { "create-cozy": "Crear mi Twake" }, "Files": { "share": { "cta": "Compartir", "title": "Compartir", "details": { "title": "Detalles de lo compartido", "createdAt": "El %{date}", "ro": "Puede leerlo", "rw": "Puede cambiar", "desc": { "ro": "Usted puede consultar, descargar y añadir el contenido a su Coz. Recibirá las modificaciones que el propietario haga, pero usted no podrá modificarlo. ", "rw": "Usted puede consultar, modificar y suprimir el contenido. Las modificaciones del contenido se repercutirán automaticamente entre sus Twake." } }, "sharedByMe": "Compartido por mí", "sharedWithMe": "Compartido conmigo", "sharedBy": "Compartido por %{name}", "shareByLink": { "subtitle": "Por enlace público", "desc": "Quien disponga del enlace suministrado puede mirar y descargar sus archivos.", "creating": "Creando el enlace...", "copy": "Copiar el enlace", "copied": "El enlace ha sido copiado en el portapapeles", "failed": "No se puede copiar en el portapapeles" }, "shareByEmail": { "subtitle": "Por correo electrónico", "email": "Para:", "emailPlaceholder": "Entre la dirección email o el nombre del destinatario", "send": "Enviar", "genericSuccess": "Usted envía una invitación a %{count} contactos", "success": "Ustad envía una invitación a %{email}.", "comingsoon": "Dentro de poco, podrá compartir documentos y fotos en un solo clic con su familia, sus amigos e incluso sus compañeros de trabajo. No se preocupe, ¡le avisaremos cuando esté listo!", "onlyByLink": "Este %{type} no se puede compartir con un enlace, ya que", "type": { "file": "Archivo", "folder": "carpeta" }, "hasSharedParent": "se encuentra en una carpeta compartida", "hasSharedChild": "contiene un elemento compartido" }, "revoke": { "title": "Parar el intercambio", "desc": "Su contacto conservará una copia pero los cambios que haga no se sincronizarán.", "success": "Usted ha borrado este archivo compartido desde %{email}" }, "revokeSelf": { "title": "Parar el intercambio", "desc": "Usted conservará el contenido pero no se actualizará más entre sus Twake.", "success": "Usted fue borrado desde este compartir" }, "sharingLink": { "title": "Enlace a compartir", "copy": "Copiar", "copied": "Copiado" }, "whoHasAccess": { "title": "1 persona accede |||| %{smart_count} personas acceden" }, "protectedShare": { "title": "Vendrá pronto!", "desc": "Compartir algo por email con su familia y sus amigos!" }, "close": "Cerrar", "gettingLink": "Creación del enlace...", "error": { "generic": "Ha ocurrido un error al usted crear el link para compartir el archivo, por favor vuelva a ensayar.", "revoke": "Epa, Ha ocurrido un error. Contactos para resolver el problema cuanto antes." }, "specialCase": { "base": "ste %{type} no se puede compartir sino con un enlace como éste", "isInSharedFolder": "está en una carpeta compartida", "hasSharedFolder": "contiene una carpeta compartida" } }, "viewer-fallback": "Si el archivo ha comenzado a descargarse, puede cerrar esta ventana..", "dropzone": { "teaser": "Ponga los archivos para subirlos en:", "noFolderSupport": "Por el momento, su navegador no acepta las funciones de arrastar-soltar de carpetas. Por favor, suba los archivos manualmente." } }, "table": { "head_name": "Nombre", "head_update": "Ultima actualización", "head_size": "Tamaño", "head_status": "Compartir", "head_thumbnail_size": "Cambiar el tamaño de las miniaturas", "row_update_format": "LLL d, yyyy", "row_update_format_full": "LLL d, yyyy", "row_read_only": "Compartido (sólo en lectura)", "row_read_write": "Compartido (Lectura & Escritura)", "row_size_symbols": { "B": "o", "KB": "Ko", "MB": "Mo", "GB": "Go", "TB": "To", "PB": "Po", "EB": "Eo", "ZB": "Zo", "YB": "Yo" }, "load_more": "Cargar más archivos", "mobile": { "head_name_asc": "A-Z", "head_name_desc": "Z-A", "head_updated_at_asc": "El más viejo primero", "head_updated_at_desc": "El más reciente primero", "head_size_asc": "El más liviano primero", "head_size_desc": "El más pesado primero" }, "tooltip": { "carbonCopy": { "title": "Copia Carbón", "caption": "Indica si Twake Workplace, su sitio de hospedaje, define el documento como \"auténtico y original\", ya que puede afirmar que proviene directamente de un servicio de terceros, sin haber sufrido ninguna modificación." }, "electronicSafe": { "title": "Seguridad electrónica", "caption": "Indica si el documento original está protegido por su seguridad digital personal con las certificaciones que le dan valor probatorio y una garantía de retención de 50 años más allá de su depósito si el documento es definido como \"auténtico y original\" por Twake Workplace, donde se hospeda su Twake, ya que puede afirmar que proviene directamente de un servicio de terceros, sin haber sufrido ninguna modificación.\n\n" } } }, "Storage": { "title": "Almacenamiento", "availability": "%{smart_count} GB disponibles", "increase": "Aumenta tu espacio" }, "SelectionBar": { "selected_count": "item seleccionado |||| items seleccionados", "share": "Compartir", "download": "Descargar", "trash": "Borrar", "destroy": "Borrar definitivamente", "rename": "Cambiar el nombre", "restore": "Restaurar", "close": "Cerrar", "openWith": "Abir con...", "applePreview": "Vista preliminar de Apple", "forward": "Enviar", "forwardTo": "Enviar a...", "moveto": "Trasladar a...", "moveto_mobile": "Trasladar", "phone-download": "Hacerla disponible cuando esté desconectado", "qualify": "Clasificar", "history": "Historia" }, "DeleteConfirm": { "title": "¿Suprimir este elemento? |||| ¿Suprimir estos elementos?", "trash": "Será desplazado a la Papelera. ||| Serán desplazados a la Papelera.", "restore": "Usted puede restaurarlo cuando lo desee. ||| Usted puede restaurarlos cuando lo desee.", "link": "El intercambio de enlaces ya no estará activo", "referenced": "Algunos de los archivos incluidos en la selección se refieren a un álbum de fotos. Se borrarán si usted procede a enviarlos a la papelera.", "cancel": "Anular", "delete": "Suprimir" }, "emptytrashconfirmation": { "title": "¿Suprimir definitivamente?", "forbidden": "Usted no podrá acceder más a estos archivos.", "restore": "Usted no podrá recuperar estos archivos si no ha hecho una copia de seguridad.", "cancel": "Anular", "delete": "Suprimir definitivamente" }, "DestroyConfirm": { "title": "¿Suprimir definitivamente?", "forbidden": "Usted no podrá acceder más a este archivo. ||| Usted no podrá acceder más a estos archivos.", "restore": "Usted no podrá recuperar este archivo si no ha hecho una copia de seguridad. ||| Usted no podrá recuperar estos archivos si no ha hecho una copia de seguridad.", "cancel": "Anular", "delete": "Suprimir definitivamente" }, "quotaalert": { "title": "Su espacio disco está lleno :(", "desc": "Por favor, suprima archivos, vacíe su basura o aumente su espacio en el disco antes de volver a subir archivos.", "confirm": "OK", "increase": "Aumente su espacio disco" }, "loading": { "message": "Cargando" }, "empty": { "title": "No hay archivos en esta carpeta.", "text": "Selecciona archivos en tu computadora o arrástralos aquí.", "mobile_text": "Selecciona archivos en tu dispositivo.", "trash_title": "Usted no tiene ningún archivo borrado.", "trash_text": "Los archivos que no necesita más échelos a la Papelera y suprímalos definitivamente para liberar espacio de almacenamiento." }, "error": { "open_folder": "Algo ha fallado al abrir la carpeta.", "button": { "reload": "Actualizar ahora" }, "download_file": { "offline": "Usted debe estar conectado para descargar este archivo", "missing": "Este archivo no existe" } }, "Error": { "public_unshared_title": "Lo sentimos, este enlace ya no es válido.", "public_unshared_text": "Este enlace ha caducado o ha sido eliminado por su propietario. Hágale saber a él o ella que lo ha perdido!", "generic": "Algo ha fallado. Espere algunos minutos y vuelva a ensayar." }, "alert": { "could_not_open_file": "El archivo no se puede abrir", "try_again": "Ha ocurrido un error, por favor ensaye más tarde.", "restore_file_success": "La selección ha sido restaurada con éxito.", "trash_file_success": "La selección ha sido desplazada a la Papelera.", "destroy_file_success": "Se ha suprimido definitivamente la selección.", "empty_trash_progress": "Su papelera se está vaciando. Esto puede tomar poco tiempo.", "empty_trash_success": "La papelera ha sido vaciada.", "folder_name": "El elemento %{folderName} ya existe, por favor escoger otro nombre.", "file_name": "El elemento %{fileName} ya existe, por favor escoger otro nombre.", "folder_generic": "Ha ocurrido un error, por favor vuelva a ensayar.", "folder_abort": "Se requiere poner un nombre a la nueva carpeta si desea guardarla. Su información no ha sido guardada.", "offline": "Esta función no esta disponible cuando usted está desconectado.", "preparing": "Preparando sus archivos...", "item_copied": "1 elemento copiado", "items_copied": "%{count} elementos copiados", "item_cut": "1 elemento cortado", "items_cut": "%{count} elementos cortados", "item_moved": "1 elemento ha sido movido", "items_moved": "%{count} elementos han sido movidos", "item_pasted": "1 elemento ha sido movido", "items_pasted": "%{count} elementos han sido movidos", "copy_files_only": "No se pueden copiar carpetas", "copy_not_allowed": "La operación de copia no está permitida en esta vista.", "cut_not_allowed": "La operación de corte no está permitida en esta vista.", "paste_error": "Ha ocurrido un error al pegar los archivos", "paste_failed": "Error al pegar los archivos", "paste_sharing_error": "No se pueden pegar los archivos debido a restricciones de compartición. Por favor use la acción Mover en su lugar.", "paste_same_folder_skipped": "No se pueden mover elementos a la misma carpeta en la que ya se encuentran.", "paste_not_allowed": "No puedes pegar en esta carpeta", "cannot_move_shared_drive": "No puedes mover la carpeta de unidad compartida", "cannot_copy_shared_drive": "No puedes copiar la carpeta de la unidad compartida" }, "upload": { "label": "Subir", "alert": { "network": "Usted no dispone de una conexión internet. Vuelva a ensayar cuando disponga de una." } }, "intents": { "alert": { "error": "La recuperación del archivo ha fallado. Súbalo manualmente con ayuda del menú de Twake." }, "picker": { "select": "Seleccionar", "cancel": "Anular", "new_folder": "Nueva carpeta", "instructions": "Seleccionar un blanco" } }, "UploadQueue": { "header": "Subiendo %{smart_count} foto a Twake Drive |||| Subiendo %{smart_count} fotos a Twake Drive", "header_mobile": "Subiendo %{done} de %{total}", "header_done": "Subidos %{done} de %{total} con éxito", "close": "cerrar", "item": { "pending": "Pendiente" } }, "Viewer": { "close": "Cerrar", "noviewer": { "download": "Descargar este archivo", "openWith": "Abir con...", "cta": { "saveTime": "¡Gane tiempo!", "installDesktop": "Instale la herramienta de sincronización para su ordenador", "accessFiles": "Acceda a sus archivos directamente desde su ordenador" } }, "actions": { "download": "Descargar", "forward": "Reenviar" }, "loading": { "error": "Este archivo no se puede cargar. ¿Tienes alguna conexión a Internet funcionando ahora?", "retry": "Reinténtelo" }, "error": { "generic": "Se ha producido un error al abrir este archivo, por favor inténtelo de nuevo.", "noNetwork": "Actualmente usted está desconectado." }, "panel": { "title": "Información útil" } }, "Move": { "to": "Trasladar a:", "action": "Trasladar", "cancel": "Anular", "modalTitle": "Trasladar", "title": "%{smart_count} elemento |||| %{smart_count} elementos", "success": "%{subject} ha sido desplazado a %{target}. |||| %{smart_count} elementos han sido desplazados a %{target}.", "error": "Ha ocurrido un error al desplazar este elemento, por favor vuelva a ensayar. |||| Ha ocurrido un error al desplazar estos elementos, por favor vuelva a ensayar.", "cancelled": "%{subject} ha sido devuelto a su carpeta de origen. |||| %{smart_count} elementos han sido devueltos a sus carpetas de origen.", "cancelledWithRestoreErrors": "%{subject} ha sido desplazado a su ubicación original pero hubo un error al restaurar el archivo de la papelera. |||| %{smart_count} elementos han sido desplazados a su ubicación original pero hubo %{restoreErrorsCount} error(es) al restaurar los archivos de la papelera.", "cancelled_error": "Lo sentimos, ha ocurrido un error al anular el desplazamiento. |||| Lo sentimos, un error ha ocurrido al anular los desplazamientos." }, "ImportToDrive": { "title": "%{smart_count} elemento |||| %{smart_count} elementos", "to": "Guardado en:", "action": "Guardar", "cancel": "Anular", "success": "%{smart_count} archivo guardado |||| %{smart_count} archivos guardados", "error": "Algo ha fallado, vuelva a ensayar" }, "FileOpenerExternal": { "fileNotFoundError": "Error: archivo no encontrado" }, "TOS": { "updated": { "title": "Lo nuevo en el RGPD", "detail": "En el marco de la Reglamento General de Protección de Datos (RGPD), [nuestras Condiciones Generales de Utilización se han actualizado](%{link})  y se aplicarán a partir del 25 de mayo de 2018.", "cta": "Aceptar CGU y continuar", "disconnect": "Rechazar y desconectarse", "error": "Algo ha fallado, vuelva a ensayar más tarde" } }, "manifest": { "permissions": { "contacts": { "description": "Necesario para compartir archivos con sus contactos" }, "groups": { "description": "Necesario para compartir archivos con sus grupos" } } }, "models": { "contact": { "defaultDisplayName": "Anónimo" } }, "Scan": { "scan_a_doc": "Escanear un doc", "save_doc": "Guardar el doc", "filename": "Nombre del archivo", "save": "Guardar", "cancel": "Anular", "qualify": "Clasificar", "apply": "Aplicar", "error": { "offline": "Usted está actualmente fuera de línea y no puede utilizar esta funcionalidad. Vuelva a ensayarlo más tarde", "uploading": "Ya está cargando un archivo. Espere hasta el final de la carga e inténtelo de nuevo.", "generic": "Algo ha fallado, vuelva a ensayar." }, "successful": { "qualified_ok": "¡Usted ha clasificado exitosamente su archivo!" } }, "History": { "description": "Las últimas 20 versiones de sus archivos se guardan automáticamente. Seleccione una versión para descargarla.", "current_version": "Versión actual", "loading": "Cargando...", "noFileVersionEnabled": "Su Twake pronto podrá archivar las últimas modificaciones de un archivo para no arriesgarse a perderlas en el futuro." }, "External": { "redirection": { "title": "Redireccionar", "text": "Está a punto de ser redireccionado...", "error": "Error durante la redirección. Generalmente, esto significa que el contenido del archivo no está en el formato correcto." } }, "RenameModal": { "title": "Cambiar el nombre", "description": "Está a punto de cambiar la extensión del archivo. ¿Quiere continuar?", "continue": "Continuar", "cancel": "Anular" }, "Shortcut": { "title_modal": "Crear un atajo", "filename": "Nombre del archivo", "url": "URL", "cancel": "Anular", "create": "Crear", "created": "Su atajo ha sido creado", "errored": "Ha ocurrido un error", "filename_error_ends": "El nombre debe terminar con .url", "needs_info": "El atajo necesita al menos una url y un nombre de archivo", "url_badformat": "Su url no está en el formato correcto" }, "searchbar": { "placeholder": "Buscar", "empty": "No se ha encontrado ningún resultado para su consulta “%{query}”" }, "search": { "empty": { "subtitle": "No se ha encontrado ningún resultado para su consulta “%{query}”" } }, "actions": { "details": "Detalles", "personalizeFolder": { "label": "Personalizar carpeta" }, "summariseByAI": "Resumir" }, "FolderCustomizer": { "title": "Personalizar carpeta", "description": "Elija un color específico para su carpeta", "cancel": "Cancelar", "apply": "Aplicar", "error": "Se ha producido un error, por favor inténtelo de nuevo.", "tabs": { "colors": "Colores", "icons": "Iconos" }, "iconPicker": { "recents": "Recientes", "chooseCustomIcon": "Elegir un icono personalizado" } } } ================================================ FILE: src/locales/fr.json ================================================ { "Nav": { "item_drive": "Mon Drive", "item_recent": "Récents", "item_sharings": "Partages", "item_shared": "Partagés", "item_activity": "Activité", "item_trash": "Corbeille", "item_migration": "Migration", "item_settings": "Paramètres", "item_collect": "Administratif", "item_shared_drives": "Drives partagés", "item_favorites": "Favoris", "item_external_drives": "Disques externes", "item_my_drive": "Mon Drive", "btn-client": "Télécharger Twake Drive ", "btn-client-web": "Obtenez un Twake", "btn-client-mobile": "Emportez votre cloud personnel avec vous : installez notre app %{name} !", "banner-txt-client": "Installez %{name} pour ordinateur et synchronisez vos fichiers pour les rendre accessibles à tout moment.", "banner-btn-client": "Télécharger", "link-client": "https://cozy.io/fr/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile", "link-client-ios": "https://apps.apple.com/app/cloud-personnel-cozy/id1600636174", "link-client-web": "https://cozy.io/try-it", "view_more": "Voir plus", "view_less": "Voir moins", "item_nextcloud": "Nextcloud" }, "breadcrumb": { "title_drive": "Fichiers", "title_recent": "Récents", "title_sharings": "Partages", "title_shared": "Mes fichiers partagés", "title_activity": "Activité", "title_trash": "Corbeille", "label": "Voir le chemin", "title_shared_drives": "Drives", "title_favorites": "Favoris" }, "Toolbar": { "more": "Plus" }, "toolbar": { "menu_manage_access": "Gérer les accès", "menu_leave_shared_drive": "Quitter le partage", "menu_upload": "Importer des fichiers", "item_more": "Plus", "menu_new_folder": "Dossier", "menu_new_shared_drive": "Drive partagé", "menu_select": "Sélectionner les éléments", "menu_share_folder": "Partager le dossier", "menu_download": "Télécharger", "menu_sync_cozy": "Synchroniser dans mon Twake", "add_to_mine": "Ajouter à mon Twake", "menu_download_folder": "Télécharger le dossier", "menu_download_file": "Télécharger ce fichier", "menu_create_note": "Note", "menu_create_docs": "Docs", "menu_create_shortcut": "Raccourci", "share": "Partager", "trash": "Supprimer", "delete_shared_drive": "Supprimer le drive partagé", "leave": "Quitter le partage et supprimer le dossier", "menu_add": "Ajouter", "menu_create": "Créer", "menu_add_item": "Ajouter un élément", "menu_onlyOffice": { "text": "Document texte", "spreadsheet": "Feuille de calcul", "slide": "Présentation" }, "select_all": "Tout sélectionner", "select_all_mobile": "Tout", "clear_selection": "Effacer la sélection", "clear_selection_mobile": "Annuler", "sharings_tab_all": "Tout", "sharings_tab_drives": "Drives" }, "Share": { "create-cozy": "Créer mon Twake" }, "Files": { "share": { "cta": "Partager", "title": "Partager", "details": { "title": "Détails du partage", "createdAt": "Depuis le %{date}", "ro": "Peut consulter", "rw": "Peut modifier", "desc": { "ro": "Vous pouvez consulter, télécharger, et ajouter ce contenu à votre Twake. Vous recevrez les modifications faites par le propriétaire, mais vous ne pourrez pas le modifier.", "rw": "Vous pouvez consulter, modifier et supprimer du contenu. Les modifications sur le contenu seront répercutées automatiquement entre vos Twake." } }, "shared": "Partagé", "sharedByMe": "Partagé", "sharedWithMe": "Partagé avec moi", "sharedBy": "Partagé par %{name}", "shareByLink": { "subtitle": "Par lien public", "desc": "Chaque personne possédant le lien fourni peut voir et télécharger vos fichiers.", "creating": "Création du lien...", "copy": "Copier le lien", "copied": "Lien copié dans le presse-papiers.", "failed": "Impossible de copier dans le presse papier" }, "shareByEmail": { "subtitle": "Par email", "email": "À :", "emailPlaceholder": "Saisissez le courriel ou le nom du destinataire.", "send": "Envoyer", "genericSuccess": "Vous avez invité %{count} contacts.", "success": "Vous avez envoyé une invitation à %{email}.", "comingsoon": "Bientôt disponible ! Vous pourrez partager un document et vos photos en un seul clic avec votre famille, vos amis, et même vos collaborateurs. Ne vous inquiétez pas, on vous prévient quand ce sera prêt !", "onlyByLink": "Ce %{type} ne peut être partagé que sous la forme d'un lien, car il", "type": { "file": "fichier", "folder": "dossier" }, "hasSharedParent": "se trouve dans un dossier partagé.", "hasSharedChild": "contient un élément partagé." }, "revoke": { "title": "Arrêter le partage", "desc": "Votre contact conservera une copie mais vos changements ne seront plus synchronisés.", "success": "Vous avez cessé de partager ce fichier avec %{email}." }, "revokeSelf": { "title": "Arrêter le partage", "desc": "Vous conservez le contenu mais il ne sera plus mis à jour entre vos Twake.", "success": "Vous avez été retiré de ce partage." }, "sharingLink": { "title": "Partager", "copy": "Copier", "copied": "Copié" }, "whoHasAccess": { "title": "1 personne y a accès |||| %{smart_count} personnes y ont accès" }, "protectedShare": { "title": "Prochainement !", "desc": "Partagez ce que vous souhaitez par email avec votre famille et vos amis !" }, "close": "Fermer", "gettingLink": "Création du lien…", "error": { "generic": "Une erreur est survenue lors de la création du lien de partage, merci de réessayer", "revoke": "Oups, une erreur est survenue. Contactez-nous pour que nous résolvions la situation au plus vite.\n" }, "specialCase": { "base": "Ce %{type} ne peut être partagé que sous la forme d'un lien, car il", "isInSharedFolder": "se trouve dans un dossier partagé.", "hasSharedFolder": "contient un dossier partagé." } }, "viewer-fallback": "Le fichier est en cours de téléchargement, vous pouvez fermer cette fenêtre.", "dropzone": { "teaser": "Déposez des fichiers pour les importer vers :", "noFolderSupport": "Votre navigateur ne prend pas en charge le glisser-déposer de dossier pour le moment. Veuillez importer les fichiers manuellement." } }, "table": { "head_name": "Nom", "head_update": "Mise à jour", "head_size": "Taille", "head_status": "Partage", "head_thumbnail_size": "Changer la taille des miniatures", "head_view_mode": "Mode d'affichage", "head_view_list": "Vue liste", "head_view_grid": "Vue grille", "row_update_format": "d LLL yyyy", "row_update_format_full": "d LLLL yyyy", "row_read_only": "Partagé (lecture seule)", "row_read_write": "Partagé (lecture & écriture)", "row_size_symbols": { "B": "o", "KB": "Ko", "MB": "Mo", "GB": "Go", "TB": "To", "PB": "Po", "EB": "Eo", "ZB": "Zo", "YB": "Yo" }, "row_sharing_shortcut_aria_label": "Nouveau raccourci de partage", "load_more": "Plus de fichiers", "mobile": { "head_name_asc": "A-Z", "head_name_desc": "Z-A", "head_updated_at_asc": "Plus anciens en premier", "head_updated_at_desc": "Plus récents en premier", "head_size_asc": "Plus légers en premier", "head_size_desc": "Plus lourds en premier" }, "tooltip": { "carbonCopy": { "title": "Copie conforme", "caption": "Le document est défini \"authentique et original\" par Twake Workplace, l'hébergeur de votre Twake, car il peut affirmer qu'il provient directement des services de son émetteur sans avoir subi aucune modification." }, "electronicSafe": { "title": "Coffre-fort numérique", "caption": "Indique si le document original est sécurisé par votre coffre-fort numérique personnel avec les certifications qui lui confèrent une valeur probante et une garantie de conservation de 50 ans au-delà de son dépôt." } } }, "Storage": { "title": "Stockage", "availability": "%{smart_count} Go disponible", "increase": "Augmenter l'espace" }, "SelectionBar": { "selected_count": "élément sélectionné |||| éléments sélectionnés", "share": "Partager", "download": "Télécharger", "trash": "Supprimer", "trash_all": "Supprimer tout", "destroy": "Supprimer définitivement", "rename": "Renommer", "restore": "Restaurer", "close": "Fermer", "openWith": "Ouvrir avec...", "applePreview": "Aperçu Apple", "forward": "Transférer", "forwardTo": "Transférer vers...", "moveto": "Déplacer vers…", "moveto_mobile": "Déplacer", "phone-download": "Rendre accessible hors-ligne", "requalify": "Requalifier", "qualify": "Qualifier", "history": "Versions", "more": "Afficher plus d'action", "openWithinNextcloud": "Ouvrir dans Nextcloud" }, "DeleteConfirm": { "title": "Supprimer %{filename} ? |||| Supprimer %{smart_count} %{type} ?", "trash": "Cet élément sera déplacé dans la corbeille. |||| Ces éléments seront déplacés dans la corbeille.", "restore": "Vous pouvez toujours le restaurer quand vous voulez.", "share_accepted": "Le partage sera arrêté. Ainsi, les contacts suivant conserveront une copie mais vos changements ne seront plus synchronisés :", "share_waiting": "Le partage sera arrêté. Ainsi, les contacts suivant ne pourront donc plus accepter le partage et ne pourront plus accéder aux contenus partagés :", "share_both": "Le partage sera arrêté. Ainsi, les contacts ayant stocké les fichiers dans leur Twake conserveront une copie, les autres contacts ne pourront plus accéder aux contenus partagés :", "link": "Le partage par lien ne sera plus actif.", "referenced": "Des photos de la sélection sont dans un album. Elles seront retirées de l'album si vous confirmez.", "cancel": "Annuler", "delete": "Supprimer" }, "EmptyTrashConfirm": { "title": "Supprimer définitivement ?", "forbidden": "Vous ne pourrez plus accéder à ces fichiers.", "restore": "Vous ne pourrez pas restaurer ces fichiers.", "cancel": "Annuler", "delete": "Tout supprimer", "processing": "Votre corbeille est en train de se vider. Cela peut prendre quelques instants.", "success": "La corbeille a été vidée.", "error": "Une erreur est survenue, merci de réessayer." }, "DestroyConfirm": { "title": "Supprimer %{filename} ? |||| Supprimer %{smart_count} %{type} ?", "forbidden": "Vous ne pourrez plus accéder à ce %{type}. |||| Vous ne pourrez plus accéder à ces %{type}.", "restore": "Vous ne pourrez pas restaurer ce %{type}. |||| Vous ne pourrez pas restaurer ces %{type}.", "cancel": "Annuler", "delete": "Supprimer définitivement", "success": "Le %{type} a été supprimé définitivement. |||| %{smart_count} %{type} ont été supprimés définitivement.", "error": "Une erreur est survenue, merci de réessayer.", "processing": "La suppression est en cours. Cela peut prendre quelques instants." }, "quotaalert": { "title": "Votre espace disque est plein :(", "desc": "Veuillez supprimer des fichiers, vider votre corbeille ou augmenter votre espace disque avant d'importer de nouveau fichier.", "confirm": "OK", "increase": "Augmenter votre espace disque" }, "loading": { "message": "Chargement", "onlyOfficeCreateInProgress": "Création du fichier en cours..." }, "empty": { "title": "Vous n'avez aucun fichier dans ce dossier.", "text": "Sélectionnez les fichiers sur votre ordinateur ou faites-les glisser ici.", "mobile_text": "Sélectionnez les fichiers sur votre appareil.", "trash_title": "Vous n'avez aucun fichier supprimé.", "trash_text": "Déplacez les fichiers dont vous n'avez plus besoin dans la corbeille et supprimez-les définitivement pour récupérer de l'espace de stockage.", "shared-drive_text": "Créez et partagez votre premier drive." }, "error": { "open_folder": "Une erreur est survenue pendant l'ouverture du dossier.", "open_file": "Une erreur est survenue pendant l'ouverture du fichier.", "button": { "reload": "Rafraîchir" }, "download_file": { "offline": "Vous devez être connecté pour pouvoir ouvrir ce fichier", "missing": "Le fichier n'existe pas" } }, "Error": { "public_unshared_title": "Désolé, ce lien n'est plus disponible.", "public_unshared_text": "Ce lien a expiré ou il a été supprimé par le ou la propriétaire. Signalez-lui que vous voulez accéder à son contenu !", "generic": "Une erreur s'est produite. Attendez quelques minutes et recommencez." }, "alert": { "could_not_open_file": "Impossible d'ouvrir le fichier", "try_again": "Une erreur est survenue, merci de réessayer dans un instant.", "restore_file_success": "La sélection a été restaurée avec succès.", "trash_file_success": "La sélection a été déplacée dans la Corbeille.", "trash_file_processing": "Le déplacement vers la Corbeille est en cours...", "trash_shared_drive_success": "Le drive partagé a été déplacé dans la Corbeille.", "destroy_file_success": "La sélection a été supprimée définitivement.", "folder_name": "L'élément %{folderName} existe déjà, merci de choisir un nouveau nom.", "file_name": "L'élément %{fileName} existe déjà, utilisez un nouveau nom", "file_name_missing": "Le nom du fichier est manquant, veuillez choisir un nouveau nom.", "file_name_illegal_name": "Le nom du fichier %{fileName} est invalide, veuillez choisir un nouveau nom.", "file_name_illegal_characters": "Le nom du fichier %{fileName} est invalide, il contient les caractères interdits suivants : %{characters}", "folder_generic": "Une erreur est survenue, merci de réessayer.", "folder_abort": "Vous devez nommer votre dossier si vous voulez le sauvegarder. Vos informations n'ont pas été enregistrées.", "offline": "Cette fonctionnalité n’est pas disponible en mode hors-ligne.", "preparing": "Préparation de vos fichiers...", "item_copied": "1 élément copié", "items_copied": "%{count} éléments copiés", "item_cut": "1 élément coupé", "items_cut": "%{count} éléments coupés", "item_moved": "1 élément a été déplacé", "items_moved": "%{count} éléments ont été déplacés", "item_pasted": "1 élément a été déplacé", "items_pasted": "%{count} éléments ont été déplacés", "copy_files_only": "Impossible de copier les dossiers", "copy_not_allowed": "L'opération de copie n'est pas autorisée dans cette vue.", "cut_not_allowed": "L'opération de coupe n'est pas autorisée dans cette vue.", "delete_not_allowed": "L'opération de suppression n'est pas autorisée dans cette vue.", "paste_error": "Une erreur s'est produite lors du collage des fichiers", "paste_failed": "Échec du collage des fichiers", "paste_sharing_error": "Impossible de coller les fichiers en raison de restrictions de partage. Veuillez utiliser l'action Déplacer à la place.", "paste_same_folder_skipped": "Impossible de déplacer les éléments dans le même dossier où ils se trouvent déjà.", "paste_not_allowed": "Vous ne pouvez pas coller dans ce dossier", "cannot_move_shared_drive": "Vous ne pouvez pas déplacer le dossier de lecteur partagé", "cannot_copy_shared_drive": "Vous ne pouvez pas copier le dossier du lecteur partagé" }, "upload": { "label": "Importer", "documentType": { "file": "fichier", "directory": "dossier", "element": "élément" }, "alert": { "success": "%{smart_count} %{type} importé. |||| %{smart_count} %{type} importés.", "success_conflicts": "%{smart_count} %{type} importé avec %{conflictNumber} conflit(s). |||| %{smart_count} %{type} importés avec %{conflictNumber} conflit(s).", "success_updated": "%{smart_count} %{type} importé et %{updatedCount} mis à jour. |||| %{smart_count} %{type} importés et %{updatedCount} mis à jour.", "success_updated_conflicts": "%{smart_count} %{type} importé, %{updatedCount} mis à jour et %{conflictCount} conflit(s). |||| %{smart_count} %{type} importés, %{updatedCount} mis à jour et %{conflictCount} conflit(s).", "updated": "%{smart_count} %{type} mis à jour. |||| %{smart_count} %{type} mis à jour.", "updated_conflicts": "%{smart_count} %{type} mis à jour avec %{conflictCount} conflit(s). |||| %{smart_count} %{type} mis à jour avec %{conflictCount} conflit(s).", "errors": "Une erreur est survenue lors de l’import du %{type}, merci de réessayer plus tard.", "network": "Vous ne disposez pas d'une connexion internet. Merci de réessayer quand ce sera le cas.", "fileTooLargeErrors": "Fichier trop volumineux. Taille maximale autorisée par fichier : %{max_size_value} Go", "unreadable_files": "Certains fichiers n'ont pas pu être lus. Le chemin est peut-être trop long ou le dossier a été modifié pendant le transfert." }, "limit": { "title": "Importation impossible : Ce dossier contient plus de %{limit} fichiers", "content": "Pour une importation de cette taille, nous vous recommandons d'utiliser l'application de synchronisation sur ordinateur.", "content_public": "Veuillez réduire le nombre de fichiers et réessayer.", "cancel": "Annuler", "close": "Fermer", "download_desktop": "Installer l'application" } }, "intents": { "alert": { "error": "La récupération du fichier a échoué. Téléchargez le fichier manuellement puis ajoutez-le à Twake. " }, "picker": { "select": "Sélectionner", "cancel": "Annuler", "new_folder": "Nouveau dossier", "instructions": "Choisir une cible" } }, "UploadQueue": { "header": "Import de %{smart_count} élément dans votre Twake |||| Import de %{smart_count} éléments dans votre Twake", "header_preparing": "Préparation de %{smart_count} élément |||| Préparation de %{smart_count} éléments", "header_mobile": "Import de %{done} sur %{total}", "header_done": "%{done} sur %{total} élément(s) importé(s)", "success_flagship": "%{smart_count} fichier importé avec succès. |||| %{smart_count} fichiers importés avec succès.", "close": "Fermer", "item": { "pending": "En attente", "preparing": "En préparation" } }, "Viewer": { "close": "Fermer", "noviewer": { "download": "Télécharger ce fichier", "openWith": "Ouvrir avec...", "openInOnlyOffice": "Ouvrir avec Only Office", "cta": { "saveTime": "Gagnez du temps !", "installDesktop": "Installez l'outil de synchronisation pour ordinateur", "accessFiles": "Accédez à vos fichiers directement sur votre ordinateur" } }, "actions": { "download": "Télécharger", "forward": "Transférer" }, "loading": { "error": "Ce fichier n'a pas pu être chargé. Avez-vous une connexion internet qui fonctionne actuellement ?", "retry": "Réessayer" }, "error": { "noapp": "Votre téléphone n'a identifié aucune application pour lire ce type de fichier.", "generic": "Une erreur est survenue lors de l'ouverture de ce fichier, merci de réessayer.", "noNetwork": "Vous êtes actuellement hors ligne." }, "panel": { "title": "Informations utiles" } }, "Move": { "to": "Déplacer vers :", "action": "Déplacer", "cancel": "Annuler", "modalTitle": "Déplacer", "title": "%{smart_count} élément |||| %{smart_count} éléments", "success": "%{subject} a été déplacé dans %{target}. |||| %{smart_count} éléments ont été déplacés dans %{target}.", "error": "Une erreur est survenue pendant le déplacement de cet élément, merci de réessayer plus tard. |||| Une erreur est survenue pendant le déplacement de ces éléments, merci de réessayer plus tard.", "cancelled": "%{subject} a été rapatrié dans son dossier d’origine. |||| %{smart_count} éléments ont été rapatriés dans leur dossiers d’origine.", "cancelledWithRestoreErrors": "%{subject} a été rapatrié dans son dossier d'origine mais il y a eu une erreur lors de la restauration du fichier depuis la corbeille. |||| %{smart_count} éléments ont été rapatriés dans leur dossiers d'origine mais il y a eu %{restoreErrorsCount} erreur(s) lors de la restauration des fichiers depuis la corbeille.", "cancelled_error": "Une erreur est survenue lors de l’annulation du déplacement. |||| Une erreur est survenue lors de l’annulation de ces déplacements.", "multipleEntries": "%{smart_count} élément |||| %{smart_count} éléments", "addFolder": "Ajouter un dossier", "outsideSharedFolder": { "title": "Déplacement en dehors du dossier %{sharedFolder}", "content_1": "Attention, vous souhaitez déplacer %{name} en dehors du dossier partagé %{sharedFolder}. |||| Attention, vous souhaitez déplacer %{smart_count} %{type} en dehors du dossier partagé %{sharedFolder}.", "content_2": "Ce déplacement, va retirer le %{type} %{name} du partage. Ce %{type} va donc être mis à la corbeille pour l'ensemble des membres du partage. |||| Ce déplacement, va retirer les %{smart_count} %{type} du partage. Ces %{type} vont donc être mis à la corbeille pour l'ensemble des membres du partage.", "cancel": "Annuler", "confirm": "J'ai compris" }, "insideSharedFolder": { "title": "Déplacer vers un dossier partagé ?", "content": "Tous les membres ayant accès à %{destination} auront également accès à %{source}. |||| Tous les membres ayant accès à %{destination} auront également accès aux %{type} sélectionnés.", "cancel": "Annuler", "confirm": "Ok" }, "sharedFolderInsideAnother": { "title": "Déplacement impossible", "content_1": "Vous souhaitez déplacer un élément partagé dans un dossier lui-même partagé. Ce type déplacement n'est pas autorisé.", "content_2": "Si vous souhaitez tout de même déplacer %{source} dans %{destination}, veuillez arrêter le partage de :", "cancel": "Annuler le déplacement", "confirm": "Arrêter le partage" } }, "ImportToDrive": { "title": "%{smart_count} fichier |||| %{smart_count} fichiers", "to": "Enregistrer dans :", "action": "Enregistrer", "cancel": "Annuler", "success": "%{smart_count} fichier enregistré |||| %{smart_count} fichiers enregistrés", "error": "Une erreur s'est produite. Merci de recommencer. " }, "FileOpenerExternal": { "fileNotFoundError": "Erreur : fichier non trouvé" }, "TOS": { "updated": { "title": "Du nouveau avec le RGPD !", "detail": "Dans le cadre du Règlement Général de la Protection des Données (RGPD), [nos CGU sont actualisées](%{link}) et s’appliquent pour vous à partir du 25 mai 2018.", "cta": "Accepter les CGU et continuer", "disconnect": "Refuser et se déconnecter", "error": "Une erreur est survenue, merci de réessayer plus tard" } }, "manifest": { "permissions": { "contacts": { "description": "Utilisé pour partager des éléments à vos contacts" }, "groups": { "description": "Utilisé pour partager des éléments à vos groupes" } } }, "models": { "contact": { "defaultDisplayName": "Anonyme" } }, "Scan": { "none": "Aucune", "scan_a_doc": "Numériser un doc", "save_doc": "Enregistrer le document", "filename": "Nom du fichier", "save": "Sauvegarder", "cancel": "Annuler", "qualify": "Qualifier", "requalify": "Requalifier", "apply": "Appliquer", "error": { "offline": "Vous êtes actuellement déconnecté, vous ne pouvez donc pas utiliser cette fonctionnalité. Connectez-vous à internet et recommencez. ", "uploading": "Vous avez déjà un fichier en cours de téléchargement. Attendez la fin et recommencez.", "generic": "Un problème est survenu. Veuillez réessayer. " }, "successful": { "qualified_ok": "Fichier qualifié avec succès !" } }, "History": { "description": "Les 20 dernières versions de vos fichiers sont conservées automatiquement. Sélectionnez une version pour la télécharger.", "current_version": "Version actuelle", "loading": "Chargement...", "noFileVersionEnabled": "Nouveauté : votre Twake pourra prochainement archiver les dernières modifications d'un fichier pour ne plus jamais risquer de les perdre" }, "External": { "redirection": { "title": "Redirection", "text": "Vous êtes sur le point d'être redirigé... ", "error": "Erreur pendant la redirection. Généralement cela signifie que le contenu du fichier n'est pas dans le bon format. " } }, "RenameModal": { "title": "Renommer", "description": "Vous êtes sur le point de changer l'extension du fichier. Voulez-vous continuer ? ", "continue": "Continuer", "cancel": "Annuler" }, "Shortcut": { "title_modal": "Créer un raccourci", "filename": "Nom du fichier", "url": "URL", "cancel": "Annuler", "create": "Créer", "created": "Le raccourci a été créé", "errored": "Une erreur s'est produite", "filename_error_ends": "Le nom du fichier doit se terminer par .url", "needs_info": "Un raccourci a besoin d'un nom et d'une URL", "url_badformat": "L'URL saisie n'est pas dans le bon format" }, "OnlyOffice": { "Error": { "title": "Quelque chose n'a pas fonctionné", "text": "Essayez de recharger la page s'il vous plaît" }, "readOnly": { "title": "Lecture seule", "tooltip": "Vous êtes uniquement autorisé à visualiser ce document. Contactez le propriétaire pour obtenir des droits d'écriture." }, "createFileName": { "text": "Nouveau document texte", "spreadsheet": "Nouvelle feuille de calcul", "slide": "Nouvelle présentation" }, "toolbar": { "goToHome": "Aller à l'accueil" }, "actions": { "edit": "Modifier", "validate": "Valider" }, "tooltip": { "title": "Modifier le document", "text": "Le document est actuellement en lecture seule, Vous pouvez le modifier en cliquant ici.", "actions": { "ok": "Ok", "hide": "Ne plus afficher" } } }, "Migration": { "title": "Mettre à jour Twake Drive", "content": "Twake Drive doit être mis à jour afin d'améliorer ses performances. Cela peut prendre jusqu'à plusieurs minutes durant lesquelles vous ne pourrez pas utiliser l'application. Souhaitez-vous le faire maintenant ? Si vous refusez, nous vous redemanderons la prochaine fois.", "confirm": "Ok, c'est parti !", "cancel": "Non, pas maintenant" }, "searchbar": { "placeholder": "Rechercher", "empty": "Aucun résultat trouvé pour la requête \"%{query}\"" }, "button": { "back": "Retour", "add": "Ajouter", "create": "Créer" }, "search": { "action": "Rechercher", "empty": { "title": "Aucun résultat", "subtitle": "Aucun résultat trouvé pour la requête \"%{query}\"" } }, "PushBanner": { "quota": { "text": "Vous n'avez presque plus d'espace de stockage. Si vous atteignez la limite, vous ne pourrez plus ajouter de fichiers. Vous pouvez supprimer des fichiers, vider votre corbeille ou changer d'offre.", "actions": { "first": "J'ai compris", "second": "Voir les offres" } } }, "FileDivergedModal": { "title": "Quelqu’un a modifié ce fichier", "content": "Quelqu’un a modifié le contenu de ce fichier pendant que vous l'éditiez. Vous pouvez récupérer ces changements ou continuer votre édition sur un nouveau fichier.", "confirm": "Continuer d'éditer", "cancel": "Voir les changements", "error": "Une erreur est survenue, merci de réessayer.", "confirmReload": { "title": "Voir les changements", "content": "En accédant au nouveau fichier, vos modifications seront annulées.", "cancel": "Annuler", "confirm": "Ok, j’ai compris" }, "viewMode": { "title": "Quelqu’un a modifié ce fichier", "content": "Quelqu’un a modifié le contenu de ce fichier. Vous pouvez récupérer ces changements.", "confirm": "Voir les changements" } }, "FileDeletedModal": { "title": "Quelqu’un a supprimé ce fichier", "content": "Quelqu’un a supprimé ce fichier pendant que vous l'éditiez. Vous pouvez arrêter vos modifications ou restaurer ce fichier pour continuer vos modifications.", "confirm": "Restaurer le fichier", "cancel": "Annuler l'édition", "error": "Une erreur est survenue, merci de réessayer." }, "TrashedBanner": { "text": "Cet élément est dans la corbeille", "destroy": "Supprimer définitivement", "restore": "Restaurer", "restoreSuccess": "L’élément a bien été restauré", "restoreError": "Une erreur est survenue, merci de réessayer.", "destroySuccess": "L’élément a bien été supprimé" }, "MigrationProgressBanner": { "title": "Migration depuis Nextcloud en cours", "percent": "%{percent}% terminé", "importing": "Importation de %{count} fichiers depuis Nextcloud ...", "cancel": "Annuler", "done": { "title": "Migration terminée !", "body": "%{count} fichiers importés avec succès depuis Nextcloud" } }, "EntriesType": { "file": "fichier |||| fichiers", "directory": "dossier |||| dossiers", "element": "élément |||| éléments" }, "NotFound": { "title": "L’élément est introuvable", "text": "Nous n’avons trouvé aucun élément à cette adresse. Il s’agit peut-être d’une erreur de frappe." }, "NextcloudBreadcrumb": { "root": "Drive partagés", "trash": "Corbeille" }, "NextcloudToolbar": { "share": "Partager" }, "NextcloudDeleteConfirm": { "title": "Supprimer %{filename} ? |||| Supprimer %{smart_count} %{type} ?", "trash": "Cet élément sera déplacé dans la corbeille de Nextcloud. |||| Ces éléments seront déplacés dans la corbeille de Nextcloud.", "restore": "Vous pouvez toujours le restaurer quand vous voulez depuis Nextcloud.", "error": "Une erreur est survenue, merci de réessayer.", "cancel": "Annuler", "delete": "Supprimer" }, "FileName": { "sharedDrive": "Drives", "trash": "Corbeille" }, "NextcloudBanner": { "title": "Les éléments ci-dessous sont affichés depuis un drive NextCloud et ne sont pas stockés dans votre Twake." }, "favorites": { "label": { "add": "Ajouter aux favoris", "addMobile": "Favoris", "remove": "Retirer des favoris" }, "error": "Une erreur est survenue, merci de réessayer.", "success": { "add": "%{filename} a été ajouté aux favoris |||| Ces éléments ont été ajoutés aux favoris", "remove": "%{filename} a été retiré des favoris |||| Ces éléments ont été retirés des favoris" } }, "TrashToolbar": { "emptyTrash": "Vider la corbeille" }, "RestoreNextcloudFile": { "label": "Restaurer", "success": "L'élément a bien été restauré", "error": "Une erreur est survenue, merci de réessayer." }, "actions": { "details": "Détails", "infos": "Détails et qualification", "infosMobile": "Détails", "duplicateTo": { "label": "Dupliquer vers…" }, "duplicateToMobile": { "label": "Dupliquer" }, "personalizeFolder": { "label": "Personnaliser le dossier" }, "summariseByAI": "Résumer" }, "FolderCustomizer": { "title": "Personnaliser le dossier", "description": "Choisissez une couleur spécifique pour votre dossier", "cancel": "Annuler", "apply": "Appliquer", "error": "Une erreur est survenue, merci de réessayer.", "tabs": { "colors": "Couleurs", "icons": "Icônes" }, "iconPicker": { "recents": "Récents", "chooseCustomIcon": "Choisir une icône personnalisée" } }, "DuplicateModal": { "subTitle": "Dupliquer vers :", "confirmLabel": "Dupliquer ici", "success": "%{fileName} a été dupliqué dans %{destinationName}. |||| %{smart_count} éléments ont été dupliqués dans %{destinationName}.", "error": "Une erreur est survenue, merci de réessayer." }, "OpenFolderButton": { "label": "Ouvrir le dossier" }, "LastUpdate": { "titleFormat": "dd LLLL yyyy, HH:MM" }, "AddMenu": { "readOnlyFolder": "Ce dossier est en lecture seule. Vous ne pouvez pas effectuer cette action." }, "PublicNoteRedirect": { "error": { "title": "Impossible d'accéder au document", "subtitle": "Le lien de partage semble manquant ou invalide. Merci de demander au propriétaire du document de vérifier les accès" } }, "antivirus": { "infectedFile": "Ce fichier est infecté par un virus", "popover": { "title": "Le téléchargement et le partage sont bloqués pour des raisons de sécurité", "description": "Le système Twake a détecté un virus" } } } ================================================ FILE: src/locales/index.js ================================================ import { getI18n } from 'twake-i18n' import ar from './ar.json' import de from './de.json' import en from './en.json' import es from './es.json' import fr from './fr.json' import it from './it.json' import ja from './ja.json' import ko from './ko.json' import nl from './nl.json' import nl_NL from './nl_NL.json' import pl from './pl.json' import ru from './ru.json' import zh_CN from './zh_CN.json' import zh_TW from './zh_TW.json' export const locales = { ar, de, en, es, fr, it, ja, ko, nl, nl_NL, pl, ru, zh_CN, zh_TW } export const getDriveI18n = () => getI18n(undefined, lang => locales[lang]) ================================================ FILE: src/locales/it.json ================================================ { "Nav": { "item_drive": "Drive", "item_recent": "Recenti", "item_sharings": "Condivisioni", "item_shared": "Condiviso da me", "item_activity": "Attività", "item_trash": "Cestino", "item_settings": "Impostazioni", "item_collect": "Amministrativo", "btn-client": "Ottieni Twake Drive per desktop", "btn-client-web": "Ottieni Twake", "btn-client-mobile": "Ottieni %{name} sul tuo telefono!", "banner-txt-client": "Ottieni %{name} per Desktop e sincronizza i tuoi file in modo sicuro per renderli accessibili in qualsiasi momento.", "banner-btn-client": "Scarica", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile", "link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8", "link-client-web": "https://cozy.io/try-it" }, "breadcrumb": { "title_drive": "Drive", "title_recent": "Recenti", "title_sharings": "Condivisioni", "title_shared": "Condiviso da me", "title_activity": "Attività", "title_trash": "Cestino", "label": "Mostra percorso" }, "Toolbar": { "more": "Altro" }, "toolbar": { "menu_upload": "Carica files", "item_more": "Altro", "menu_new_folder": "Cartella", "menu_select": "Seleziona oggetti", "menu_share_folder": "Condividi cartella", "menu_download": "Scarica", "menu_sync_cozy": "Sincronizza con il mio Twake", "add_to_mine": "Aggiungi al mio Twake", "menu_download_folder": "Cartella download", "menu_download_file": "Scarica questo file", "menu_create_note": "Nota", "menu_create_shortcut": "Collegamento", "empty_trash": "Svuota cestino", "share": "Condividi", "trash": "Rimuovi", "delete_shared_drive": "Elimina unità condivisa", "leave": "Lascia la cartella condivisa ed eliminala", "menu_add": "Aggiungi", "menu_create": "Creare", "menu_onlyOffice": { "text": "Documento di testo", "spreadsheet": "Foglio di calcolo", "slide": "Presentazione" }, "select_all": "Seleziona tutto", "clear_selection": "Cancella selezione", "sharings_tab_all": "Tutti", "sharings_tab_drives": "Unità" }, "Share": { "create-cozy": "Crea il mio Twake" }, "Files": { "share": { "cta": "Condividi", "title": "Condividi", "details": { "title": "Dettagli condivisione", "createdAt": "Il %{date}", "ro": "Può visualizzare", "rw": "Può modificare", "desc": { "ro": "È possibile visualizzare, scaricare e aggiungere questo contenuto al proprio Twake. Riceverao gli aggiornamenti da parte del proprietario, ma non potrai aggiornarli tu stesso.", "rw": "Puoi visualizzare, aggiornare, cancellare e aggiungere questo contenuto al tuo Twake. Gli aggiornamenti apportati saranno visibili anche agli altri Twake." } }, "sharedByMe": "Condiviso da me", "sharedWithMe": "Condiviso con me", "sharedBy": "Condiviso da %{name}", "shareByLink": { "subtitle": "Tramite link pubblico", "desc": "Chiunque abbia il link fornito può vedere e scaricare i tuoi file.", "creating": "Creazione del link...", "copy": "Copia link", "copied": "Il link è stato copiato negli appunti", "failed": "Impossibile copiare negli appunti" }, "shareByEmail": { "subtitle": "Tramite email", "email": "A:", "emailPlaceholder": "Inserire l'indirizzo e-mail o il nome del destinatario", "send": "Invia", "genericSuccess": "Hai inviato un invito a %{count} contatti.", "success": "Hai inviato un invito a %{email}.", "comingsoon": "Prossimamente! Potrai condividere documenti e foto con un solo clic con la tua famiglia, i tuoi amici e persino i tuoi colleghi. Non preoccuparti, ti faremo sapere quando sarà disponibile!", "onlyByLink": "Questo %{tipo} può essere condiviso solo tramite link, perché", "type": { "file": "file", "folder": "cartella" }, "hasSharedParent": "ha un genitore condiviso", "hasSharedChild": "contiene un elemento condiviso" }, "revoke": { "title": "Rimuovi dalla condivisione", "desc": "Questo contatto manterrà una copia, ma le modifiche non saranno più sincronizzate.", "success": "Hai rimosso questo file condiviso da %{email}." }, "revokeSelf": { "title": "Rimuovimi dalla condivisione", "desc": "Il contenuto viene mantenuto, ma non verrà più aggiornato sul tuo Twake.", "success": "Sei stato rimosso da questa condivisione." }, "sharingLink": { "title": "Link per la condivisione", "copy": "Copia", "copied": "Copiato" }, "whoHasAccess": { "title": "1 persona ha accesso |||| %{smart_count} persone hanno accesso" }, "protectedShare": { "title": "In arrivo!", "desc": "Condividi qualsiasi cosa via e-mail con la tua famiglia e i tuoi amici!" }, "close": "Chiudi", "gettingLink": "Ottenendo il tuo link", "specialCase": { "isInSharedFolder": "è in una cartella condivisa", "hasSharedFolder": "contiene una cartella condivisa" } } }, "table": { "head_name": "Nome", "head_update": "Ultimo aggiornamento", "head_size": "Dimensione", "row_update_format": "LLL d, yyyy", "row_update_format_full": "LLLL d, yyyy", "row_read_only": "Condividi (Solo Lettura)", "row_read_write": "Condividi (Lettura e Scrittura)", "row_size_symbols": { "B": "B", "KB": "KB", "MB": "MB", "GB": "GB", "TB": "TB", "PB": "PB", "EB": "EB", "ZB": "ZB", "YB": "YB" }, "load_more": "Carica altro", "mobile": { "head_name_asc": "A-Z", "head_name_desc": "Z-A" } }, "Storage": { "title": "Archiviazione", "availability": "%{smart_count} GB disponibili", "increase": "Aumenta il tuo spazio" }, "SelectionBar": { "share": "Condividi", "download": "Scarica", "trash": "Rimuovi", "destroy": "Elimina permanentemente", "rename": "Rinomina", "restore": "Ripristina", "close": "Chiudi", "openWith": "Apri con..." }, "DeleteConfirm": { "cancel": "Annulla", "delete": "Rimuovi" }, "emptytrashconfirmation": { "title": "Eliminare permanentemente?", "forbidden": "Non sarai più in grado di accedere a questi file.", "cancel": "Annulla", "delete": "Elimina tutto" }, "DestroyConfirm": { "title": "Eliminare permanentemente?", "cancel": "Annulla", "delete": "Elimina permanentemente" }, "quotaalert": { "confirm": "OK" }, "loading": { "message": "Caricamento" }, "empty": { "title": "Non hai nessun file in questa cartella." }, "error": { "button": { "reload": "Aggiorna adesso" }, "download_file": { "offline": "Devi essere connesso per scaricare questo file" } }, "alert": { "could_not_open_file": "Il file non può essere aperto", "empty_trash_success": "Il cestino è stato svuotato", "folder_generic": "Si è verificato un errore, per favore riprova.", "offline": "Questa caratteristica non è disponibile offline.", "item_copied": "1 elemento copiato", "items_copied": "%{count} elementi copiati", "item_cut": "1 elemento tagliato", "items_cut": "%{count} elementi tagliati", "item_moved": "1 elemento è stato spostato", "items_moved": "%{count} elementi sono stati spostati", "item_pasted": "1 elemento è stato spostato", "items_pasted": "%{count} elementi sono stati spostati", "copy_files_only": "Non è possibile copiare le cartelle", "copy_not_allowed": "L'operazione di copia non è consentita in questa vista.", "cut_not_allowed": "L'operazione di taglio non è consentita in questa vista.", "paste_error": "Si è verificato un errore durante l'incollaggio dei file", "paste_failed": "Incollaggio dei file fallito", "paste_sharing_error": "Impossibile incollare i file a causa di restrizioni di condivisione. Si prega di utilizzare l'azione Sposta invece.", "paste_same_folder_skipped": "Impossibile spostare gli elementi nella stessa cartella in cui si trovano già.", "paste_not_allowed": "Non puoi incollare in questa cartella", "cannot_move_shared_drive": "Non puoi spostare la cartella dell'unità condivisa", "cannot_copy_shared_drive": "Non puoi copiare la cartella dell’unità condivisa" }, "UploadQueue": { "close": "chiudi", "item": { "pending": "In attesa" } }, "Viewer": { "close": "Chiudi", "noviewer": { "download": "Scarica questo file", "openWith": "Apri con..." }, "actions": { "download": "Scarica" }, "loading": { "retry": "Riprova" } }, "ImportToDrive": { "action": "Salva" }, "FileOpenerExternal": { "fileNotFoundError": "Errore: file non trovato" }, "models": { "contact": { "defaultDisplayName": "Anonimo" } }, "Scan": { "save_doc": "Salva il documento", "save": "Salva" }, "History": { "current_version": "Versione corrente", "loading": "Caricamento..." }, "External": { "redirection": { "text": "Stai per essere reindirizzato..." } }, "RenameModal": { "title": "Rinomina" }, "Shortcut": { "url": "URL", "errored": "Si è verificato un errore" }, "OnlyOffice": { "readOnly": { "title": "Sola lettura" }, "createFileName": { "text": "Nuovo documento di testo", "spreadsheet": "Nuovo foglio di calcolo", "slide": "Nuova presentazione" } }, "searchbar": { "placeholder": "Cerca", "empty": "Nessun risultato trovato per la richiesta “%{query}”" }, "search": { "empty": { "subtitle": "Nessun risultato trovato per la richiesta “%{query}”" } }, "actions": { "details": "Dettagli", "personalizeFolder": { "label": "Personalizza cartella" }, "summariseByAI": "Riassumere" }, "FolderCustomizer": { "title": "Personalizza cartella", "description": "Scegli un colore specifico per la tua cartella", "cancel": "Annulla", "apply": "Applica", "error": "Si è verificato un errore, riprova.", "tabs": { "colors": "Colori", "icons": "Icone" }, "iconPicker": { "recents": "Recenti", "chooseCustomIcon": "Scegli un'icona personalizzata" } } } ================================================ FILE: src/locales/ja.json ================================================ { "Nav": { "item_drive": "ドライブ", "item_recent": "最近使用したファイル", "item_sharings": "共有", "item_shared": "自分が共有した", "item_activity": "アクティビティ", "item_trash": "ゴミ箱", "item_settings": "設定", "item_collect": "管理", "btn-client": "デスクトップ用 Twake ドライブを入手", "btn-client-web": "Twake を入手する", "btn-client-mobile": "お使いのモバイルで %{name} ドライブを入手しましょう!", "banner-txt-client": "デスクトップ用 %{name} ドライブを入手して、ファイルに安全に同期していつでもアクセスできるようにしましょう。", "banner-btn-client": "ダウンロード", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile", "link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8", "link-client-web": "https://cozy.io/try-it" }, "breadcrumb": { "title_drive": "ドライブ", "title_recent": "最近使用したファイル", "title_sharings": "共有", "title_shared": "自分が共有した", "title_activity": "アクティビティ", "title_trash": "ゴミ箱" }, "Toolbar": { "more": "さらに" }, "toolbar": { "item_more": "さらに", "menu_select": "アイテムを選択", "menu_share_folder": "フォルダーを共有", "menu_download_folder": "ダウンロードフォルダー", "menu_download_file": "このファイルをダウンロード", "empty_trash": "ゴミ箱を空にする", "share": "共有", "trash": "削除", "delete_shared_drive": "共有ドライブを削除", "leave": "共有されたフォルダーから離れて削除する", "select_all": "すべて選択", "clear_selection": "選択をクリア", "sharings_tab_all": "すべて", "sharings_tab_drives": "ドライブ" }, "Share": { "create-cozy": "自分の Twake を作成する" }, "Files": { "share": { "cta": "共有", "title": "共有", "details": { "title": "共有の詳細", "createdAt": "日付 %{date}", "ro": "読み取り可能", "rw": "変更可能", "desc": { "ro": "このコンテンツを表示、ダウンロード、あなたの Twake に追加することができます。 所有者による更新を受け取りますが、あなた自身でこのコンテンツを更新することはできません。", "rw": "このコンテンツを表示、更新、削除、あなたの Twake に追加することができます。 行った更新は他の Twake でも見られます。" } }, "sharedByMe": "自分が共有した", "sharedWithMe": "自分と共有", "sharedBy": "%{name} が共有しました", "shareByLink": { "subtitle": "公開リンクで", "desc": "提供されたリンクを持つ人は、誰でもあなたのファイルを見たりダウンロードしたりすることができます。", "creating": "リンクを作成中...", "copy": "リンクをコピー", "copied": "リンクをクリップボードにコピーしました", "failed": "クリップボードにコピーできません" }, "shareByEmail": { "subtitle": "メールで", "email": "宛先:", "emailPlaceholder": "メールアドレスまたは受信者の名前を入力してください", "send": "送信", "genericSuccess": "%{count} 連絡先に招待状を送信しました。", "success": "招待状を %{email} に送信しました。", "comingsoon": "まもなく登場します! 家族や友達、さらには同僚ともワンクリックで文書や写真を共有できます。 ご心配なく、準備ができたらお知らせします!", "onlyByLink": "この %{type} はリンクを共有することだけできます。", "type": { "file": "ファイル", "folder": "フォルダー" }, "hasSharedParent": "共有した親があります", "hasSharedChild": "共有した要素を含みます" }, "revoke": { "title": "共有から削除", "desc": "この連絡先はコピーを保存しますが、変更は同期されません。", "success": "この共有済ファイルを %{email} から削除しました。" }, "revokeSelf": { "title": "共有から自分を削除", "desc": "コンテンツを保存しますが、もうお使いの Twake 間で更新されません。", "success": "この共有から削除されました。" }, "sharingLink": { "title": "共有するリンク", "copy": "コピー", "copied": "コピーしました" }, "whoHasAccess": { "title": "1 人がアクセスできます |||| %{smart_count} 人がアクセスできます" }, "protectedShare": { "title": "まもなく登場します!", "desc": "あなたの家族や友達とメールで何でも共有してください!" }, "close": "閉じる", "gettingLink": "リンクの取得中...", "error": { "generic": "ファイル共有リンクの作成中にエラーが発生しました。もう一度やり直してください。", "revoke": "エラーが発生しました。 できるだけ早くこの問題を解決できるように、私たちにご連絡ください。" }, "specialCase": { "base": "この %{type} は共有できませんが、リンクできます", "isInSharedFolder": "共有フォルダーの中にあります", "hasSharedFolder": "共有フォルダーを含みます" } }, "viewer-fallback": "ファイルのダウンロードが始まったら、これを閉じることができます。", "dropzone": { "teaser": "ファイルをドラッグ&ドロップするとアップロードします:", "noFolderSupport": "現在お使いのブラウザーでフォルダーのドラッグ&ドロップはサポートされていません。 手動でファイルをアップロードしてください。" } }, "table": { "head_name": "名前", "head_update": "最終更新", "head_size": "サイズ", "head_thumbnail_size": "サムネイルのサイズを切り替え", "row_update_format": "yyyy/LL/dd", "row_update_format_full": "yyyy/LL/dd", "row_read_only": "共有 (読み取り専用)", "row_read_write": "共有 (読み書き)", "row_size_symbols": { "B": "B", "KB": "KB", "MB": "MB", "GB": "GB", "TB": "TB", "PB": "PB", "EB": "EB", "ZB": "ZB", "YB": "YB" }, "load_more": "さらに読み込む", "mobile": { "head_name_asc": "A-Z", "head_name_desc": "Z-A", "head_updated_at_asc": "古いものが先頭", "head_updated_at_desc": "最近使用したものが先頭", "head_size_asc": "小さいものが先頭", "head_size_desc": "大きなものが先頭" } }, "Storage": { "title": "ストレージ", "availability": "%{smart_count} GB 利用可能", "increase": "スペースを増やす" }, "SelectionBar": { "selected_count": "アイテム選択 |||| アイテム選択", "share": "共有", "download": "ダウンロード", "trash": "削除", "destroy": "完全に削除", "rename": "名前の変更", "restore": "復元", "close": "閉じる", "moveto": "移動…", "moveto_mobile": "移動", "phone-download": "オフラインで利用可能にする", "qualify": "分類", "history": "履歴" }, "DeleteConfirm": { "title": "この要素を削除しますか? |||| これらの要素を削除しますか?", "trash": "ゴミ箱に移動されます。 |||| ゴミ箱に移動されます。", "restore": "いつでも元に戻すことができます。 |||| いつでも元に戻すことができます。", "referenced": "選択範囲内の一部のファイルがフォトアルバムに関連しています。それらはゴミ箱に移動すると、削除されます。", "cancel": "キャンセル", "delete": "削除" }, "emptytrashconfirmation": { "title": "完全に削除しますか?", "forbidden": "これらのファイルにもうアクセスすることはできません。", "restore": "バックアップを作成していない場合、これらのファイルを復元することはできません。", "cancel": "キャンセル", "delete": "すべて削除" }, "DestroyConfirm": { "title": "完全に削除しますか?", "forbidden": "このファイルにもうアクセスすることはできません。 |||| これらのファイルにもうアクセスすることはできません。", "restore": "バックアップを作成していない場合、このファイルを復元することはできません。 |||| バックアップを作成していない場合、これらのファイルを復元することはできません。", "cancel": "キャンセル", "delete": "完全に削除" }, "quotaalert": { "title": "お使いのディスク容量が一杯です :(", "desc": "ファイルを再度アップロードする前に、ファイルを削除するか、ゴミ箱を空にするか、ディスク容量を増やしてください。", "confirm": "OK", "increase": "ディスク容量を増やす" }, "loading": { "message": "読み込み中" }, "empty": { "title": "このフォルダーにファイルはありません。", "text": "コンピューター上のファイルを選択するか、ここにドラッグアンドドロップしてください。", "mobile_text": "デバイス上のファイルを選択してください。", "trash_title": "削除されたファイルはありません。", "trash_text": "不要になったファイルをゴミ箱に移動し、アイテムを完全に削除するとストレージページを解放します。" }, "error": { "open_folder": "フォルダーを開くときに何か問題が発生しました。", "button": { "reload": "今すぐ更新" }, "download_file": { "offline": "このファイルをダウンロードするには接続している必要があります", "missing": "このファイルが見つかりません" } }, "Error": { "public_unshared_title": "申し訳ありません。このリンクはもう利用できません。", "public_unshared_text": "このリンクは有効期限が切れているか、所有者によって削除されています。 見つからないことを彼または彼女に知らせてください!", "generic": "エラーが発生しました。数分待ってからもう一度やり直してください。" }, "alert": { "could_not_open_file": "ファイルを開くことができません", "try_again": "エラーが発生しました。しばらくしてからもう一度やり直してください。", "restore_file_success": "選択を正常に復元しました。", "trash_file_success": "選択をゴミ箱に移動しました。", "destroy_file_success": "選択を完全に削除しました。", "empty_trash_progress": "ゴミ箱を空にしています。これは数分かかることがあります。", "empty_trash_success": "ゴミ箱を空にしました。", "folder_name": "要素 %{folderName} はすでに存在します。新しい名前を選んでください。", "folder_generic": "エラーが発生しました。もう一度やり直してください。", "folder_abort": "保存したい場合、新しいフォルダーに名前を追加する必要があります。 情報は保存されていません。", "offline": "この機能はオフラインでは利用できません。", "preparing": "ファイルを準備しています…", "item_copied": "1個のアイテムをコピーしました", "items_copied": "%{count}個のアイテムをコピーしました", "item_cut": "1個のアイテムを切り取りました", "items_cut": "%{count}個のアイテムを切り取りました", "item_moved": "1個のアイテムが移動されました", "items_moved": "%{count}個のアイテムが移動されました", "item_pasted": "1個のアイテムが移動されました", "items_pasted": "%{count}個のアイテムが移動されました", "copy_files_only": "フォルダーはコピーできません", "copy_not_allowed": "このビューではコピー操作は許可されていません。", "cut_not_allowed": "このビューでは切り取り操作は許可されていません。", "paste_error": "ファイルの貼り付け中にエラーが発生しました", "paste_failed": "ファイルの貼り付けに失敗しました", "paste_sharing_error": "共有制限のためファイルを貼り付けることができません。代わりに移動アクションを使用してください。", "paste_same_folder_skipped": "アイテムを既に存在する同じフォルダに移動することはできません。", "paste_not_allowed": "このフォルダに貼り付けることはできません", "cannot_move_shared_drive": "共有ドライブフォルダを移動することはできません", "cannot_copy_shared_drive": "共有ドライブのフォルダをコピーできません" }, "upload": { "label": "アップロード", "alert": { "network": "現在オフラインです。 接続したらもう一度やり直してください。" } }, "intents": { "alert": { "error": "ファイルを自動的にアップロードできません。アップロードメニューで手動でアップロードしてください。" }, "picker": { "select": "選択", "cancel": "キャンセル", "new_folder": "新しいフォルダー", "instructions": "対象を選択" } }, "UploadQueue": { "header": "%{smart_count} 枚の写真を Twake ドライブにアップロード中 |||| %{smart_count} 枚の写真を Twake ドライブにアップロード中", "header_mobile": "アップロード中 %{done} / %{total}", "header_done": "%{done} / %{total} を正常にアップロードしました", "close": "閉じる", "item": { "pending": "保留" } }, "Viewer": { "close": "閉じる", "noviewer": { "download": "このファイルをダウンロード", "openWith": "...で開く", "cta": { "saveTime": "時間を節約しましょう!", "installDesktop": "コンピュータに同期ツールをインストール", "accessFiles": "自分のコンピュータ上のファイルに直接アクセス" } }, "actions": { "download": "ダウンロード" }, "loading": { "error": "このファイルを読み込めませんでした。 現在、インターネットに接続していますか?", "retry": "再試行" }, "error": { "generic": "このファイルを開くときにエラーが発生しました。もう一度やり直してください。", "noNetwork": "現在オフラインです。" } }, "Move": { "to": "移動先:", "action": "移動", "cancel": "キャンセル", "modalTitle": "移動", "title": "%{smart_count} アイテム |||| %{smart_count} アイテム", "success": "%{subject} を %{target} に移動しました。 |||| %{smart_count} アイテムを %{target} に移動しました。", "error": "このアイテムを移動中に問題が発生しました。後でもう一度やり直してください。 |||| これらのアイテムを移動中に問題が発生しました。後でもう一度やり直してください。", "cancelled": "%{subject} を元の場所にもどしました。 |||| %{smart_count} アイテムを元の場所に戻しました。", "cancelledWithRestoreErrors": "%{subject} を元の場所に戻しましたが、ゴミ箱からファイルを復元する時にエラーが発生しました。 |||| %{smart_count} 件を元の場所に戻しましたが、ゴミ箱からファイルを復元する時に %{restoreErrorsCount} エラーが発生しました。", "cancelled_error": "アイテムを戻す際にエラーが発生しました。 |||| アイテムを戻す際にエラーが発生しました。" }, "ImportToDrive": { "title": "%{smart_count} アイテム |||| %{smart_count} アイテム", "to": "保存先:", "action": "保存", "cancel": "キャンセル", "success": "%{smart_count} 保存済ファイル |||| %{smart_count} 保存済ファイル", "error": "何か問題が発生しました。もう一度やり直してください" }, "FileOpenerExternal": { "fileNotFoundError": "エラー: ファイルが見つかりません" }, "TOS": { "updated": { "title": "GDPR が現実のものになります !", "detail": "一般データ保護規則に従って、[利用規約が更新されました](%{link}) 、2018 年 5 月 25 日にすべての Twake ユーザーに適用されます。", "cta": "利用規約に同意して続行する", "disconnect": "拒否して切断する", "error": "問題が発生しました。後でもう一度やり直してください" } }, "manifest": { "permissions": { "contacts": { "description": "連絡先とファイルを共有するために必要です" }, "groups": { "description": "グループとファイルを共有するために必要です" } } }, "models": { "contact": { "defaultDisplayName": "匿名" } }, "Scan": { "scan_a_doc": "ドキュメントをスキャン", "save_doc": "ドキュメントを保存", "filename": "ファイル名", "save": "保存", "cancel": "キャンセル", "qualify": "分類", "apply": "適用", "error": { "offline": "現在オフラインのため、この機能は使用できません。 後でもう一度やり直してください", "uploading": "すでにファイルをアップロードしています。 このアップロードが終了するまで待ってから、もう一度やり直してください。", "generic": "何か問題が発生しました。もう一度やり直してください。" }, "successful": { "qualified_ok": "ファイルの分類ができました!" } }, "History": { "description": "ファイルの最新の20バージョンが自動的に保存されます。 ダウンロードするバージョンを選択してください。", "current_version": "現在のバージョン", "loading": "読み込んでいます...", "noFileVersionEnabled": "Twake は、ファイルの最後の変更をすぐにアーカイブできるので、もう失う危険はありません。" }, "External": { "redirection": { "title": "リダイレクト", "text": "リダイレクトしています…", "error": "リダイレクト中にエラーが発生しました。 通常、これはファイルの内容が正しい形式ではないことを意味します。" } }, "RenameModal": { "title": "名前の変更", "description": "ファイルの拡張子を変更しようとしています。 続行してもよろしいですか?", "continue": "続行", "cancel": "キャンセル" }, "Shortcut": { "title_modal": "ショートカットの作成", "filename": "ファイル名", "url": "URL", "cancel": "キャンセル", "create": "作成", "created": "ショートカットを作成しました", "errored": "エラーが発生しました", "filename_error_ends": "名前は .url で終了する必要があります", "needs_info": "ショートカットは URL とファイル名である必要があります", "url_badformat": "URL が正しい形式ではありません" }, "searchbar": { "placeholder": "検索します", "empty": "問い合わせ “%{query}” の結果が見つかりません" }, "actions": { "details": "詳細", "personalizeFolder": { "label": "フォルダをカスタマイズ" }, "summariseByAI": "要約" }, "FolderCustomizer": { "title": "フォルダをカスタマイズ", "description": "フォルダの特定の色を選択します", "cancel": "キャンセル", "apply": "適用", "error": "エラーが発生しました。もう一度お試しください。", "tabs": { "colors": "色", "icons": "アイコン" }, "iconPicker": { "recents": "最近使用", "chooseCustomIcon": "カスタムアイコンを選択" } } } ================================================ FILE: src/locales/ko.json ================================================ { "Nav": { "item_drive": "드라이브", "item_recent": "최근", "item_activity": "활동", "item_settings": "설정", "banner-btn-client": "다운로드", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile", "link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8", "link-client-web": "https://cozy.io/try-it" }, "breadcrumb": { "title_drive": "드라이브", "title_recent": "최근", "title_activity": "활동" }, "Toolbar": { "more": "더보기" }, "toolbar": { "item_more": "더보기", "menu_new_folder": "폴더", "menu_download": "다운로드", "sharings_tab_all": "모두", "sharings_tab_drives": "드라이브" }, "Files": { "share": { "shareByLink": { "creating": "링크 생성", "copy": "링크 복사", "copied": "링크를 클립보드에 복사했습니다." }, "sharingLink": { "copy": "복사" }, "error": { "generic": "파일 공유 링크 생성 중에 오류가 발생했습니다. 나중에 다시 시도하세요.", "revoke": "이런, 오류가 발생했습니다. 저희에게 알려 주시면 최대한 빨리 문제를 해결하겠습니다." } } }, "table": { "head_name": "이름", "head_size": "크기" }, "error": { "open_folder": "폴더를 여는 동안 문제가 발생했습니다.", "button": { "reload": "지금 새로고침" } }, "Error": { "public_unshared_title": "죄송합니다. 이 링크는 더이상 이용할 수 없습니다.", "generic": "오류가 발생했습니다. 나중에 다시 시도하세요." }, "alert": { "could_not_open_file": "이 파일을 열 수 없습니다.", "item_copied": "1개 항목이 복사되었습니다", "items_copied": "%{count}개 항목이 복사되었습니다", "item_cut": "1개 항목이 잘라내기되었습니다", "items_cut": "%{count}개 항목이 잘라내기되었습니다", "item_moved": "1개 항목이 이동되었습니다", "items_moved": "%{count}개 항목이 이동되었습니다", "items_pasted": "%{count}개 항목이 이동되었습니다", "copy_files_only": "폴더는 복사할 수 없습니다", "copy_not_allowed": "이 보기에서는 복사 작업이 허용되지 않습니다.", "cut_not_allowed": "이 보기에서는 잘라내기 작업이 허용되지 않습니다.", "paste_error": "파일 붙여넣기 중 오류가 발생했습니다", "paste_failed": "파일 붙여넣기에 실패했습니다", "paste_sharing_error": "공유 제한으로 인해 파일을 붙여넣을 수 없습니다. 대신 이동 작업을 사용하십시오.", "paste_same_folder_skipped": "항목을 이미 있는 동일한 폴더로 이동할 수 없습니다.", "paste_not_allowed": "이 폴더에 붙여넣을 수 없습니다", "cannot_move_shared_drive": "공유 드라이브 폴더를 이동할 수 없습니다", "cannot_copy_shared_drive": "공유 드라이브 폴더를 복사할 수 없습니다" }, "History": { "loading": "불러오는 중..." }, "OnlyOffice": { "createFileName": { "text": "새 문서 만들기", "spreadsheet": "새 스프레드시트 만들기", "slide": "새 프레젠테이션 만들기" } }, "actions": { "details": "세부 정보", "personalizeFolder": { "label": "폴더 개인화" }, "summariseByAI": "요약" }, "FolderCustomizer": { "title": "폴더 개인화", "description": "폴더의 특정 색상을 선택하세요", "cancel": "취소", "apply": "적용", "error": "오류가 발생했습니다. 다시 시도해 주세요.", "tabs": { "colors": "색상", "icons": "아이콘" }, "iconPicker": { "recents": "최근 항목", "chooseCustomIcon": "사용자 지정 아이콘 선택" } } } ================================================ FILE: src/locales/nl.json ================================================ { "breadcrumb": { "title_drive": "Schijf", "title_recent": "Recent", "title_shared": "Gedeeld door mij", "title_activity": "Activiteit", "title_trash": "Prullenbak" }, "toolbar": { "menu_select": "Selecteer items", "empty_trash": "Leeg de prullenbak", "select_all": "Alles selecteren", "delete_shared_drive": "Gedeelde schijf verwijderen", "sharings_tab_all": "Alles", "sharings_tab_drives": "Stations" }, "table": { "head_name": "Naam", "head_update": "Laatst bijgewerkt", "head_size": "Grootte", "row_read_only": "Delen (alleen lezen)", "row_read_write": "Delen (Lezen en schrijven)", "row_size_symbols": { "B": "B", "KB": "KB", "MB": "MB", "GB": "GB", "TB": "TB", "PB": "PB", "EB": "EB", "ZB": "ZB", "YB": "YB" } }, "DeleteConfirm": { "title": "Verwijder dit element? |||| Verwijder deze elementen?", "trash": "Het zal worden verplaatst naar de Prullenbak. ||| Ze zullen worden verplaatst naar de Prullenbak.", "restore": "Je kunt het nog steeds terughalen als je wilt. |||| Je kunt ze nog steeds terughalen als je wilt.", "cancel": "Annuleren", "delete": "Verwijderen" }, "emptytrashconfirmation": { "title": "Permanent verwijderen?", "forbidden": "Je kunt deze bestanden niet meer benaderen.", "restore": "Als je geen back-up gemaakt hebt, kun je deze bestanden niet meer terugzetten.", "cancel": "Annuleren", "delete": "Verwijder alles" }, "DestroyConfirm": { "title": "Verwijder permanent?", "forbidden": "Je kunt dit bestand net meer benaderen. |||| Je kunt deze bestanden niet meer benaderen.", "restore": "Als je geen back-up gemaakt hebt, kun je dit bestand niet meer terugzetten. |||| Als je geen back-up gemaakt hebt, kun je deze bestanden niet meer terugzetten.", "cancel": "Annuleren", "delete": "Verwijder permanent" }, "quotaalert": { "title": "Jouw schijfruimte is vol :(", "confirm": "OK" }, "loading": { "message": "Laden" }, "empty": { "title": "Er staan geen bestanden in deze map." }, "error": { "open_folder": "Er is is fout gegaan bij het openen van de map.", "button": { "reload": "Nu verversen" }, "download_file": { "offline": "Je moet verbonden zijn om dit bestand te downloaden", "missing": "Dit bestand bestaat niet" } }, "alert": { "try_again": "Er is een fout opgetreden, probeer het later nog eens.", "restore_file_success": "De selectie is succesvol herstelt.", "trash_file_success": "De selectie is verplaatst naar de Prullenbak.", "destroy_file_success": "De selectie is permanent verwijderd.", "folder_name": "Het element %{foldername} bestaat al, kies een andere naam.", "folder_generic": "Er is een fout opgetreden, probeer het opnieuw.", "folder_abort": "Je moet de nieuwe map een naam geven als je het wilt opslaan. De gegevens zijn niet opgeslagen.", "offline": "Deze mogelijkheid is niet beschikbaar offline.", "item_copied": "1 item gekopieerd", "items_copied": "%{count} items gekopieerd", "item_cut": "1 item geknipt", "items_cut": "%{count} items geknipt", "item_moved": "1 item is verplaatst", "items_moved": "%{count} items zijn verplaatst", "item_pasted": "1 item is verplaatst", "items_pasted": "%{count} items zijn verplaatst", "copy_files_only": "Mappen kunnen niet worden gekopieerd", "copy_not_allowed": "Kopieerbewerking is niet toegestaan in deze weergave.", "cut_not_allowed": "Knipbewerking is niet toegestaan in deze weergave.", "paste_error": "Er is een fout opgetreden bij het plakken van bestanden", "paste_failed": "Plakken van bestanden mislukt", "paste_sharing_error": "Kan bestanden niet plakken vanwege deelbeperkingen. Gebruik in plaats daarvan de actie Verplaatsen.", "paste_same_folder_skipped": "Kan items niet verplaatsen naar dezelfde map waar ze al in staan.", "paste_not_allowed": "Je kunt niet plakken in deze map", "cannot_move_shared_drive": "Je kunt de gedeelde schijfmap niet verplaatsen", "cannot_copy_shared_drive": "Je kunt de gedeelde schijfmap niet kopiëren" }, "actions": { "details": "Details", "personalizeFolder": { "label": "Map personaliseren" }, "summariseByAI": "Samenvatten" }, "FolderCustomizer": { "title": "Map personaliseren", "description": "Kies een specifieke kleur voor uw map", "cancel": "Annuleren", "apply": "Toepassen", "error": "Er is een fout opgetreden, probeer het opnieuw.", "tabs": { "colors": "Kleuren", "icons": "Pictogrammen" }, "iconPicker": { "recents": "Recente", "chooseCustomIcon": "Kies een aangepast pictogram" } } } ================================================ FILE: src/locales/nl_NL.json ================================================ { "Nav": { "item_drive": "Schijf", "item_recent": "Recent", "item_sharings": "Gedeelde items", "item_shared": "Door mij gedeeld", "item_activity": "Activiteit", "item_trash": "Prullenbak", "item_settings": "Instellingen", "item_collect": "Administratie", "btn-client": "Download Twake Schijf voor je computer", "btn-client-web": "Download Twake", "btn-client-mobile": "Download %{name} Schijf op je telefoon!", "banner-txt-client": "Download %{name} Schijf voor je computer en synchroniseer veilig je bestanden om ze overal beschikbaar te maken.", "banner-btn-client": "Downloaden", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile", "link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8", "link-client-web": "https://cozy.io/try-it" }, "breadcrumb": { "title_drive": "Schijf", "title_recent": "Recent", "title_sharings": "Gedeelde items", "title_shared": "Door mij gedeeld", "title_activity": "Activiteit", "title_trash": "Prullenbak", "label": "Locatie tonen" }, "Toolbar": { "more": "Meer" }, "toolbar": { "menu_upload": "Bestanden uploaden", "item_more": "Meer", "menu_new_folder": "Map", "menu_select": "Items selecteren", "menu_share_folder": "Map delen", "menu_download": "Downloaden", "menu_sync_cozy": "Synchroniseren naar mijn Twake", "add_to_mine": "Toevoegen aan mijn Twake", "menu_download_folder": "Map downloaden", "menu_download_file": "Download dit bestand", "menu_create_note": "Notitie", "menu_create_shortcut": "Snelkoppeling", "empty_trash": "Prullenbak legen", "share": "Delen", "trash": "Verwijderen", "delete_shared_drive": "Gedeelde schijf verwijderen", "leave": "Gedeelde map verlaten en verwijderen", "menu_add": "Toevoegen", "menu_create": "Creëren", "menu_add_item": "Item toevoegen", "menu_onlyOffice": { "text": "Tekstdocumen", "spreadsheet": "Werkblad", "slide": "Presentatie" }, "select_all": "Alles selecteren", "sharings_tab_all": "Alles", "sharings_tab_drives": "Stations" }, "Share": { "create-cozy": "Maak mijn Twake" }, "Files": { "share": { "cta": "Delen", "title": "Delen", "details": { "title": "Deelinformatie", "createdAt": "Op %{date}", "ro": "Mag bekijken", "rw": "Mag wijzigen", "desc": { "ro": "Je kunt deze inhoud bekijken, downloaden op en toevoegen aan je Twake. Je ontvangt bijgewerkte versies van de eigenaar, maar je kunt zelfs niks aanpassen.", "rw": "Je kunt deze inhoud bekijken, downloaden op en toevoegen aan je Twake. Bijgewerkte versies zijn beschikbaar op andere Cozies." } }, "sharedByMe": "Door mij gedeeld", "sharedWithMe": "Met mij gedeeld", "sharedBy": "Gedeeld door %{name}", "shareByLink": { "subtitle": "Via openbare link", "desc": "Iedereen die de link heeft kan je bestanden bekijken en downloaden.", "creating": "Bezig met maken van je link…", "copy": "Link kopiëren", "copied": "Link is gekopieerd naar het klembord", "failed": "Kan niet kopiëren naar klembord" }, "shareByEmail": { "subtitle": "Via e-mail", "email": "Aan:", "emailPlaceholder": "Voer het e-mailadres of de naam in van de ontvanger", "send": "Versturen", "genericSuccess": "Je hebt een uitnodiging verstuurd aan %{count} contactpersonen.", "success": "Je hebt een uitnodiging verstuurd aan %{email}.", "comingsoon": "Binnenkort kun je documenten en foto's met één klik delen met je familie, vrienden en zelfs met je collega's! Geen zorgen, we laten je weten wanneer dit beschikbaar is.", "onlyByLink": "Dit %{type} kan niet worden gedeeld via een link omdat het", "type": { "file": "bestand", "folder": "map" }, "hasSharedParent": "een gedeelde bovenliggende map bevat", "hasSharedChild": "een gedeeld itembevat" }, "revoke": { "title": "Verwijderen uit gedeelde items", "desc": "De contactpersoon behoudt de kopie, maar aanpassingen worden niet langer gesynchroniseerd.", "success": "Je hebt dit gedeelde bestand verwijderd uit %{email}." }, "revokeSelf": { "title": "Verwijder mij uit gedeelde items", "desc": "De inhoud blijft bewaard, maar wordt niet langer bijgewerkt tussen je Twake-apparaten.", "success": "Je bent verwijderd uit deze gedeelde items." }, "sharingLink": { "title": "Link om te delen", "copy": "Kopiëren", "copied": "Gekopieerd" }, "whoHasAccess": { "title": "1 persoon heeft toegang |||| %{smart_count} personen hebben toegang" }, "protectedShare": { "title": "Binnenkort!", "desc": "Deel van alles via e-mail met je familie en vrienden!" }, "close": "Sluiten", "gettingLink": "Bezig met ophalen van je link…", "error": { "generic": "Er is een fout opgetreden tijdens het creëren van de link. Probeer het opnieuw.", "revoke": "Oeps, er is een fout opgetreden. Neem contact met ons op zodat we het probleem z.s.m. kunnen verhelpen." }, "specialCase": { "base": "Dit %{type} kan niet worden gedeeld met een link omdat het", "isInSharedFolder": "zich bevindt in een gedeelde map", "hasSharedFolder": "een gedeelde map bevat" } }, "viewer-fallback": "Je kunt dit sluiten zodra het downloaden is gestart.", "dropzone": { "teaser": "Versleep bestanden om ze te uploaden naar:", "noFolderSupport": "Je browser heeft geen ondersteuning voor slepen-en-neerzetten. Upload de bestanden handmatig." } }, "table": { "head_name": "Naam", "head_update": "Laatst bijgewerkt", "head_size": "Grootte", "head_status": "Delen", "head_thumbnail_size": "Miniatuurgrootte aanpassen", "row_update_format": "LLL d, yyyy", "row_update_format_full": "LLLL d, yyyy", "row_read_only": "Delen (alleen-lezen)", "row_read_write": "Delen (lezen en bewerken)", "row_size_symbols": { "B": "B", "KB": "KB", "MB": "MB", "GB": "GB", "TB": "TB", "PB": "PB", "EB": "EB", "ZB": "ZB", "YB": "YB" }, "load_more": "Meer laden", "mobile": { "head_name_asc": "A-Z", "head_name_desc": "Z-A", "head_updated_at_asc": "Oudste eerst", "head_updated_at_desc": "Recentste eerst", "head_size_asc": "Kleinste eerst", "head_size_desc": "Grootste eerst" }, "tooltip": { "carbonCopy": { "title": "Carbon Copy", "caption": "Toont aan of het document ‘authentiek en origineel’ is verklaard door Twake Workplace, de hoster van je Twake. Het kan namelijk zo zijn dat de claim is gedaan door een externe partij zonder enige aanpassing." }, "electronicSafe": { "title": "Elektronische kluis", "caption": "Toont aan of het oorspronkelijke document veilig is opgeslagen in je persoonlijke digitale kluis, voorzien van alle bijbehorende certificeringen en 50 jaar garantie." } } }, "Storage": { "title": "Opslag", "availability": "%{smart_count} GB beschikbaar", "increase": "Vergroot je ruimte" }, "SelectionBar": { "selected_count": "item geselecteerd |||| items geselecteerd", "share": "Delen", "download": "Downloaden", "trash": "Verwijderen", "destroy": "Permanent verwijderen", "rename": "Naam wijzigen", "restore": "Herstellen", "close": "Sluiten", "openWith": "Openen met…", "applePreview": "Apple-voorbeeld", "forward": "Vooruit", "forwardTo": "Doorsturen naar…", "moveto": "Verplaatsen naar…", "moveto_mobile": "Verplaatsen", "phone-download": "Offline beschikbaar maken", "qualify": "Categoriseren", "history": "Geschiedenis", "more": "Meer" }, "DeleteConfirm": { "title": "Dit item verwijderen? |||| Deze items verwijderen?", "trash": "Het wordt verplaatst naar de prullenbak. |||| Ze worden verplaatst naar de prullenbak.", "restore": "Je kunt het ten allen tijde herstellen. |||| Je kunt ze ten allen tijde herstellen.", "link": "De gedeelde link komt te vervallen", "referenced": "Sommige geselecteerde bestanden horen bij een foto-album. Als je doorgaat, dan worden ze verwijderd.", "cancel": "Annuleren", "delete": "Verwijderen" }, "emptytrashconfirmation": { "title": "Permanent verwijderen?", "forbidden": "Je hebt dan geen toegang meer tot deze bestanden.", "restore": "Je kunt deze bestanden niet herstellen als je geen back-up hebt gemaakt.", "cancel": "Annuleren", "delete": "Alles verwijderen" }, "DestroyConfirm": { "title": "Permanent verwijderen?", "forbidden": "Je hebt dan geen toegang meer tot dit bestand. |||| Je hebt dat geen toegang meer tot deze bestanden.", "restore": "Je kunt dit bestand niet herstellen als je geen back-up hebt gemaakt. |||| Je kunt deze bestanden niet herstellen als je geen back-up hebt gemaakt.", "cancel": "Annuleren", "delete": "Permanent verwijderen" }, "quotaalert": { "title": "Je hebt geen vrije schijfruimte meer :(", "desc": "Verwijder bestanden en leeg de prullenbak voordat je wéér probeert om bestanden te uploaden.", "confirm": "Oké", "increase": "Vergroot je schijfruimte" }, "loading": { "message": "Bezig met laden…", "onlyOfficeCreateInProgress": "Bezig met aanmaken van huidig bestand…" }, "empty": { "title": "Deze map bevat geen bestanden.", "text": "Selecteer bestanden op uw computer of sleep ze hierheen.", "mobile_text": "Selecteer bestanden op je apparaat.", "trash_title": "Je hebt geen verwijderde bestanden.", "trash_text": "Verplaats bestanden die je niet langer nodig hebt naar de prullenbak en verwijder items permanent om ruimte vrij te maken." }, "error": { "open_folder": "Er is iets misgegaan tijdens het openen van de map.", "open_file": "Er is iets misgegaan tijdens het openen van het bestand.", "button": { "reload": "Nu herladen" }, "download_file": { "offline": "Je moet verbonden zijn om dit bestand te kunnen downloaden", "missing": "Dit bestand ontbreekt" } }, "Error": { "public_unshared_title": "Sorry, deze link niet langer beschikbaar.", "public_unshared_text": "Deze link is verlopen of verwijderd door de eigenaar. Stel hem of haar hiervan op de hoogte!", "generic": "Er is iets misgegaan. Wacht een paar minuten en probeer het opnieuw." }, "alert": { "could_not_open_file": "Het bestand kan niet worden geopend", "try_again": "Er is een fout opgetreden; probeer het later opnieuw.", "restore_file_success": "De selectie is hersteld.", "trash_file_success": "De selectie is verplaatst naar de prullenbak.", "destroy_file_success": "De selectie is permanent verwijderd.", "empty_trash_progress": "De prullenbak wordt geleegd; dit kan even duren.", "empty_trash_success": "De prullenbak is geleegd.", "folder_name": "‘%{folderName}’ bestaat al. Kies een andere naam.", "file_name": "‘%{fileName}’ bestaat al. Kies een andere naam.", "file_name_missing": "De bestandsnaam ontbreekt - geef een naam op.", "file_name_illegal_name": "‘%{fileName}’ is ongeldig. Kies een andere naam.", "file_name_illegal_characters": "%{fileName} bevat ongeldige tekens: %{characters}", "folder_generic": "Er is een fout opgetreden; probeer het opnieuw.", "folder_abort": "Als je je nieuwe map wilt opslaan, dan moet je deze een naam geven. Je informatie is niet opgeslagen.", "offline": "Deze functie is niet offline beschikbaar.", "preparing": "Bezig met voorbereiden van je bestanden…", "item_copied": "1 item gekopieerd", "items_copied": "%{count} items gekopieerd", "item_cut": "1 item geknipt", "items_cut": "%{count} items geknipt", "item_moved": "1 item is verplaatst", "items_moved": "%{count} items zijn verplaatst", "item_pasted": "1 item is verplaatst", "items_pasted": "%{count} items zijn verplaatst", "copy_files_only": "Mappen kunnen niet worden gekopieerd", "copy_not_allowed": "Kopieerbewerking is niet toegestaan in deze weergave.", "cut_not_allowed": "Knipbewerking is niet toegestaan in deze weergave.", "paste_error": "Er is een fout opgetreden bij het plakken van bestanden", "paste_failed": "Plakken van bestanden mislukt", "paste_sharing_error": "Kan bestanden niet plakken vanwege deelbeperkingen. Gebruik in plaats daarvan de actie Verplaatsen.", "paste_same_folder_skipped": "Kan items niet verplaatsen naar dezelfde map waar ze al in staan.", "paste_not_allowed": "U kunt niet plakken in deze map", "cannot_move_shared_drive": "U kunt de gedeelde schijfmap niet verplaatsen", "cannot_copy_shared_drive": "Je kunt de gedeelde schijfmap niet kopiëren" }, "upload": { "label": "Uploaden", "alert": { "network": "Je bent momenteel offline. Maak verbinding en probeer het opnieuw." } }, "intents": { "alert": { "error": "Het bestand kan niet automatisch worden geüpload. Doe het handmatig via het uploadmenu." }, "picker": { "select": "Selecteren", "cancel": "Annuleren", "new_folder": "Nieuwe map", "instructions": "Kies een doel" } }, "UploadQueue": { "header": "Bezig met uploaden van %{smart_count} foto naar Twake Drive |||| Bezig met uploaden van %{smart_count} foto's naar Twake Drive", "header_mobile": "Bezig met uploaden - %{done} van %{total}…", "header_done": "%{done} van de %{total} geüpload", "close": "sluiten", "item": { "pending": "In wachtrij" } }, "Viewer": { "close": "Sluiten", "noviewer": { "download": "Download dit bestand", "openWith": "Openen met…", "openInOnlyOffice": "Openen met OnlyOffice", "cta": { "saveTime": "Bespaar wat tijd!", "installDesktop": "Installeer de synchronisatie-app op je computer", "accessFiles": "Direct toegang tot je bestanden vanaf je computer" } }, "actions": { "download": "Downloaden", "forward": "Vooruit" }, "loading": { "error": "Dit bestand kan niet worden geladen. Ben je verbonden met het internet?", "retry": "Opnieuw proberen" }, "error": { "noapp": "Er is geen app die dit bestand in behandeling kan nemen.", "generic": "Er is een fout opgetreden tijdens het openen van dit bestand. Probeer het opnieuw.", "noNetwork": "Je bent momenteel offline." }, "panel": { "title": "Nuttige informatie" } }, "Move": { "to": "Verplaatsen naar:", "action": "Verplaatsen", "cancel": "Annuleren", "modalTitle": "Verplaatsen", "title": "%{smart_count} item |||| %{smart_count} items", "success": "%{subject} is verplaatst naar %{target}. ||| %{smart_count} items zijn verplaatst naar %{target}.", "error": "Er is iets misgegaan tijdens het verplaatsen van dit item; probeer het later opnieuw. |||| Er is iets misgegaan tijdens het verplaatsen van deze items; probeer het later opnieuw.", "cancelled": "%{subject} is teruggeplaatst op de oorspronkelijke locatie. ||| %{smart_count} items zijn teruggeplaatst op hun oorspronkelijke locatie.", "cancelledWithRestoreErrors": "%{subject} is teruggeplaatst op de oorspronkelijke locatie, maar er is een fout opgetreden. ||| %{smart_count} items zijn teruggeplaatst op hun oorspronkelijke locatie, maar er zijn %{restoreErrorsCount} fouten opgetreden.", "cancelled_error": "Sorry, er is iets misgegaan tijdens het terughalen van dit item. |||| Sorry, er is iets misgegaan tijdens terughalen van deze items.", "multipleEntries": "%{smart_count} item |||| %{smart_count} items", "outsideSharedFolder": { "title": "Verplaatsen buiten de map ‘%{sharedFolder}’", "cancel": "Annuleren", "confirm": "Ik begrijp het" } }, "ImportToDrive": { "title": "%{smart_count} item |||| %{smart_count} items", "to": "Opslaan in:", "action": "Opslaan", "cancel": "Annuleren", "success": "%{smart_count} opgeslagen bestand |||| %{smart_count} opgeslagen bestanden", "error": "Er is iets misgegaan; probeer het opnieuw." }, "FileOpenerExternal": { "fileNotFoundError": "Fout: bestand niet gevonden" }, "TOS": { "updated": { "title": "De GDPR is werkelijkheid geworden!", "detail": "In verband met de General Data Protection Regulation, ook wel AVG, [zijn onze algemene voorwaarden bijgewerkt](%{link}) en van toepassing op alle Twake-gebruikers vanaf 25 mei 2018.", "cta": "Voorwaarden accepteren en doorgaan", "disconnect": "Weigeren en verbinding verbreken", "error": "Er is iets misgegaan; probeer het later opnieuw." } }, "manifest": { "permissions": { "contacts": { "description": "Vereist om bestanden te kunnen delen met je contactpersonen" }, "groups": { "description": "Vereist om bestanden te kunnen delen in je groepen" } } }, "models": { "contact": { "defaultDisplayName": "Anoniem" } }, "Scan": { "scan_a_doc": "Document scannen", "save_doc": "Document opslaan", "filename": "Bestandsnaam", "save": "Opslaan", "cancel": "Annuleren", "qualify": "Categoriseren", "apply": "Toepassen", "error": { "offline": "Je kunt deze functie momenteel niet gebruiken omdat je offline bent. Probeer het later opnieuw.", "uploading": "Je bent al een bestand aan het uploaden. Wacht tot dat is afgerond en probeer het dan opnieuw.", "generic": "Er is iets misgegaan; probeer het opnieuw." }, "successful": { "qualified_ok": "Je hebt je eerste bestand gecategoriseerd!" } }, "History": { "description": "De laatste 20 versies van je bestanden worden automatisch bewaard. Selecteer een versie om deze te downloaden.", "current_version": "Huidige versie", "loading": "Bezig met laden…", "noFileVersionEnabled": "Je Twake kan binnenkort de recenste bestandsaanpassingen archiveren zodat je nooit meer een bestand kwijtraakt" }, "External": { "redirection": { "title": "Doorverwijzing", "text": "Je wordt doorverwezen…", "error": "Doorverwijzing mislukt. Normaliter betekent dit dat de bestandsinhoud niet goed is opgemaakt." } }, "RenameModal": { "title": "Naam wijzigen", "description": "Je staat op het punt om de bestandsextensie te wijzigen. Wil je doorgaan?", "continue": "Doorgaan", "cancel": "Annuleren" }, "Shortcut": { "title_modal": "Snelkoppeling maken", "filename": "Bestandsnaam", "url": "URL", "cancel": "Annuleren", "create": "Maken", "created": "De snelkoppeling is gemaakt", "errored": "Er is een fout opgetreden", "filename_error_ends": "De naam moet eindigen op .url", "needs_info": "De snelkoppeling moet op zijn minst voorzien zijn van een url en bestandsnaam", "url_badformat": "De url is onjuist opgemaakt" }, "OnlyOffice": { "Error": { "title": "Er is iets misgegaan", "text": "Herlaad de pagina" }, "readOnly": { "title": "Alleen-lezen", "tooltip": "Je bent alleen bevoegd om dit document te lezen - neem contact op met de eigenaar als je het ook wilt kunnen bewerken." }, "createFileName": { "text": "Nieuw tekstdocument", "spreadsheet": "Nieuw werkblad", "slide": "Nieuwe presentatie" }, "toolbar": { "goToHome": "Ga naar overzicht" }, "actions": { "edit": "Bewerken", "validate": "Verifiëren" } }, "Migration": { "title": "Twake Schijf bijwerken", "content": "Twake Schijf moet worden bijgewerkt om de prestaties ervan te verbeteren - dit kan enkele minuten duren. Gedurende deze periode kun je de app niet gebruiken. Wil je Twake Schijf nu bijwerken? Als je dat niet wilt, dan vragen we het volgende keer opnieuw.", "confirm": "Ja, doe maar!", "cancel": "Nee, niet nu" }, "searchbar": { "placeholder": "Zoeken naar iets", "empty": "Geen resultaten gevonden voor de zoekopdracht \"%{query}\"" }, "button": { "back": "Terug", "add": "Toevoegen", "create": "Creëren" }, "search": { "action": "Zoeken", "empty": { "title": "Geen zoekresultaten", "subtitle": "Geen resultaten gevonden voor de zoekopdracht \"%{query}\"" } }, "PushBanner": { "quota": { "text": "Je hebt nog weinig opslagruimte. Als je het limiet bereikt, dan kun je geen bestanden meer toevoegen. Je kunt bestanden verwijderen, de prullenbak legen of een ander abonnement kiezen om ruimte vrij te maken.", "actions": { "first": "Ik begrijp het", "second": "Abonnementen bekijken" } } }, "EntriesType": { "file": "bestand |||| bestanden", "directory": "map |||| mappen", "element": "item |||| items" }, "actions": { "details": "Details", "personalizeFolder": { "label": "Map personaliseren" }, "summariseByAI": "Samenvatten" }, "FolderCustomizer": { "title": "Map personaliseren", "description": "Kies een specifieke kleur voor uw map", "cancel": "Annuleren", "apply": "Toepassen", "error": "Er is een fout opgetreden, probeer het opnieuw.", "tabs": { "colors": "Kleuren", "icons": "Pictogrammen" }, "iconPicker": { "recents": "Recente", "chooseCustomIcon": "Kies een aangepast pictogram" } } } ================================================ FILE: src/locales/pl.json ================================================ { "Nav": { "item_drive": "Dysk", "item_recent": "Bieżące", "item_shared": "Udostępnione przeze mnie", "item_activity": "Aktywności", "item_trash": "Kosze", "item_settings": "Ustawienia", "btn-client-web": "Pobierz Twake", "btn-client-mobile": "Pobierz Twake Drive na urządzenie mobilne", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile", "link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8" }, "breadcrumb": { "title_drive": "Dysk", "title_recent": "Bieżące", "title_shared": "Udostępnione przeze mnie", "title_activity": "Aktywności", "title_trash": "Kosze" }, "Toolbar": { "more": "Więcej" }, "toolbar": { "item_more": "Więcej", "menu_select": "Wybierz elementy", "menu_download_folder": "Pobierz folder", "empty_trash": "Opróżnij kosz", "share": "Udostępnij", "leave": "Opuść udostępniony folder i usuń go", "select_all": "Zaznacz wszystko", "sharings_tab_all": "Wszystko", "sharings_tab_drives": "Dyski" }, "Files": { "share": { "cta": "Udostępnij", "title": "Udostępnij", "details": { "title": "Szczegóły udostępniania", "createdAt": "Utworzone %{date}" }, "sharedByMe": "Udostępnione przeze mnie", "sharedWithMe": "Udostępnione dla mnie", "shareByLink": { "desc": "Każdy posiadający ten lim może zobaczyć i pobrać Twoje pliki." }, "shareByEmail": { "email": "Do:", "send": "Wyślij", "genericSuccess": "Wysłałeś zaproszenie do %{count} kontaktów.", "success": "Wysłałeś zaproszenie do %{email}." } } }, "searchbar": { "placeholder": "Szukaj czegokolwiek", "empty": "Brak wyników dla wyszukania \"%{query}\"" }, "search": { "empty": { "subtitle": "Brak wyników dla wyszukania \"%{query}\"" } }, "alert": { "item_copied": "1 element skopiowany", "items_copied": "%{count} elementów skopiowanych", "item_cut": "1 element wycięty", "items_cut": "%{count} elementów wyciętych", "item_moved": "1 element został przeniesiony", "items_moved": "%{count} elementów zostało przeniesionych", "item_pasted": "1 element został przeniesiony", "items_pasted": "%{count} elementów zostało przeniesionych", "copy_files_only": "Nie można kopiować folderów", "copy_not_allowed": "Operacja kopiowania nie jest dozwolona w tym widoku.", "cut_not_allowed": "Operacja wycinania nie jest dozwolona w tym widoku.", "paste_error": "Wystąpił błąd podczas wklejania plików", "paste_failed": "Wklejanie plików nie powiodło się", "paste_sharing_error": "Nie można wkleić plików z powodu ograniczeń udostępniania. Zamiast tego użyj akcji Przenieś.", "paste_same_folder_skipped": "Nie można przenieść elementów do tego samego folderu, w którym już się znajdują.", "paste_not_allowed": "Nie możesz wkleić do tego folderu", "cannot_move_shared_drive": "Nie możesz przenieść folderu dysku udostępnionego", "cannot_copy_shared_drive": "Nie możesz skopiować folderu dysku współdzielonego" }, "actions": { "details": "Szczegóły", "personalizeFolder": { "label": "Personalizuj folder" }, "summariseByAI": "Podsumuj" }, "FolderCustomizer": { "title": "Personalizuj folder", "description": "Wybierz konkretny kolor dla swojego folderu", "cancel": "Anuluj", "apply": "Zastosuj", "error": "Wystąpił błąd, spróbuj ponownie.", "tabs": { "colors": "Kolory", "icons": "Ikony" }, "iconPicker": { "recents": "Ostatnie", "chooseCustomIcon": "Wybierz niestandardową ikonę" } } } ================================================ FILE: src/locales/ru.json ================================================ { "Nav": { "item_drive": "Мой диск", "item_recent": "Недавние", "item_sharings": "Общие", "item_shared": "Мои отправленные файлы", "item_activity": "Активность", "item_trash": "Корзина", "item_migration": "Миграция", "item_settings": "Настройки", "item_collect": "Администрирование", "item_shared_drives": "Общие диски", "item_favorites": "Избранное", "item_my_drive": "Мой диск", "btn-client": "Получить достуа к Twake Drive для ПК", "support-us": "Посмотреть предложения", "support-us-description": "Хотите получить больше места или просто поддержать Cozy?", "btn-client-web": "Получить доступ к Twake", "btn-client-mobile": "Возьмите свой персональный облачный сервис с собой: установите %{name} на все устройства!", "banner-txt-client": "Получите %{name} для ПК и безопасно синхронизируйте свои файлы, чтобы они всегда были доступны.", "banner-btn-client": "Скачать", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile", "link-client-ios": "https://apps.apple.com/app/cloud-personnel-cozy/id1600636174", "link-client-web": "https://cozy.io/try-it", "view_more": "Показать больше", "view_less": "Показать меньше", "item_nextcloud": "Nextcloud" }, "breadcrumb": { "title_drive": "Файлы", "title_recent": "Недавние", "title_sharings": "Общие", "title_shared": "Мои отправленные файлы", "title_activity": "Активность", "title_trash": "Корзина", "label": "Показать путь", "title_shared_drives": "Диски", "title_favorites": "Избранное" }, "Toolbar": { "more": "Ещё" }, "toolbar": { "menu_upload": "Загрузить файлы", "item_more": "Ещё", "menu_new_folder": "Папка", "menu_select": "Выбрать элементы", "menu_share_folder": "Поделиться папкой", "menu_download": "Скачать", "menu_sync_cozy": "Синхронизировать с моим Twake", "add_to_mine": "Добавить в мой Twake", "menu_download_folder": "Скачать папку", "menu_download_file": "Скачать этот файл", "menu_create_note": "Заметка", "menu_create_shortcut": "Ярлык", "share": "Поделиться", "trash": "Удалить", "delete_shared_drive": "Удалить общий диск", "leave": "Покинуть доступную папку и удалить ее.", "menu_add": "Добавить", "menu_create": "Создать", "menu_add_item": "Добавить элемент", "menu_onlyOffice": { "text": "Текстовый документ", "spreadsheet": "Таблица", "slide": "Презентация" }, "select_all": "Выбрать всё", "select_all_mobile": "все", "clear_selection": "Очистить выбор", "clear_selection_mobile": "отмена", "sharings_tab_all": "Всё", "sharings_tab_drives": "Диски" }, "Share": { "create-cozy": "Создать Twake Drive" }, "Files": { "share": { "cta": "Поделиться", "title": "Поделиться", "details": { "title": "Информация об общем доступе", "createdAt": "%{date}", "ro": "Можно читать", "rw": "Можно изменять", "desc": { "ro": "Вы можете просматривать, скачивать и добавлять эти файлы на свой диск. Вы будете получать обновления от владельца, но не сможете вносить изменения.", "rw": "Вы можете просматривать, редактировать, удалять и добавлять файлы на свой диск. Ваши изменения будут видны другим пользователям." } }, "shared": "Общий доступ", "sharedByMe": "Мои отправленные файлы", "sharedWithMe": "Доступно мне", "sharedBy": "Доступ предоставлен %{name}", "shareByLink": { "subtitle": "По публичной ссылке", "desc": "Любой, у кого есть эта ссылка, может просматривать и скачивать ваши файлы.", "creating": "Создание ссылки...", "copy": "Копировать ссылку", "copied": "Ссылка скопирована в буфер обмена", "failed": "Не удалось скопировать в буфер обмена" }, "shareByEmail": { "subtitle": "По email", "email": "Кому:", "emailPlaceholder": "Введите email или имя получателя", "send": "Отправить", "genericSuccess": "Вы отправили %{count} приглашений контактам.", "success": "Вы отправили приглашение %{email}.", "comingsoon": "Скоро вы сможете делиться документами и фотографиями в один клик с семьёй, друзьями и коллегами. Мы сообщим, когда функция будет доступна!", "onlyByLink": "%{type} можно отправить только по ссылке, потому что", "type": { "file": "файл", "folder": "папка" }, "hasSharedParent": "он находится в общей папке", "hasSharedChild": "он содержит общий элемент" }, "revoke": { "title": "Прекратить общий доступ", "desc": "Этот контакт сохранит копию, но изменения больше не будут синхронизироваться.", "success": "Вы прекратили общий доступ к файлу для %{email}." }, "revokeSelf": { "title": "Прекратить мой доступ", "desc": "Вы сохраните контент, но он больше не будет обновляться между вашими дисками.", "success": "Ваш доступ к этому общему ресурсу отменён." }, "sharingLink": { "title": "Ссылка для общего доступа", "copy": "Копировать", "copied": "Скопировано" }, "whoHasAccess": { "title": "1 человек имеет доступ |||| %{smart_count} человек имеют доступ" }, "protectedShare": { "title": "Скоро!", "desc": "Делитесь любыми файлами по электронной почте с семьёй и друзьями!" }, "close": "Закрыть", "gettingLink": "Создание ссылки...", "error": { "generic": "Произошла ошибка при создании ссылки для общего доступа, попробуйте ещё раз.", "revoke": "Произошла ошибка. Пожалуйста, свяжитесь с нами, чтобы мы могли решить эту проблему как можно скорее." }, "specialCase": { "base": "Этот %{type} можно отправить только по ссылке, так как он", "isInSharedFolder": "находится в общей папке", "hasSharedFolder": "содержит общую папку" } }, "viewer-fallback": "Если файл начал загружаться, вы можете закрыть это окно.", "dropzone": { "teaser": "Перетащите файлы для загрузки в:", "noFolderSupport": "Ваш браузер не поддерживает перетаскивание папок. Пожалуйста, загрузите файлы вручную." } }, "table": { "head_name": "Имя", "head_update": "Последнее обновление", "head_size": "Размер", "head_status": "Общий доступ", "head_thumbnail_size": "Изменить размер миниатюр", "head_view_mode": "Режим просмотра", "head_view_list": "Список", "head_view_grid": "Плитка", "row_update_format": "LLL d, yyyy", "row_update_format_full": "LLLL d, yyyy", "row_read_only": "Общий доступ (Только чтение)", "row_read_write": "Общий доступ (Чтение и запись)", "row_size_symbols": { "B": "Б", "KB": "КБ", "MB": "МБ", "GB": "ГБ", "TB": "ТБ", "PB": "ПБ", "EB": "ЭБ", "ZB": "ЗБ", "YB": "ЙБ" }, "row_sharing_shortcut_aria_label": "Новый ярлык общего доступа", "load_more": "Загрузить ещё", "mobile": { "head_name_asc": "А-Я", "head_name_desc": "Я-А", "head_updated_at_asc": "Сначала старые", "head_updated_at_desc": "Сначала новые", "head_size_asc": "Сначала лёгкие", "head_size_desc": "Сначала тяжёлые" }, "tooltip": { "carbonCopy": { "title": "Копия", "caption": "Указывает, является ли документ \"аутентичным и оригинальным\" согласно Twake Workplace, так как может утверждаться, что получен напрямую от стороннего сервиса без изменений." }, "electronicSafe": { "title": "Электронный сейф", "caption": "Указывает, защищён ли оригинальный документ вашим личным цифровым сейфом с сертификатами, которые придают ему доказательную силу и гарантируют хранение в течение 50 лет после загрузки." } } }, "Storage": { "title": "Хранилище", "availability": "Доступно %{smart_count} ГБ", "increase": "Увеличить пространство" }, "SelectionBar": { "selected_count": "выбран 1 элемент |||| выбрано %{smart_count} элементов", "share": "Поделиться", "download": "Скачать", "trash": "Удалить", "destroy": "Удалить навсегда", "rename": "Переименовать", "restore": "Восстановить", "close": "Закрыть", "openWith": "Открыть с помощью...", "applePreview": "Просмотр Apple", "forward": "Переслать", "forwardTo": "Переслать...", "moveto": "Переместить в...", "moveto_mobile": "Переместить", "phone-download": "Сделать доступным офлайн", "qualify": "Категоризировать", "history": "История", "more": "Ещё", "openWithinNextcloud": "Открыть в Nextcloud" }, "DeleteConfirm": { "title": "Удалить %{filename}? |||| Удалить %{smart_count} %{type}?", "trash": "Элемент будет перемещён в корзину. |||| Элементы будут перемещены в корзину.", "restore": "Вы сможете восстановить его в любое время. |||| Вы сможете восстановить их в любое время.", "share_accepted": "Общий доступ будет остановлен. Указанные ниже контакты сохранят копию, но ваши изменения больше не будут синхронизироваться:", "share_waiting": "Общий доступ будет остановлен. Указанные ниже контакты больше не смогут принять приглашение или получить доступ к файлам:", "share_both": "Общий доступ будет остановлен. Это означает, что контакты, сохранившие файлы на своем диске, сохранят их копию, а другие контакты больше не смогут получить доступ к общим файлам:", "link": "Общий доступ по ссылке больше не будет активен", "referenced": "Некоторые файлы в выборке связаны с фотоальбомом. Они будут удалены из него, если вы переместите их в корзину.", "cancel": "Отмена", "delete": "Удалить" }, "EmptyTrashConfirm": { "title": "Удалить навсегда?", "forbidden": "Вы больше не сможете получить доступ к этим файлам.", "restore": "Вы не сможете восстановить эти файлы, если у вас нет резервной копии.", "cancel": "Отмена", "delete": "Удалить всё", "processing": "Корзина очищается. Это может занять некоторое время.", "success": "Корзина очищена.", "error": "Произошла ошибка, попробуйте ещё раз." }, "DestroyConfirm": { "title": "Удалить %{filename}? |||| Удалить %{smart_count} %{type}?", "forbidden": "Вы больше не сможете получить доступ к этому(-ой) %{type}. |||| Вы больше не сможете получить доступ к этим %{type}.", "restore": "Вы не сможете восстановить этот(-у) %{type}, если у вас нет резервной копии. |||| Вы не сможете восстановить эти %{type}, если у вас нет резервной копии.", "cancel": "Отмена", "delete": "Удалить навсегда", "success": "%{type} удален(-а) навсегда. |||| %{smart_count} %{type} удалены навсегда.", "error": "Произошла ошибка, попробуйте ещё раз." }, "quotaalert": { "title": "Ваше дисковое пространство заполнено :(", "desc": "Пожалуйста, удалите файлы, очистите корзину или увеличьте дисковое пространство перед загрузкой новых файлов.", "confirm": "OK", "increase": "Увеличить дисковое пространство" }, "loading": { "message": "Загрузка", "onlyOfficeCreateInProgress": "Создание файла..." }, "empty": { "title": "В этой папке нет файлов.", "text": "Выберите файлы на вашем компьютере или перетащите их сюда.", "mobile_text": "Выберите файлы на вашем устройстве.", "trash_title": "У вас нет удалённых файлов.", "trash_text": "Перемещайте ненужные файлы в корзину и удаляйте их навсегда, чтобы освободить место.", "shared-drive_text": "Создайте и поделитесь вашим первым Диск." }, "error": { "open_folder": "Произошла ошибка при открытии папки.", "open_file": "Произошла ошибка при открытии файла.", "button": { "reload": "Обновить сейчас" }, "download_file": { "offline": "Для скачивания файла необходимо подключение к интернету", "missing": "Этот файл отсутствует" } }, "Error": { "public_unshared_title": "Извините, эта ссылка больше недоступна.", "public_unshared_text": "Срок действия ссылки истёк, или владелец удалил её. Сообщите ему, что вы не смогли получить доступ", "generic": "Что-то пошло не так. Подождите несколько минут и попробуйте снова." }, "alert": { "could_not_open_file": "Не удалось открыть файл", "try_again": "Произошла ошибка, попробуйте ещё раз через некоторое время.", "restore_file_success": "Выбранные элементы успешно восстановлены.", "trash_file_success": "Выбранные элементы перемещены в корзину.", "destroy_file_success": "Выбранные элементы удалены навсегда.", "folder_name": "Элемент %{folderName} уже существует, выберите другое имя.", "file_name": "Элемент %{fileName} уже существует, выберите другое имя.", "file_name_missing": "Отсутствует имя файла, выберите другое имя.", "file_name_illegal_name": "Имя %{fileName} недопустимо, выберите другое имя.", "file_name_illegal_characters": "Элемент %{fileName} содержит недопустимые символы: %{characters}", "folder_generic": "Произошла ошибка, попробуйте ещё раз.", "folder_abort": "Чтобы сохранить новую папку, необходимо указать её имя. Ваши данные не сохранены.", "offline": "Эта функция недоступна офлайн.", "preparing": "Подготовка файлов…", "item_copied": "1 элемент скопирован", "items_copied": "%{count} элементов скопированы", "item_cut": "1 элемент вырезан", "items_cut": "%{count} элементов вырезаны", "item_moved": "1 элемент был перемещён", "items_moved": "%{count} элементов было перемещено", "item_pasted": "1 элемент был перемещён", "items_pasted": "%{count} элементов было перемещено", "copy_files_only": "Невозможно скопировать папки", "copy_not_allowed": "Операция копирования не разрешена в этом представлении.", "cut_not_allowed": "Операция вырезания не разрешена в этом представлении.", "paste_error": "Произошла ошибка при вставке файлов", "paste_failed": "Не удалось вставить файлы", "paste_sharing_error": "Невозможно вставить файлы из-за ограничений общего доступа. Используйте действие Переместить вместо этого.", "paste_same_folder_skipped": "Невозможно переместить элементы в ту же папку, в которой они уже находятся.", "paste_not_allowed": "Вы не можете вставить в эту папку", "cannot_move_shared_drive": "Вы не можете переместить папку общего диска", "cannot_copy_shared_drive": "Вы не можете скопировать папку общего диска" }, "upload": { "label": "Загрузить", "documentType": { "file": "файл", "directory": "папка", "element": "элемент" }, "alert": { "success": "%{smart_count} %{type} успешно загружен. |||| %{smart_count} %{type} успешно загружены.", "success_conflicts": "%{smart_count} %{type} загружен с %{conflictNumber} конфликтом(-ами). |||| %{smart_count} %{type} загружены с %{conflictNumber} конфликтом(-ами).", "success_updated": "%{smart_count} %{type} загружен и %{updatedCount} обновлён. |||| %{smart_count} %{type} загружены и %{updatedCount} обновлены.", "success_updated_conflicts": "%{smart_count} %{type} загружен, %{updatedCount} обновлён, c %{conflictCount} конфликтом(-ами). |||| %{smart_count} %{type} загружены, %{updatedCount} обновлены, с %{conflictCount} конфликтом(-ами).", "updated": "%{smart_count} %{type} обновлён. |||| %{smart_count} %{type} обновлены.", "updated_conflicts": "%{smart_count} %{type} обновлён с %{conflictCount} конфликтом(-ами). |||| %{smart_count} %{type} обновлены с %{conflictCount} конфликтом(-ами).", "errors": "При загрузке %{type} произошли ошибки.", "network": "Вы находитесь офлайн. Попробуйте снова после подключения.", "fileTooLargeErrors": "Файл слишком большой. Максимальный размер файла: %{max_size_value} ГБ" } }, "intents": { "alert": { "error": "Не удалось автоматически загрузить файл, пожалуйста, загрузите его вручную через меню загрузки." }, "picker": { "select": "Выбрать", "cancel": "Отмена", "new_folder": "Новая папка", "instructions": "Выберите цель" } }, "UploadQueue": { "header": "Загрузка %{smart_count} фото в Twake Drive |||| Загрузка %{smart_count} фото в Twake Drive", "header_mobile": "Загружено %{done} из %{total}", "header_done": "Успешно загружено %{done} из %{total}", "success_flagship": "%{smart_count} файл успешно загружен. |||| %{smart_count} файла(-ов) успешно загружены.", "close": "закрыть", "item": { "pending": "В ожидании" } }, "Viewer": { "close": "Закрыть", "noviewer": { "download": "Скачать этот файл", "openWith": "Открыть с помощью...", "openInOnlyOffice": "Открыть в Only Office", "cta": { "saveTime": "Сэкономьте время!", "installDesktop": "Установите инструмент синхронизации для вашего компьютера", "accessFiles": "Получайте доступ к файлам прямо с компьютера" } }, "actions": { "download": "Скачать", "forward": "Переслать" }, "loading": { "error": "Не удалось загрузить файл. Проверьте подключение к интернету.", "retry": "Повторить" }, "error": { "noapp": "На вашем устройстве нет приложения для открытия этого файла.", "generic": "Произошла ошибка при открытии файла, попробуйте ещё раз.", "noNetwork": "Вы находитесь офлайн." }, "panel": { "title": "Полезная информация" } }, "Move": { "to": "Переместить в:", "action": "Переместить", "cancel": "Отмена", "modalTitle": "Переместить", "title": "%{smart_count} элемент |||| %{smart_count} элементов", "success": "%{subject} перемещён в %{target}. |||| %{smart_count} элементов перемещены в %{target}.", "error": "Произошла ошибка при перемещении элемента, попробуйте позже. |||| Произошла ошибка при перемещении элементов, попробуйте позже.", "cancelled": "%{subject} возвращён в исходное расположение. |||| %{smart_count} элементов возвращены в исходное расположение.", "cancelledWithRestoreErrors": "%{subject} возвращён в исходное расположение, но произошла ошибка при восстановлении из корзины. |||| %{smart_count} элементов возвращены в исходное расположение, но произошло %{restoreErrorsCount} ошибок при восстановлении из корзины.", "cancelled_error": "Извините, произошла ошибка при возврате элемента. |||| Извините, произошла ошибка при возврате элементов.", "multipleEntries": "%{smart_count} элемент |||| %{smart_count} элементов", "addFolder": "Добавить папку", "outsideSharedFolder": { "title": "Перемещение за пределы папки %{sharedFolder}", "content_1": "Внимание: вы хотите переместить %{name} из общей папки %{sharedFolder}. |||| Внимание: вы хотите переместить %{smart_count} %{type} из общей папки %{sharedFolder}.", "content_2": "Это перемещение отменит общий доступ к %{type} %{name}. Этот %{type} будет перемещён в корзину для всех участников общего доступа. |||| Это перемещение отменит общий доступ к %{smart_count} %{type}. Эти %{type} будут перемещены в корзину для всех участников общего доступа.", "cancel": "Отмена", "confirm": "Я понимаю" }, "insideSharedFolder": { "title": "Переместить в общую папку?", "content": "Все, кто имеет доступ к %{destination}, также получат доступ к %{source}. |||| Все, кто имеет доступ к %{destination}, также получат доступ к выбранным %{type}.", "cancel": "Отмена", "confirm": "ОК" }, "sharedFolderInsideAnother": { "title": "Невозможно переместить", "content_1": "Вы пытаетесь переместить общий элемент в общую папку. Это действие запрещено.", "content_2": "Если вы всё же хотите переместить %{source} в %{destination}, отмените общий доступ:", "cancel": "Отменить перемещение", "confirm": "Отменить общий доступ" } }, "ImportToDrive": { "title": "%{smart_count} элемент |||| %{smart_count} элементов", "to": "Сохранить в:", "action": "Сохранить", "cancel": "Отмена", "success": "%{smart_count} сохранённый файл |||| %{smart_count} сохранённых файла(-ов)", "error": "Произошла ошибка. Попробуйте ещё раз" }, "FileOpenerExternal": { "fileNotFoundError": "Ошибка: файл не найден" }, "TOS": { "updated": { "title": "GDPR вступает в силу!", "detail": "В рамках Общего регламента по защите данных наши Условия обслуживания были обновлены и будут применяться ко всем пользователям Twake Workplace с 25 мая 2018 года.", "cta": "Принять Условия и продолжить", "disconnect": "Отказаться и выйти", "error": "Произошла ошибка, попробуйте позже" } }, "manifest": { "permissions": { "contacts": { "description": "Необходимо, чтобы делиться файлами с вашими контактами" }, "groups": { "description": "Необходимо, чтобы делиться файлами с вашими группами" } } }, "models": { "contact": { "defaultDisplayName": "Анонимно" } }, "Scan": { "none": "Ничего", "scan_a_doc": "Сканировать документ", "save_doc": "Сохранить документ", "filename": "Имя файла", "save": "Сохранить", "cancel": "Отмена", "qualify": "Категоризировать", "requalify": "Перекатегоризировать", "apply": "Применить", "error": { "offline": "Вы находитесь офлайн и не можете использовать эту функцию. Попробуйте позже.", "uploading": "Вы уже загружаете файл. Дождитесь завершения загрузки и попробуйте снова.", "generic": "Произошла ошибка. Попробуйте ещё раз." }, "successful": { "qualified_ok": "Вы успешно категоризировали файл!" } }, "History": { "description": "Последние 20 версий ваших файлов сохраняются автоматически. Выберите версию для скачивания.", "current_version": "Текущая версия", "loading": "Загрузка...", "noFileVersionEnabled": "Вскоре Twake Drive сможет архивировать последние изменения файлов, чтобы вы больше не рисковали их потерять" }, "External": { "redirection": { "title": "Перенаправление", "text": "Вы будете перенаправлены…", "error": "Ошибка при перенаправлении. Обычно это означает, что содержимое файла имеет неверный формат." } }, "RenameModal": { "title": "Переименовать", "description": "Вы собираетесь изменить расширение файла. Продолжить?", "continue": "Продолжить", "cancel": "Отмена" }, "Shortcut": { "title_modal": "Создать ярлык", "filename": "Имя файла", "url": "URL", "cancel": "Отмена", "create": "Создать", "created": "Ярлык создан", "errored": "Произошла ошибка", "filename_error_ends": "Имя должно заканчиваться на .url", "needs_info": "Для создания ярлыка необходимы URL и имя файла", "url_badformat": "URL имеет неверный формат" }, "OnlyOffice": { "Error": { "title": "Что-то пошло не так", "text": "Попробуйте перезагрузить страницу" }, "readOnly": { "title": "Только чтение", "tooltip": "Вы можете только просматривать этот документ. Обратитесь к владельцу для получения прав на редактирование." }, "createFileName": { "text": "Новый текстовый документ", "spreadsheet": "Новая таблица", "slide": "Новая презентация" }, "toolbar": { "goToHome": "На главную" }, "actions": { "edit": "Редактировать", "validate": "Подтвердить" }, "tooltip": { "title": "Редактировать документ", "text": "Документ доступен только для чтения. Вы можете изменить его, нажав здесь.", "actions": { "ok": "ОК", "hide": "Не показывать" } } }, "Migration": { "title": "Обновление Twake Drive", "content": "Twake Drive необходимо обновить для улучшения производительности. Это может занять несколько минут, в течение которых приложение будет недоступно. Хотите сделать это сейчас? Если вы откажетесь, мы спросим вас снова в следующий раз.", "confirm": "Да, сделаем это", "cancel": "Нет, не сейчас" }, "searchbar": { "placeholder": "Поиск", "empty": "По запросу “%{query}” ничего не найдено" }, "button": { "back": "Назад", "add": "Добавить", "create": "Создать" }, "search": { "action": "Поиск", "empty": { "title": "Нет результатов", "subtitle": "По запросу “%{query}” ничего не найдено" } }, "PushBanner": { "quota": { "text": "У вас почти закончилось место в хранилище. При достижении лимита вы не сможете добавлять новые файлы. Вы можете удалить файлы, очистить корзину или изменить тарифный план.", "actions": { "first": "Я понимаю", "second": "Посмотреть планы" } } }, "FileDivergedModal": { "title": "Кто-то изменил этот файл", "content": "Кто-то изменил файл вне Twake Drive во время вашего редактирования. Вы можете получить эти изменения вместо своих или продолжить редактирование в новом файле.", "confirm": "Продолжить редактирование", "cancel": "Посмотреть изменения", "error": "Произошла ошибка, попробуйте ещё раз.", "confirmReload": { "title": "Посмотреть изменения", "content": "При доступе к новому файлу ваши изменения будут отменены.", "cancel": "Отмена", "confirm": "ОК, я понял" }, "viewMode": { "title": "Кто-то изменил этот файл", "content": "Кто-то изменил содержимое этого файла. Вы можете получить доступ к этим изменениям.", "confirm": "Посмотреть изменения" } }, "FileDeletedModal": { "title": "Кто-то удалил этот файл", "content": "Кто-то удалил этот файл во время вашего редактирования. Вы можете прекратить редактирование или восстановить файл, чтобы продолжить.", "confirm": "Восстановить файл", "cancel": "Отменить изменения", "error": "Произошла ошибка, попробуйте ещё раз." }, "TrashedBanner": { "text": "Элемент находится в корзине", "destroy": "Удалить навсегда", "restore": "Восстановить", "restoreSuccess": "Элемент восстановлен", "restoreError": "Произошла ошибка, попробуйте ещё раз.", "destroySuccess": "Элемент удалён" }, "MigrationProgressBanner": { "title": "Миграция с Nextcloud в процессе", "percent": "%{percent}% завершено", "importing": "Импорт %{count} файлов из Nextcloud...", "cancel": "Отменить", "done": { "title": "Миграция завершена!", "body": "Успешно импортировано %{count} файлов из Nextcloud" } }, "EntriesType": { "file": "файл |||| файлы", "directory": "папка |||| папки", "element": "элемент |||| элементы" }, "NotFound": { "title": "Элемент не найден", "text": "По этому адресу ничего не найдено. Возможно, есть ошибка в написании." }, "NextcloudBreadcrumb": { "root": "Общие диски", "trash": "Корзина" }, "NextcloudToolbar": { "share": "Поделиться" }, "NextcloudDeleteConfirm": { "title": "Удалить %{filename}? |||| Удалить %{smart_count} %{type}?", "trash": "Этот элемент будет перемещён в корзину Nextcloud. |||| Эти элементы будут перемещены в корзину Nextcloud.", "restore": "Вы всегда можете восстановить его из корзины Nextcloud.", "error": "Произошла ошибка, попробуйте ещё раз.", "cancel": "Отмена", "delete": "Удалить" }, "FileName": { "sharedDrive": "Диски", "trash": "Корзина" }, "NextcloudBanner": { "title": "Элементы ниже отображаются из диска NextCloud и не хранятся в вашем Twake." }, "favorites": { "label": { "add": "Добавить в избранное", "addMobile": "Избранное", "remove": "Удалить из избранного" }, "error": "Произошла ошибка, попробуйте ещё раз.", "success": { "add": "%{filename} добавлен в избранное |||| Эти элементы добавлены в избранное", "remove": "%{filename} удалён из избранного |||| Эти элементы удалены из избранного" } }, "TrashToolbar": { "emptyTrash": "Очистить корзину" }, "RestoreNextcloudFile": { "label": "Восстановить", "success": "Элемент восстановлен", "error": "Произошла ошибка, попробуйте ещё раз." }, "actions": { "details": "Подробности", "infos": "Детали и категоризация", "infosMobile": "Детали", "duplicateTo": { "label": "Дублировать в…" }, "duplicateToMobile": { "label": "Дублировать" }, "personalizeFolder": { "label": "Персонализировать папку" }, "summariseByAI": "Резюмировать" }, "FolderCustomizer": { "title": "Персонализировать папку", "description": "Выберите определенный цвет для вашей папки", "cancel": "Отмена", "apply": "Применить", "error": "Произошла ошибка, попробуйте ещё раз.", "tabs": { "colors": "Цвета", "icons": "Иконки" }, "iconPicker": { "recents": "Недавние", "chooseCustomIcon": "Выберите пользовательскую иконку" } }, "DuplicateModal": { "subTitle": "Дублировать в:", "confirmLabel": "Дублировать здесь", "success": "%{fileName} дублирован в %{destinationName}. |||| %{smart_count} элементов дублированы в %{destinationName}.", "error": "Произошла ошибка, попробуйте ещё раз." }, "OpenFolderButton": { "label": "Открыть папку" }, "LastUpdate": { "titleFormat": "LLLL dd, yyyy, HH:MM" }, "AddMenu": { "readOnlyFolder": "Это папка только для чтения. Вы не можете выполнить это действие." }, "PublicNoteRedirect": { "error": { "title": "Не удалось получить доступ к документу", "subtitle": "Ссылка для общего доступа отсутствует или недействительна. Попросите владельца документа проверить доступ." } } } ================================================ FILE: src/locales/vi.json ================================================ { "Nav": { "item_drive": "Ổ đĩa của tôi", "item_recent": "Gần đây", "item_sharings": "Chia sẻ", "item_shared": "Chia sẻ bởi tôi", "item_activity": "Hoạt động", "item_trash": "Thùng rác", "item_migration": "Di chuyển", "item_settings": "Cài đặt", "item_collect": "Quản trị", "item_shared_drives": "Ổ đĩa dùng chung", "item_favorites": "Yêu thích", "item_my_drive": "Ổ đĩa của tôi", "btn-client": "Tải TwakeDrive cho máy tính", "support-us": "Xem những ưu đãi", "support-us-description": "Bạn muốn có thêm dung lượng hay đơn giản chỉ muốn ủng hộ Cozy??", "btn-client-web": "Tải Twake", "btn-client-mobile": "Mang đám mây cá nhân theo bạn mọi nơi: cài đặt %{name} trên tất cả thiết bị của bạn!", "banner-txt-client": "Tải %{name} cho máy tính và đồng bộ hóa tệp của bạn một cách an toàn để luôn có thể truy cập mọi lúc", "banner-btn-client": "Tải xuống", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile", "link-client-ios": "https://apps.apple.com/app/cloud-personnel-cozy/id1600636174", "link-client-web": "https://cozy.io/try-it", "view_more": "Hiển thị thêm", "view_less": "Hiển thị ít hơn", "item_nextcloud": "Nextcloud" }, "breadcrumb": { "title_drive": "Tệp", "title_recent": "Gần đây", "title_sharings": "Chia sẻ", "title_shared": "Chia sẻ bởi tôi", "title_activity": "Hoạt động", "title_trash": "Thùng rác", "label": "Hiển thị đường dẫ", "title_shared_drives": "Ổ đĩa", "title_favorites": "Yêu thích" }, "Toolbar": { "more": "Thêm" }, "toolbar": { "menu_upload": "Tải lên tệp tin", "item_more": "Thêm", "menu_new_folder": "Thư mục", "menu_select": "Chọn mục", "menu_share_folder": "Chia sẻ thư mục", "menu_download": "Tải xuống", "menu_sync_cozy": "Đồng bộ với Twake của tôi", "add_to_mine": "Thêm vào Twake của tôi", "menu_download_folder": "Tải thư mục xuống", "menu_download_file": "Tải tệp này xuống", "menu_create_note": "Ghi chú", "menu_create_shortcut": "Lối tắt", "share": "Chia sẻ", "trash": "Xoá", "leave": "Rời khỏi thư mục được chia sẻ & xoá", "menu_add": "Thêm", "menu_create": "Tạo", "menu_add_item": "Thêm mục", "menu_onlyOffice": { "text": "Tài liệu văn bản", "spreadsheet": "Bảng tính", "slide": "Bài thuyết trình" }, "select_all": "Chọn tất cả", "select_all_mobile": "Tất cả", "clear_selection": "Xóa lựa chọn", "clear_selection_mobile": "Hủy", "sharings_tab_all": "Tất cả", "sharings_tab_drives": "Ổ đĩa" }, "Share": { "create-cozy": "Tạo Twake của tôi" }, "Files": { "share": { "cta": "Chia sẻ", "title": "Chia sẻ", "details": { "title": "Chi tiết chia sẻ", "createdAt": "On %{date}", "ro": "Có thể đọc", "rw": "Có quyền chỉnh sửa", "desc": { "ro": "Bạn có thể xem, tải xuống và thêm nội dung này vào Twake của mình. Bạn sẽ nhận được các bản cập nhật từ chủ sở hữu, nhưng bạn sẽ không thể tự cập nhật nội dung này.", "rw": "Bạn có thể xem, cập nhật, xóa và thêm nội dung này vào Twake của mình. Những cập nhật bạn thực hiện sẽ hiển thị với các Cozy khác." } }, "shared": "Đã chia sẻ", "sharedByMe": "Chia sẻ bởi tôi", "sharedWithMe": "Chia sẻ với tôi", "sharedBy": "Chia sẻ bởi %{name}", "shareByLink": { "subtitle": "Bằng liên kết công khai", "desc": "Bất kỳ ai có liên kết đều có thể xem và tải xuống tệp của bạn.", "creating": "Đang tạo liên kết...", "copy": "Sao chép liên kết", "copied": "Liên kết đã được sao chép vào bộ nhớ tạm", "failed": "Không thể sao chép vào bộ nhớ tạm" }, "shareByEmail": { "subtitle": "Bằng email", "email": "Tới:", "emailPlaceholder": "Nhập địa chỉ email hoặc tên người nhận", "send": "Gửi", "genericSuccess": "Bạn đã gửi lời mời đến %{count} liên hệ.", "success": "Bạn đã gửi lời mời đến %{email}.", "comingsoon": "Sắp ra mắt! Bạn sẽ có thể chia sẻ tài liệu và hình ảnh chỉ với một cú nhấp chuột với gia đình, bạn bè và cả đồng nghiệp. Đừng lo, chúng tôi sẽ thông báo khi tính năng này sẵn sàng!", "onlyByLink": "Chỉ có thể chia sẻ %{type} này bằng liên kết, vì", "type": { "file": "tệp", "folder": "thư mục" }, "hasSharedParent": "nó thuộc một thư mục đã được chia sẻ", "hasSharedChild": "nó chứa phần tử đã được chia sẻ" }, "revoke": { "title": "Gỡ chia sẻ", "desc": "Liên hệ này sẽ giữ một bản sao, nhưng các thay đổi sẽ không còn được đồng bộ.", "success": "Bạn đã gỡ tệp chia sẻ khỏi %{email}." }, "revokeSelf": { "title": "Gỡ tôi khỏi chia sẻ", "desc": "Bạn sẽ giữ lại nội dung nhưng sẽ không còn được cập nhật giữa các Twake của bạn.", "success": "Bạn đã được gỡ khỏi chia sẻ này." }, "sharingLink": { "title": "Liên kết chia sẻ", "copy": "Sao chép", "copied": "Đã sao chép" }, "whoHasAccess": { "title": "1 người có quyền truy cập |||| %{smart_count} người có quyền truy cập" }, "protectedShare": { "title": "Sắp ra mắt!", "desc": "Chia sẻ mọi thứ qua email với gia đình và bạn bè!" }, "close": "Đóng", "gettingLink": "Đang khởi tạo liên kết...", "error": { "generic": "Đã xảy ra lỗi khi tạo liên kết chia sẻ tệp, vui lòng thử lại.", "revoke": "Rất tiếc, đã xảy ra lỗi. Vui lòng liên hệ với chúng tôi để khắc phục sự cố này sớm nhất có thể." }, "specialCase": { "base": "%{type} này chỉ có thể chia sẻ qua liên kết vì", "isInSharedFolder": "nằm trong một thư mục đã được chia sẻ", "hasSharedFolder": "chứa một thư mục đã được chia sẻ" } }, "viewer-fallback": "Nếu tệp đã bắt đầu tải xuống, bạn có thể đóng cửa sổ này.", "dropzone": { "teaser": "Thả tệp vào đây để tải lên:", "noFolderSupport": "Trình duyệt của bạn hiện không hỗ trợ kéo & thả thư mục. Vui lòng tải tệp lên theo cách thủ công." } }, "table": { "head_name": "Tên", "head_update": "Cập nhật lần cuối", "head_size": "Kích thước", "head_status": "Chia sẻ", "head_thumbnail_size": "Chuyển kích thước hình thu nhỏ", "head_view_mode": "Chế độ xem", "head_view_list": "Chế độ danh sách", "head_view_grid": "Chế độ lưới", "row_update_format": "LLL d, yyyy", "row_update_format_full": "LLLL d, yyyy", "row_read_only": "Chia sẻ (Chỉ đọc)", "row_read_write": "Chia sẻ (Đọc & Ghi)", "row_size_symbols": { "B": "B", "KB": "KB", "MB": "MB", "GB": "GB", "TB": "TB", "PB": "PB", "EB": "EB", "ZB": "ZB", "YB": "YB" }, "row_sharing_shortcut_aria_label": "Phím tắt chia sẻ mới", "load_more": "Tải thêm", "mobile": { "head_name_asc": "A-Z", "head_name_desc": "Z-A", "head_updated_at_asc": "Cũ nhất trước", "head_updated_at_desc": "Mới nhất trước", "head_size_asc": "Nhẹ nhất trước", "head_size_desc": "Nặng nhất trước" }, "tooltip": { "carbonCopy": { "title": "Bản sao gốc", "caption": "Chỉ ra rằng tài liệu này được xác định là \\“xác thực và nguyên bản\\” bởi Twake Workplace, đơn vị lưu trữ Twake của bạn, vì nó có thể chứng minh rằng tài liệu đến trực tiếp từ dịch vụ bên thứ ba mà không bị chỉnh sửa." }, "electronicSafe": { "title": "Két điện tử", "caption": "Chỉ ra rằng tài liệu gốc được bảo vệ trong két điện tử cá nhân của bạn, kèm theo các chứng nhận mang lại giá trị pháp lý và đảm bảo lưu trữ trong 50 năm kể từ thời điểm nộp." } } }, "Storage": { "title": "Dung lượng", "availability": "Còn %{smart_count} GB trống", "increase": "Tăng dung lượng" }, "SelectionBar": { "selected_count": "1 mục đã chọn |||| %{smart_count} mục đã chọn", "share": "Chia sẻ", "download": "tải xuống", "trash": "Xoá bỏ", "destroy": "Xoá vĩnh viễn", "rename": "Đổi tên", "restore": "Khôi phục", "close": "Đóng", "openWith": "Mở bằng...", "applePreview": "Xem trước với Aplle", "forward": "Chuyển tiếp", "forwardTo": "Chuyển tiếp đến...", "moveto": "Di chuyển tới…", "moveto_mobile": "Di chuyển", "phone-download": "Tải về để dùng ngoại tuyến", "qualify": "Phân loại", "history": "Lịch sử", "more": "Thêm", "openWithinNextcloud": "Mở bằng Nextcloud" }, "DeleteConfirm": { "title": "Xóa %{filename}? |||| Xóa %{smart_count} %{type}?", "trash": "Tệp sẽ được chuyển vào Thùng rác. |||| Các tệp sẽ được chuyển vào Thùng rác.", "restore": "Bạn có thể khôi phục bất cứ lúc nào. |||| Bạn có thể khôi phục chúng bất cứ lúc nào.", "share_accepted": "Chia sẻ sẽ bị dừng. Những liên hệ sau sẽ giữ một bản sao, nhưng thay đổi của bạn sẽ không còn được đồng bộ:", "share_waiting": "Chia sẻ sẽ bị dừng. Những liên hệ sau sẽ không còn có thể chấp nhận chia sẻ và truy cập nội dung nữa:", "share_both": "Chia sẻ sẽ bị dừng. Một số liên hệ đã lưu tệp vào Twake sẽ giữ bản sao, còn những người khác sẽ mất quyền truy cập:", "link": "Liên kết chia sẻ sẽ bị vô hiệu hóa", "referenced": "Một số tệp trong lựa chọn liên kết với album ảnh. Nếu tiếp tục xóa, chúng sẽ bị loại khỏi album.", "cancel": "Huỷ", "delete": "Xoá" }, "EmptyTrashConfirm": { "title": "Xóa vĩnh viễn?", "forbidden": "Bạn sẽ không thể truy cập các tệp này nữa.", "restore": "Bạn sẽ không thể khôi phục nếu chưa sao lưu.", "cancel": "Huỷ", "delete": "Xoá tất cả", "processing": "Đang dọn Thùng rác. Việc này có thể mất vài phút.", "success": "Thùng rác đã được làm trống.", "error": "Đã xảy ra lỗi, vui lòng thử lại." }, "DestroyConfirm": { "title": "Xóa %{filename}? |||| Xóa %{smart_count} %{type}?", "forbidden": "Bạn sẽ không thể truy cập %{type} này nữa. |||| Bạn sẽ không thể truy cập các %{type} này nữa.", "restore": "Không thể khôi phục nếu bạn chưa sao lưu. |||| Không thể khôi phục các %{type} này nếu chưa sao lưu.", "cancel": "Huỷ", "delete": "Xóa vĩnh viễn", "success": "%{type} đã được xóa vĩnh viễn. |||| %{smart_count} %{type} đã được xóa vĩnh viễn.", "error": "Đã xảy ra lỗi, vui lòng thử lại." }, "quotaalert": { "title": "Dung lượng của bạn đã đầy :(", "desc": "Vui lòng xóa tệp, dọn Thùng rác hoặc nâng cấp dung lượng trước khi tải thêm tệp.", "confirm": "OK", "increase": "Tăng dung lượng" }, "loading": { "message": "Đang tải", "onlyOfficeCreateInProgress": "Đang tạo tệp..." }, "empty": { "title": "Bạn chưa có tệp nào trong thư mục này.", "text": "Chọn tệp trên máy tính của bạn hoặc kéo chúng vào đây.", "mobile_text": "Chọn tệp trên thiết bị của bạn.", "trash_title": "Bạn chưa có tệp nào trong Thùng rác.", "trash_text": "Chuyển các tệp không cần thiết vào Thùng rác và xóa vĩnh viễn để giải phóng dung lượng.", "shared-drive_text": "Tạo và chia sẻ ổ đĩa đầu tiên của bạn." }, "error": { "open_folder": "Đã xảy ra lỗi khi mở thư mục.", "open_file": "Đã xảy ra lỗi khi mở tệp.", "button": { "reload": "Tải lại ngay" }, "download_file": { "offline": "Bạn cần kết nối mạng để tải tệp này", "missing": "Tệp này không tồn tại" } }, "Error": { "public_unshared_title": "Rất tiếc, liên kết này không còn tồn tại.", "public_unshared_text": "Liên kết này đã hết hạn hoặc bị người tạo xóa. Hãy liên hệ với họ nếu bạn bỏ lỡ!", "generic": "Đã xảy ra lỗi. Vui lòng thử lại sau vài phút." }, "alert": { "could_not_open_file": "Không thể mở tệp", "try_again": "Đã xảy ra lỗi, vui lòng thử lại sau.", "restore_file_success": "Các mục đã được khôi phục thành công.", "trash_file_success": "Các mục đã được chuyển vào Thùng rác.", "destroy_file_success": "Các mục đã được xóa vĩnh viễn.", "folder_name": "Thư mục %{folderName} đã tồn tại, vui lòng chọn tên khác.", "file_name": "Tệp %{fileName} đã tồn tại, vui lòng chọn tên khác.", "file_name_missing": "Thiếu tên tệp, vui lòng đặt tên mới.", "file_name_illegal_name": "Tên %{fileName} không hợp lệ, vui lòng chọn tên khác.", "file_name_illegal_characters": "Tên %{fileName} chứa ký tự không hợp lệ: %{characters}", "folder_generic": "Đã xảy ra lỗi, vui lòng thử lại", "folder_abort": "Bạn cần đặt tên cho thư mục mới để lưu. Dữ liệu hiện tại chưa được lưu.", "offline": "Tính năng này không khả dụng khi ngoại tuyến.", "preparing": "Đang chuẩn bị tệp của bạn…", "item_copied": "1 mục đã được sao chép", "items_copied": "%{count} mục đã được sao chép", "item_cut": "1 mục đã được cắt", "items_cut": "%{count} mục đã được cắt", "item_moved": "1 mục đã được di chuyển", "items_moved": "%{count} mục đã được di chuyển", "item_pasted": "1 mục đã được di chuyển", "items_pasted": "%{count} mục đã được di chuyển", "copy_files_only": "Không thể sao chép thư mục", "copy_not_allowed": "Thao tác sao chép không được phép trong chế độ xem này.", "cut_not_allowed": "Thao tác cắt không được phép trong chế độ xem này.", "paste_error": "Đã xảy ra lỗi khi dán tệp", "paste_failed": "Dán tệp thất bại", "paste_sharing_error": "Không thể dán tệp do hạn chế chia sẻ. Vui lòng sử dụng hành động Di chuyển thay thế.", "paste_same_folder_skipped": "Không thể di chuyển các mục vào cùng một thư mục mà chúng đã có.", "paste_not_allowed": "Bạn không thể dán vào thư mục này", "cannot_move_shared_drive": "Bạn không thể di chuyển thư mục ổ đĩa chia sẻ", "cannot_copy_shared_drive": "Bạn không thể sao chép thư mục ổ đĩa chia sẻ" }, "upload": { "label": "Tải lên", "documentType": { "file": "tệp", "directory": "thư mục", "element": "phần tử" }, "alert": { "success": "Đã tải lên %{smart_count} %{type} thành công. |||| Đã tải lên %{smart_count} %{type} thành công.", "success_conflicts": "Đã tải lên %{smart_count} %{type} với %{conflictNumber} xung đột. |||| Đã tải lên %{smart_count} %{type} với %{conflictNumber} xung đột.", "success_updated": "Đã tải lên %{smart_count} %{type}, trong đó có %{updatedCount} được cập nhật. |||| Đã tải lên %{smart_count} %{type}, trong đó có %{updatedCount} được cập nhật.", "success_updated_conflicts": "Đã tải lên %{smart_count} %{type}, %{updatedCount} được cập nhật và có %{conflictCount} xung đột. |||| Đã tải lên %{smart_count} %{type}, %{updatedCount} được cập nhật và có %{conflictCount} xung đột.", "updated": "Đã cập nhật %{smart_count} %{type}. |||| Đã cập nhật %{smart_count} %{type}.", "updated_conflicts": "Đã cập nhật %{smart_count} %{type} với %{conflictCount} xung đột. |||| Đã cập nhật %{smart_count} %{type} với %{conflictCount} xung đột.", "errors": "Đã xảy ra lỗi trong quá trình tải lên %{type}.", "network": "Bạn hiện đang ngoại tuyến. Vui lòng thử lại khi có kết nối mạng.", "fileTooLargeErrors": "Tệp quá lớn. Kích thước tệp tối đa: %{max_size_value} GB" } }, "intents": { "alert": { "error": "Không thể tải tệp lên tự động, vui lòng tải thủ công qua menu tải lên." }, "picker": { "select": "Chọn", "cancel": "Huỷ", "new_folder": "Thư mục mới", "instructions": "Chọn thư mục đích" } }, "UploadQueue": { "header": "Đang tải lên %{smart_count} ảnh lên Twake Drive |||| Đang tải lên %{smart_count} ảnh lên Twake Drive", "header_mobile": "Đã tải lên %{done} / %{total}", "header_done": "Đã tải lên thành công %{done} trong tổng số %{total}", "success_flagship": "Đã tải lên %{smart_count} tệp thành công. |||| Đã tải lên %{smart_count} tệp thành công.", "close": "đóng", "item": { "pending": "Đang chờ" } }, "Viewer": { "close": "Đóng", "noviewer": { "download": "Tải tệp này xuống", "openWith": "Mở bằng...", "openInOnlyOffice": "Mở bằng OnlyOffice", "cta": { "saveTime": "Tiết kiệm thời gian!", "installDesktop": "Cài đặt công cụ đồng bộ hóa cho máy tính của bạn", "accessFiles": "Truy cập tệp trực tiếp từ máy tính" } }, "actions": { "download": "Tải xuống", "forward": "Chuyển tiếp" }, "loading": { "error": "Không thể tải tệp này. Vui lòng kiểm tra kết nối Internet của bạn.", "retry": "Thử lại" }, "error": { "noapp": "Thiết bị của bạn không có ứng dụng nào có thể mở tệp này.", "generic": "Đã xảy ra lỗi khi mở tệp, vui lòng thử lại.", "noNetwork": "Bạn đang ở chế độ ngoại tuyến." }, "panel": { "title": "Thông tin hữu ích" } }, "Move": { "to": "Di chuyển đến:", "action": "Di chuyển", "cancel": "Huỷ", "modalTitle": "Di chuyển", "title": "%{smart_count} phần tử |||| %{smart_count} phần tử", "success": "%{subject} đã được di chuyển đến %{target}. |||| %{smart_count} phần tử đã được di chuyển đến %{target}.", "error": "Đã xảy ra lỗi khi di chuyển phần tử này, vui lòng thử lại sau. |||| Đã xảy ra lỗi khi di chuyển các phần tử này, vui lòng thử lại sau.", "cancelled": "%{subject} đã được đưa trở lại vị trí ban đầu. |||| %{smart_count} phần tử đã được đưa trở lại vị trí ban đầu.", "cancelledWithRestoreErrors": "%{subject} đã được đưa trở lại vị trí ban đầu nhưng đã xảy ra lỗi khi khôi phục tệp từ thùng rác. |||| %{smart_count} phần tử đã được đưa trở lại vị trí ban đầu nhưng có %{restoreErrorsCount} lỗi khi khôi phục các tệp từ thùng rác.", "cancelled_error": "Rất tiếc, đã xảy ra lỗi khi đưa phần tử trở lại. |||| Rất tiếc, đã xảy ra lỗi khi đưa các phần tử trở lại.", "multipleEntries": "%{smart_count} phần tử |||| %{smart_count} phần tử", "addFolder": "Thêm thư mục", "outsideSharedFolder": { "title": "Di chuyển ra khỏi thư mục chia sẻ %{sharedFolder}", "content_1": "Cảnh báo, bạn đang muốn di chuyển %{name} ra khỏi thư mục chia sẻ %{sharedFolder}. |||| Cảnh báo, bạn đang muốn di chuyển %{smart_count} %{type} ra khỏi thư mục chia sẻ %{sharedFolder}.", "content_2": "Thao tác này sẽ xóa %{type} %{name} khỏi mục chia sẻ. %{type} này sẽ bị đưa vào thùng rác đối với tất cả thành viên được chia sẻ. |||| Thao tác này sẽ xóa %{smart_count} %{type} khỏi mục chia sẻ. Các %{type} này sẽ bị đưa vào thùng rác đối với tất cả thành viên được chia sẻ.", "cancel": "Hủy", "confirm": "Tôi hiểu" }, "insideSharedFolder": { "title": "Di chuyển vào thư mục chia sẻ?", "content": "Tất cả thành viên có quyền truy cập %{destination} cũng sẽ có quyền truy cập %{source}. |||| Tất cả thành viên có quyền truy cập %{destination} cũng sẽ có quyền truy cập các %{type} đã chọn.", "cancel": "Hủy", "confirm": "Đồng ý" }, "sharedFolderInsideAnother": { "title": "Không thể di chuyển", "content_1": "Bạn đang muốn di chuyển một phần tử được chia sẻ vào một thư mục chia sẻ khác. Loại thao tác này không được phép.", "content_2": "Nếu bạn vẫn muốn di chuyển %{source} đến %{destination}, vui lòng ngừng chia sẻ:", "cancel": "Hủy di chuyển", "confirm": "Ngừng chia sẻ" } }, "ImportToDrive": { "title": "%{smart_count} phần tử |||| %{smart_count} phần tử", "to": "Lưu tại:", "action": "Lưu", "cancel": "Hủy", "success": "%{smart_count} tệp đã được lưu |||| %{smart_count} tệp đã được lưu", "error": "Đã xảy ra lỗi. Vui lòng thử lại" }, "FileOpenerExternal": { "fileNotFoundError": "Lỗi: không tìm thấy tệp" }, "TOS": { "updated": { "title": "GDPR chính thức có hiệu lực!", "detail": "Trong bối cảnh Quy định bảo vệ dữ liệu chung, [Điều khoản dịch vụ của chúng tôi đã được cập nhật](%{link}) và sẽ áp dụng cho tất cả người dùng Twake từ ngày 25 tháng 5 năm 2018.", "cta": "Chấp nhận Điều khoản và tiếp tục", "disconnect": "Từ chối và đăng xuất", "error": "Đã xảy ra lỗi, vui lòng thử lại sau" } }, "manifest": { "permissions": { "contacts": { "description": "Cần thiết để chia sẻ tệp với danh bạ của bạn" }, "groups": { "description": "Cần thiết để chia sẻ tệp với nhóm của bạn" } } }, "models": { "contact": { "defaultDisplayName": "Ẩn danh" } }, "Scan": { "none": "Không có gì", "scan_a_doc": "Quét tài liệu", "save_doc": "Lưu tài liệu", "filename": "Tên tệp", "save": "Lưu", "cancel": "Hủy", "qualify": "Phân loại", "requalify": "Phân loại lại", "apply": "Áp dụng", "error": { "offline": "Bạn hiện đang ngoại tuyến và không thể sử dụng chức năng này. Vui lòng thử lại sau.", "uploading": "Bạn đang tải lên một tệp. Vui lòng đợi quá trình này hoàn tất rồi thử lại.", "generic": "Đã xảy ra lỗi. Vui lòng thử lại." }, "successful": { "qualified_ok": "Bạn đã phân loại thành công tệp của mình! " } }, "History": { "description": "20 phiên bản gần nhất của tệp sẽ được lưu tự động. Chọn một phiên bản để tải về.", "current_version": "Phiên bản hiện tại", "loading": "Đang tải...", "noFileVersionEnabled": "Twake của bạn sắp có thể lưu lại những thay đổi cuối cùng để bạn không bao giờ lo mất dữ liệu nữa" }, "External": { "redirection": { "title": "Chuyển hướng", "text": "Bạn sắp được chuyển hướng…", "error": "Lỗi trong quá trình chuyển hướng. Thông thường điều này có nghĩa là nội dung của tệp không đúng định dạng." } }, "RenameModal": { "title": "Đổi tên", "description": "Bạn sắp thay đổi phần mở rộng của tệp. Bạn có muốn tiếp tục không?", "continue": "Tiếp tục", "cancel": "Hủy" }, "Shortcut": { "title_modal": "Tạo phím tắt", "filename": "Tên tệp", "url": "URL", "cancel": "Hủy", "create": "Tạo", "created": "Phím tắt của bạn đã được tạo", "errored": "Đã xảy ra lỗi", "filename_error_ends": "Tên tệp phải kết thúc bằng .url", "needs_info": "Phím tắt cần ít nhất một URL và tên tệp", "url_badformat": "URL của bạn không đúng định dạng" }, "OnlyOffice": { "Error": { "title": "Đã xảy ra sự cố", "text": "Vui lòng tải lại trang" }, "readOnly": { "title": "Chỉ đọc", "tooltip": "Bạn chỉ có quyền xem tài liệu này. Hãy liên hệ với chủ sở hữu để được cấp quyền chỉnh sửa." }, "createFileName": { "text": "Tài liệu văn bản mới", "spreadsheet": "Bảng tính mới", "slide": "Bài thuyết trình mới" }, "toolbar": { "goToHome": "Về trang chính" }, "actions": { "edit": "Chỉnh sửa", "validate": "Xác nhận" }, "tooltip": { "title": "Chỉnh sửa tài liệu", "text": "Tài liệu hiện đang ở chế độ chỉ đọc. Bạn có thể chỉnh sửa bằng cách nhấp vào đây.", "actions": { "ok": "Đồng ý", "hide": "Không hiển thị nữa" } } }, "Migration": { "title": "Cập nhật Twake Drive", "content": "Twake Drive cần được cập nhật để cải thiện hiệu năng. Quá trình này có thể mất vài phút và trong thời gian đó bạn sẽ không thể sử dụng ứng dụng. Bạn có muốn cập nhật ngay bây giờ không? Nếu từ chối, chúng tôi sẽ nhắc lại vào lần sau.", "confirm": "Ok, tiến hành cập nhật!", "cancel": "Không, không phải bây giờ" }, "searchbar": { "placeholder": "Tìm kiếm bất kỳ thứ gì", "empty": "Không tìm thấy kết quả cho truy vấn “%{query}”" }, "button": { "back": "Quay lại", "add": "Thêm", "create": "Tạo mới" }, "search": { "action": "Tìm kiếm", "empty": { "title": "Không có kết quả", "subtitle": "Không tìm thấy kết quả nào cho truy vấn “%{query}”" } }, "PushBanner": { "quota": { "text": "Bạn sắp hết dung lượng lưu trữ. Nếu vượt quá giới hạn, bạn sẽ không thể thêm tệp mới. Hãy xóa tệp, dọn thùng rác hoặc nâng cấp gói của bạn.", "actions": { "first": "Tôi hiểu", "second": "Xem các gói dịch vụ" } } }, "FileDivergedModal": { "title": "Tệp đã bị chỉnh sửa bởi người khác", "content": "Ai đó đã chỉnh sửa tệp bên ngoài Twake trong khi bạn đang làm việc trên đó. Bạn có thể giữ phiên bản của họ hoặc tiếp tục chỉnh sửa bản sao mới.", "confirm": "Tiếp tục chỉnh sửa", "cancel": "Xem thay đổi", "error": "Đã xảy ra lỗi, vui lòng thử lại.", "confirmReload": { "title": "Xem thay đổi", "content": "Khi mở tệp mới, các thay đổi của bạn sẽ bị hủy.", "cancel": "Hủy", "confirm": "Ok, tôi hiểu" }, "viewMode": { "title": "Tệp đã bị chỉnh sửa bởi người khác", "content": "Tệp đã bị thay đổi nội dung. Bạn có thể xem những thay đổi này.", "confirm": "Xem thay đổi" } }, "FileDeletedModal": { "title": "Tệp đã bị xóa bởi người khác", "content": "Tệp đã bị xóa trong khi bạn đang chỉnh sửa. Bạn có thể dừng chỉnh sửa hoặc khôi phục tệp để tiếp tục.", "confirm": "Khôi phục tệp", "cancel": "Hủy thay đổi", "error": "Đã xảy ra lỗi, vui lòng thử lại." }, "TrashedBanner": { "text": "Mục này đang nằm trong thùng rác", "destroy": "Xóa vĩnh viễn", "restore": "Khôi phục", "restoreSuccess": "Mục đã được khôi phục", "restoreError": "Đã xảy ra lỗi, vui lòng thử lại.", "destroySuccess": "Mục đã được xóa vĩnh viễn" }, "MigrationProgressBanner": { "title": "Đang di chuyển dữ liệu từ Nextcloud", "percent": "Hoàn tất %{percent}%", "importing": "Đang nhập %{count} tệp từ Nextcloud...", "cancel": "Hủy", "done": { "title": "Di chuyển hoàn tất !", "body": "Đã nhập thành công %{count} tệp từ Nextcloud" } }, "EntriesType": { "file": "tệp |||| các tệp", "directory": "thư mục |||| các thư mục", "element": "mục |||| các mục" }, "NotFound": { "title": "Không tìm thấy mục", "text": "Không có nội dung nào tại địa chỉ này. Có thể bạn đã nhập sai liên kết." }, "NextcloudBreadcrumb": { "root": "Ổ đĩa được chia sẻ", "trash": "Thùng rác" }, "NextcloudToolbar": { "share": "Chia sẻ" }, "NextcloudDeleteConfirm": { "title": "Xóa %{filename}? |||| Xóa %{smart_count} %{type}?", "trash": "Mục này sẽ được chuyển vào thùng rác của Nextcloud. |||| Các mục này sẽ được chuyển vào thùng rác của Nextcloud.", "restore": "Bạn có thể khôi phục bất kỳ lúc nào từ Nextcloud.", "error": "Đã xảy ra lỗi, vui lòng thử lại.", "cancel": "Hủy", "delete": "Xóa" }, "FileName": { "sharedDrive": "Ổ đĩa", "trash": "Thùng rác" }, "NextcloudBanner": { "title": "Các mục bên dưới được hiển thị từ ổ đĩa NextCloud và không được lưu trữ trong Twake." }, "favorites": { "label": { "add": "Thêm vào mục yêu thích", "addMobile": "Yêu thích", "remove": "Xóa khỏi mục yêu thích" }, "error": "Đã xảy ra lỗi, vui lòng thử lại.", "success": { "add": "%{filename} đã được thêm vào mục yêu thích |||| Các mục đã được thêm vào mục yêu thích", "remove": "%{filename} đã được xóa khỏi mục yêu thích |||| Các mục đã được xóa khỏi mục yêu thích" } }, "TrashToolbar": { "emptyTrash": "Dọn thùng rác" }, "RestoreNextcloudFile": { "label": "Khôi phục", "success": "Mục đã được khôi phục", "error": "Đã xảy ra lỗi, vui lòng thử lại." }, "actions": { "details": "Chi tiết", "infos": "Chi tiết và phân loại", "infosMobile": "Chi tiết", "duplicateTo": { "label": "Nhân bản tới…" }, "duplicateToMobile": { "label": "Nhân bản" }, "personalizeFolder": { "label": "Cá nhân hóa thư mục" }, "summariseByAI": "Tóm tắt" }, "FolderCustomizer": { "title": "Cá nhân hóa thư mục", "description": "Chọn màu cụ thể cho thư mục của bạn", "cancel": "Hủy", "apply": "Áp dụng", "error": "Đã xảy ra lỗi, vui lòng thử lại.", "tabs": { "colors": "Màu sắc", "icons": "Biểu tượng" }, "iconPicker": { "recents": "Gần đây", "chooseCustomIcon": "Chọn biểu tượng tùy chỉnh" } }, "DuplicateModal": { "subTitle": "Nhân bản đến:", "confirmLabel": "Nhân bản tại đây", "success": "%{fileName} đã được nhân bản đến %{destinationName}. |||| %{smart_count} mục đã được nhân bản đến %{destinationName}.", "error": "Đã xảy ra lỗi, vui lòng thử lại." }, "OpenFolderButton": { "label": "Mở thư mục" }, "LastUpdate": { "titleFormat": "LLLL dd, yyyy, HH:MM" }, "AddMenu": { "readOnlyFolder": "Đây là thư mục chỉ đọc. Bạn không thể thực hiện hành động này." }, "PublicNoteRedirect": { "error": { "title": "Không thể truy cập tài liệu", "subtitle": "Liên kết chia sẻ bị thiếu hoặc không hợp lệ. Vui lòng yêu cầu chủ sở hữu tài liệu kiểm tra quyền truy cập" } } } ================================================ FILE: src/locales/zh_CN.json ================================================ { "Nav": { "item_drive": "硬盘", "item_recent": "最近更改", "item_sharings": "分享", "item_shared": "由我分享", "item_activity": "活动", "item_trash": "垃圾桶", "item_collect": "管理", "btn-client": "下载桌面版 Twake Drive", "btn-client-web": "获取 Twake", "banner-btn-client": "下载", "link-client": "https://cozy.io/en/download/", "link-client-desktop": "https://nuts.cozycloud.cc/download/channel/stable/", "link-client-android": "https://play.google.com/store/apps/details?id=io.cozy.drive.mobile", "link-client-ios": "https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8", "link-client-web": "https://cozy.io/try-it" }, "breadcrumb": { "title_drive": "硬盘", "title_recent": "最近更改", "title_sharings": "分享", "title_shared": "由我分享", "title_activity": "活动", "title_trash": "垃圾桶" }, "Toolbar": { "more": "更多" }, "toolbar": { "menu_upload": "上传文件", "item_more": "更多", "menu_new_folder": "文件夹", "menu_select": "选择项目", "menu_share_folder": "分析文件夹", "menu_download": "下载", "menu_sync_cozy": "同步到我的 Twake", "add_to_mine": "添加到我的 Twake", "menu_download_folder": "下载文件夹", "menu_download_file": "下载这个文件", "menu_create_note": "便签", "empty_trash": "清空垃圾桶", "share": "分享", "trash": "移除", "delete_shared_drive": "删除共享驱动器", "leave": "离开文件夹并删除", "menu_add": "添加", "menu_create": "创造", "menu_onlyOffice": { "text": "文本文件", "spreadsheet": "电子表格", "slide": "幻灯片" }, "select_all": "全选", "clear_selection": "清晰的选择", "sharings_tab_all": "全部", "sharings_tab_drives": "驱动器" }, "Share": { "create-cozy": "创建我的 Twake" }, "searchbar": { "placeholder": "搜索任何内容", "empty": "没有任何关于“%{query}”的内容" }, "search": { "empty": { "subtitle": "没有任何关于“%{query}”的内容" } }, "alert": { "item_copied": "1个项目已复制", "items_copied": "%{count}个项目已复制", "item_cut": "1个项目已剪切", "items_cut": "%{count}个项目已剪切", "item_moved": "1个项目已移动", "items_moved": "%{count}个项目已移动", "item_pasted": "1个项目已移动", "items_pasted": "%{count}个项目已移动", "copy_files_only": "无法复制文件夹", "copy_not_allowed": "此视图中不允许复制操作。", "cut_not_allowed": "此视图中不允许剪切操作。", "paste_error": "粘贴文件时出错", "paste_failed": "粘贴文件失败", "paste_sharing_error": "由于共享限制,无法粘贴文件。请改用移动操作。", "paste_same_folder_skipped": "无法将项目移动到它们已经所在的同一文件夹。", "paste_not_allowed": "您无法粘贴到此文件夹", "cannot_move_shared_drive": "您无法移动共享驱动器文件夹", "cannot_copy_shared_drive": "您无法复制共享云端硬盘文件夹" }, "actions": { "details": "详细信息", "personalizeFolder": { "label": "个性化文件夹" }, "summariseByAI": "总结" }, "FolderCustomizer": { "title": "个性化文件夹", "description": "为您的文件夹选择特定颜色", "cancel": "取消", "apply": "应用", "error": "发生错误,请重试。", "tabs": { "colors": "颜色", "icons": "图标" }, "iconPicker": { "recents": "最近使用", "chooseCustomIcon": "选择自定义图标" } } } ================================================ FILE: src/locales/zh_TW.json ================================================ { "Nav": { "item_drive": "硬碟", "item_recent": "最近更改", "item_sharings": "分享", "item_shared": "由我分享", "item_activity": "活動", "item_trash": "垃圾桶", "item_settings": "設定", "item_collect": "管理", "btn-client": "下載桌面版 Twake Drive", "btn-client-web": "取得 Twake", "btn-client-mobile": "在您的手機上下載 %{name}", "banner-txt-client": "下載桌面版 %{name} 來安全地同步您的檔案並隨時存取它們", "banner-btn-client": "下載" }, "breadcrumb": { "title_drive": "硬碟", "title_recent": "最近更改", "title_sharings": "分享", "title_shared": "由我分享", "title_activity": "活動", "title_trash": "垃圾桶" }, "Toolbar": { "more": "更多" }, "toolbar": { "item_more": "更多", "menu_select": "選擇項目", "menu_download_folder": "下載資料夾", "menu_download_file": "下載這個檔案", "empty_trash": "清空垃圾桶", "share": "分享", "trash": "移除", "delete_shared_drive": "刪除共用磁碟機", "leave": "離開分享資料夾並刪除", "select_all": "全選", "sharings_tab_all": "全部", "sharings_tab_drives": "磁碟機" }, "Share": { "create-cozy": "建立我的 Twake" }, "Files": { "share": { "cta": "分享", "title": "分享", "details": { "title": "分享的詳細資料", "createdAt": "在 %{date}", "ro": "可以檢視", "rw": "可以修改" }, "sharedByMe": "由我分享", "sharedWithMe": "與我分享", "sharedBy": "由 %{name} 分享", "shareByLink": { "subtitle": "由公開連結", "desc": "任何人擁有這個連結可以檢視和下載您的檔案。", "creating": "正在建立您的連結...", "copy": "複製連結", "copied": "連結已經複製到剪切版", "failed": "無法複製到剪切版" }, "shareByEmail": { "subtitle": "由電郵", "email": "收件人:", "emailPlaceholder": "輸入收件人的電郵地址或名稱", "send": "傳送", "genericSuccess": "您傳送了邀請函給 %{count} 個聯絡人。", "success": "您傳送了邀請函至 %{email}", "type": { "file": "檔案", "folder": "資料夾" } }, "sharingLink": { "copy": "複製", "copied": "已複製" }, "protectedShare": { "title": "即將推出" }, "close": "關閉", "gettingLink": "正在取得您的連結..." } }, "table": { "head_name": "名稱", "head_update": "最後更新", "head_size": "大小", "row_read_only": "分享(唯讀模式)", "row_read_write": "分享(讀寫模式)", "load_more": "載入更多", "mobile": { "head_updated_at_asc": "最舊優先", "head_updated_at_desc": "最新優先", "head_size_asc": "最小優先", "head_size_desc": "最大優先" } }, "Storage": { "title": "儲存空間", "availability": "可用 %{smart_count} GB", "increase": "增加您的空間" }, "SelectionBar": { "share": "分享", "download": "下載", "trash": "移除", "destroy": "永久刪除", "rename": "重新命名", "restore": "還原", "close": "關閉", "moveto": "移動到...", "moveto_mobile": "移動到", "phone-download": "使離線可用" }, "DeleteConfirm": { "cancel": "取消", "delete": "移除" }, "emptytrashconfirmation": { "title": "永久刪除嗎?", "cancel": "取消", "delete": "全部刪除" }, "DestroyConfirm": { "title": "永久刪除嗎?", "cancel": "取消", "delete": "永久刪除" }, "quotaalert": { "title": "您的硬碟已滿 :(", "confirm": "OK" }, "loading": { "message": "載入中" }, "empty": { "title": "您在此資料夾中沒有任何檔案。", "trash_title": "您沒有任何已刪除的檔案。" }, "error": { "button": { "reload": "現在重新整理" }, "download_file": { "offline": "您需要連線才能下載此檔案", "missing": "此檔案已遺失" } }, "Error": { "public_unshared_title": "抱歉,但此連結已不可用。" }, "alert": { "could_not_open_file": "此檔案無法開啟", "item_copied": "1個項目已複製", "items_copied": "%{count}個項目已複製", "item_cut": "1個項目已剪下", "items_cut": "%{count}個項目已剪下", "item_moved": "1個項目已移動", "items_moved": "%{count}個項目已移動", "item_pasted": "1個項目已移動", "items_pasted": "%{count}個項目已移動", "copy_files_only": "無法複製資料夾", "copy_not_allowed": "此檢視中不允許複製操作。", "cut_not_allowed": "此檢視中不允許剪下操作。", "paste_error": "貼上檔案時發生錯誤", "paste_failed": "貼上檔案失敗", "paste_sharing_error": "由於共享限制,無法貼上檔案。請改用移動操作。", "paste_same_folder_skipped": "無法將項目移動到它們已經所在的同一資料夾。", "paste_not_allowed": "您無法貼上到此資料夾", "cannot_move_shared_drive": "您無法移動共享磁碟機資料夾", "cannot_copy_shared_drive": "您無法複製共用雲端硬碟資料夾" }, "actions": { "details": "詳細資訊", "personalizeFolder": { "label": "個性化資料夾" }, "summariseByAI": "總結" }, "FolderCustomizer": { "title": "個性化資料夾", "description": "為您的資料夾選擇特定顏色", "cancel": "取消", "apply": "套用", "error": "發生錯誤,請重試。", "tabs": { "colors": "顏色", "icons": "圖示" }, "iconPicker": { "recents": "最近使用", "chooseCustomIcon": "選擇自訂圖示" } } } ================================================ FILE: src/models/Contact.js ================================================ import { getInitials, getDisplayName } from 'cozy-client/dist/models/contact' import { Contact as DoctypeContact } from 'cozy-doctypes' class Contact extends DoctypeContact { static getInitials(contactOrRecipient, defaultValue = '') { if (Contact.isContact(contactOrRecipient)) { return getInitials(contactOrRecipient) } else { const s = contactOrRecipient.public_name || contactOrRecipient.name || contactOrRecipient.email return (s && s[0].toUpperCase()) || defaultValue } } static getDisplayName(contact, defaultValue = '') { if (Contact.isContact(contact)) { return getDisplayName(contact) } else { return ( contact.public_name || contact.name || contact.email || defaultValue ) } } } export default Contact ================================================ FILE: src/models/Contact.spec.js ================================================ import Contact from '@/models/Contact' describe('Contact model', () => { describe('getInitials method', () => { it('should return the first letter of public_name if it is an owner recipient', () => { const recipient = { name: 'whatever', public_name: 'janedoe' } const result = Contact.getInitials(recipient) expect(result).toEqual('J') }) it('should return the first letter of name if it is a recipient', () => { const recipient = { name: 'janedoe' } const result = Contact.getInitials(recipient) expect(result).toEqual('J') }) it('should return the first letter of email if it is a recipient and name/public_name are not defined', () => { const recipient = { name: undefined, public_name: undefined, email: 'janedoe@example.com' } const result = Contact.getInitials(recipient) expect(result).toEqual('J') }) it('should return an empty string if name/public_name are undefined', () => { const recipient = {} const result = Contact.getInitials(recipient) expect(result).toEqual('') }) it('should use a default value if name/public_name are undefined', () => { const recipient = {} const result = Contact.getInitials(recipient, 'A') expect(result).toEqual('A') }) it('should use the original implementation if a contact is given', () => { const contact = { _id: '46b5d129-0296-4466-8c02-9a6a0c17c4cb', _type: 'io.cozy.contacts', name: { givenName: 'Arya', familyName: 'Stark' } } const result = Contact.getInitials(contact) expect(result).toEqual('AS') }) }) describe('getDisplayName method', () => { it('should use the original implementation if a contact is given', () => { const contact = { _id: '46b5d129-0296-4466-8c02-9a6a0c17c4cb', _type: 'io.cozy.contacts', fullname: 'Arya Stark', name: { givenName: 'Arya', familyName: 'Stark' } } const result = Contact.getDisplayName(contact) expect(result).toEqual('Arya Stark') }) it('should use public_name if available', () => { const contact = { email: 'arya@winterfell.westeros', name: 'Arya Stark', public_name: 'aryastark' } const result = Contact.getDisplayName(contact) expect(result).toEqual('aryastark') }) it('should use name if a recipient is given', () => { const contact = { name: 'Arya Stark' } const result = Contact.getDisplayName(contact) expect(result).toEqual('Arya Stark') }) it('should use email if a recipient is given', () => { const recipient = { email: 'arya.stark@winterfell.westeros' } const result = Contact.getDisplayName(recipient) expect(result).toEqual('arya.stark@winterfell.westeros') }) it('should use an empty string as default value if nothing is available', () => { const recipient = {} const result = Contact.getDisplayName(recipient) expect(result).toEqual('') }) it('should use a default value if nothing is available', () => { const recipient = {} const result = Contact.getDisplayName(recipient, 'Anonymous') expect(result).toEqual('Anonymous') }) }) }) ================================================ FILE: src/models/index.js ================================================ export { CozyFile } from 'cozy-doctypes' export { Group } from 'cozy-doctypes' export { default as Contact } from '@/models/Contact' ================================================ FILE: src/modules/actionmenu/ActionMenuWithHeader.jsx ================================================ import React from 'react' import { isDirectory } from 'cozy-client/dist/models/file' import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu' import ActionsMenuMobileHeader from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuMobileHeader' import Icon from 'cozy-ui/transpiled/react/Icon' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import styles from '@/styles/actionmenu.styl' import getMimeTypeIcon from '@/lib/getMimeTypeIcon' import { CozyFile } from '@/models' export const ActionMenuWithHeader = ({ file, actions, onClose, anchorElRef }) => { return ( ) } const MenuHeaderFile = ({ file }) => { const { filename, extension } = CozyFile.splitFilename(file) return ( <> {filename} {extension} } primaryTypographyProps={{ variant: 'h6' }} /> ) } ================================================ FILE: src/modules/actions/addItems.jsx ================================================ import React, { forwardRef, useContext } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { const addMenuCtx = useContext(AddMenuContext) const { a11y } = addMenuCtx return ( props.onClick(addMenuCtx)} ref={ref} {...a11y} > ) }) Component.displayName = 'AddItems' return Component } export const addItems = ({ t, hasWriteAccess }) => { const label = t('toolbar.menu_add_item') const icon = PlusIcon return { name: 'addItems', label, icon, displayCondition: () => hasWriteAccess, action: (_, { isOffline, handleOfflineClick, handleToggle }) => { return isOffline ? handleOfflineClick() : handleToggle() }, Component: makeComponent(label, icon) } } ================================================ FILE: src/modules/actions/components/addToFavorites.tsx ================================================ import React, { forwardRef } from 'react' import { splitFilename } from 'cozy-client/dist/models/file' import CozyClient from 'cozy-client/types/CozyClient' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import StarOutlineIcon from 'cozy-ui/transpiled/react/Icons/StarOutline' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import type { ActionWithPolicy } from '../types' interface addToFavoritesProps { t: (key: string, options?: Record) => string client: CozyClient isMobile: boolean showAlert: import('cozy-ui/transpiled/react/providers/Alert').showAlertFunction } const addToFavorites = ({ t, client, isMobile, showAlert }: addToFavoritesProps): ActionWithPolicy => { const icon = StarOutlineIcon const label = isMobile ? t('favorites.label.addMobile') : t('favorites.label.add') return { name: 'addToFavourites', label, icon, allowInfectedFiles: false, displayCondition: docs => docs.length > 0 && docs.every(doc => !doc.cozyMetadata?.favorite) && !docs[0]?.driveId, action: async (files): Promise => { try { for (const file of files) { await client.save({ ...file, cozyMetadata: { ...file.cozyMetadata, favorite: true } }) } const { filename } = splitFilename(files[0]) showAlert({ message: t('favorites.success.add', { filename, smart_count: files.length }), severity: 'success' }) } catch (_error) { showAlert({ message: t('favorites.error'), severity: 'error' }) } }, Component: forwardRef(function AddToFavorites(props, ref) { return ( ) }) } } export { addToFavorites } ================================================ FILE: src/modules/actions/components/duplicateTo.tsx ================================================ import React, { forwardRef } from 'react' import { isFile } from 'cozy-client/dist/models/file' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import MultiFilesIcon from 'cozy-ui/transpiled/react/Icons/MultiFiles' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { navigateToModalWithMultipleFile } from '../helpers' import type { ActionWithPolicy } from '../types' interface duplicateToProps { t: (key: string, options?: Record) => string navigate: (to: string) => void pathname: string isMobile: boolean search?: string canDuplicate?: boolean } const duplicateTo = ({ t, pathname, navigate, isMobile, search, canDuplicate = true }: duplicateToProps): ActionWithPolicy => { const icon = MultiFilesIcon const label = isMobile ? t('actions.duplicateToMobile.label') : t('actions.duplicateTo.label') return { name: 'duplicateTo', label, icon, allowInfectedFiles: false, displayCondition: docs => docs.length === 1 && isFile(docs[0]) && canDuplicate, action: (files): void => { navigateToModalWithMultipleFile({ files, pathname, navigate, path: 'duplicate', search }) }, Component: forwardRef(function DuplicateTo(props, ref) { return ( ) }) } } export { duplicateTo } ================================================ FILE: src/modules/actions/components/moveTo.jsx ================================================ import React, { forwardRef } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import MovetoIcon from 'cozy-ui/transpiled/react/Icons/Moveto' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { navigateToModalWithMultipleFile } from '@/modules/actions/helpers' import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers' const moveTo = ({ t, canMove, pathname, navigate, isMobile, search, shouldHideIfSharedDriveRecipient }) => { const icon = MovetoIcon const label = isMobile ? t('SelectionBar.moveto_mobile') : t('SelectionBar.moveto') return { name: 'moveTo', label, icon, allowInfectedFiles: false, displayCondition: docs => { // special case for rename in sharings tab const isAllowedForSharedDrive = shouldHideIfSharedDriveRecipient ? docs.every(doc => !isFromSharedDriveRecipient(doc)) : true return docs.length > 0 && canMove && isAllowedForSharedDrive }, action: async files => { navigateToModalWithMultipleFile({ files, pathname, navigate, path: 'move', search }) }, Component: forwardRef(function MoveTo(props, ref) { return ( ) }) } } export { moveTo } ================================================ FILE: src/modules/actions/components/personalizeFolder.js ================================================ import React from 'react' import { makeAction } from 'cozy-ui/transpiled/react/ActionsMenu/Actions/makeAction' import PaletteIcon from 'cozy-ui/transpiled/react/Icons/Palette' import { FolderCustomizerModal } from '../../views/Folder/FolderCustomizer' const personalizeFolder = ({ t, pushModal, popModal, driveId, hasWriteAccess, onClose }) => { const icon = PaletteIcon const label = t('actions.personalizeFolder.label') return makeAction({ name: 'personalizeFolder', label, icon, displayCondition: docs => hasWriteAccess && docs.length === 1 && docs[0].type === 'directory' && !driveId, action: docs => { if (docs.length === 1 && docs[0].type === 'directory') { const folderId = docs[0]._id pushModal( { popModal() onClose?.() }} /> ) } } }) } export { personalizeFolder } ================================================ FILE: src/modules/actions/components/removeFromFavorites.tsx ================================================ import React, { forwardRef } from 'react' import { splitFilename } from 'cozy-client/dist/models/file' import CozyClient from 'cozy-client/types/CozyClient' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import StarIcon from 'cozy-ui/transpiled/react/Icons/Star' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import type { ActionWithPolicy } from '../types' interface removeFromFavoritesProps { t: (key: string, options?: Record) => string client: CozyClient showAlert: import('cozy-ui/transpiled/react/providers/Alert').showAlertFunction } const removeFromFavorites = ({ t, client, showAlert }: removeFromFavoritesProps): ActionWithPolicy => { const label = t('favorites.label.remove') const icon = StarIcon return { name: 'removeFromFavorites', label, icon, allowInfectedFiles: false, displayCondition: docs => docs.length > 0 && docs.every(doc => doc.cozyMetadata?.favorite) && !docs[0]?.driveId, action: async (files): Promise => { try { for (const file of files) { await client.save({ ...file, cozyMetadata: { ...file.cozyMetadata, favorite: false } }) } const { filename } = splitFilename(files[0]) showAlert({ message: t('favorites.success.remove', { filename, smart_count: files.length }), severity: 'success' }) } catch (_error) { showAlert({ message: t('favorites.error'), severity: 'error' }) } }, Component: forwardRef(function RemoveFromFavorites(props, ref) { return ( ) }) } } export { removeFromFavorites } ================================================ FILE: src/modules/actions/components/selectable.tsx ================================================ import React, { forwardRef } from 'react' import { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' interface selectableProps { t: (key: string, options?: Record) => string showSelectionBar: () => void } export const selectable = ({ t, showSelectionBar }: selectableProps): Action => { const label = t('toolbar.menu_select') const icon = CheckSquareIcon return { name: 'selectable', label, icon, action: (): void => { showSelectionBar() }, Component: forwardRef(function Selectable(props, ref) { return ( ) }) } } ================================================ FILE: src/modules/actions/details.jsx ================================================ import React, { forwardRef } from 'react' import flag from 'cozy-flags' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import InfoOutlinedIcon from 'cozy-ui/transpiled/react/Icons/InfoOutlined' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { return ( ) }) Component.displayName = 'details' return Component } export const details = ({ t, navigate, location }) => { const icon = InfoOutlinedIcon const label = t('actions.details') return { name: 'details', icon, label, allowInfectedFiles: false, displayCondition: () => flag('drive.new-file-viewer-ui.enabled'), Component: makeComponent(label, icon), action: () => { navigate(location.pathname, { replace: true, state: { ...location.state, triggerDetailPanelTime: (location?.state?.triggerDetailPanelTime || 0) + 1 } }) } } } ================================================ FILE: src/modules/actions/divider.jsx ================================================ import React, { forwardRef } from 'react' import Divider from 'cozy-ui/transpiled/react/Divider' export const hr = () => { return { name: 'hr', icon: 'hr', displayInSelectionBar: false, Component: forwardRef(function hr(_, ref) { return }) } } ================================================ FILE: src/modules/actions/download.jsx ================================================ import React, { forwardRef } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { downloadFiles } from './utils' import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { return ( ) }) Component.displayName = 'Download' return Component } export const download = ({ client, t, showAlert, shouldHideIfSharedDriveRecipient, isSelectAll, displayedFolder }) => { const label = t('SelectionBar.download') const icon = DownloadIcon return { name: 'download', label, icon, allowInfectedFiles: false, displayCondition: files => { // For sharing tab where we can see multiple shared folders as // recipient, disable download because we cannot download different // shared folders at the same time. if ( shouldHideIfSharedDriveRecipient && files.length > 1 && files.some(file => isFromSharedDriveRecipient(file)) ) { return false } return files.length > 0 }, action: files => { let selectedFiles = files if (isSelectAll) { selectedFiles = [displayedFolder] } return downloadFiles(client, selectedFiles, { showAlert, t }) }, Component: makeComponent(label, icon) } } ================================================ FILE: src/modules/actions/helpers.js ================================================ import { joinPath } from '@/lib/path' export const navigateToModal = ({ navigate, pathname, files, path }) => { const file = Array.isArray(files) ? files[0] : files return navigate( pathname ? joinPath(pathname, `file/${file.id}/${path}`) : `v/${path}` ) } export const navigateToModalWithMultipleFile = ({ navigate, pathname, files, path, search }) => { return navigate( { pathname: pathname ? joinPath(pathname, path) : `v/${path}`, search: search ? `?${search}` : '' }, { state: { fileIds: files.map(file => file.id) } } ) } /** * Returns the context menu visible actions * * @param {Object[]} actions - the list of actions * @returns {Object[]} - the list of actions to be displayed */ export const getContextMenuActions = (actions = []) => actions.filter( action => Object.values(action)[0]?.displayInContextMenu !== false ) ================================================ FILE: src/modules/actions/helpers.spec.js ================================================ import { navigateToModal, navigateToModalWithMultipleFile, getContextMenuActions } from './helpers' jest.mock('@/lib/path', () => ({ joinPath: jest.fn((...paths) => paths.join('/')) })) describe('actions helpers', () => { describe('navigateToModal', () => { let mockNavigate beforeEach(() => { mockNavigate = jest.fn() }) afterEach(() => { jest.clearAllMocks() }) it('should navigate to modal with pathname and single file', () => { const params = { navigate: mockNavigate, pathname: '/folder/123', files: { id: 'file-123', name: 'test.pdf' }, path: 'preview' } navigateToModal(params) expect(mockNavigate).toHaveBeenCalledWith( '/folder/123/file/file-123/preview' ) }) it('should navigate to modal with pathname and array of files', () => { const params = { navigate: mockNavigate, pathname: '/folder/456', files: [ { id: 'file-1', name: 'first.pdf' }, { id: 'file-2', name: 'second.pdf' } ], path: 'edit' } navigateToModal(params) expect(mockNavigate).toHaveBeenCalledWith('/folder/456/file/file-1/edit') }) }) describe('navigateToModalWithMultipleFile', () => { let mockNavigate beforeEach(() => { mockNavigate = jest.fn() }) afterEach(() => { jest.clearAllMocks() }) it('should navigate with pathname, multiple files, and search params', () => { const params = { navigate: mockNavigate, pathname: '/folder/123', files: [ { id: 'file-1', name: 'doc1.pdf' }, { id: 'file-2', name: 'doc2.pdf' }, { id: 'file-3', name: 'doc3.pdf' } ], path: 'share', search: 'tab=link' } navigateToModalWithMultipleFile(params) expect(mockNavigate).toHaveBeenCalledWith( { pathname: '/folder/123/share', search: '?tab=link' }, { state: { fileIds: ['file-1', 'file-2', 'file-3'] } } ) }) it('should navigate with pathname and multiple files without search params', () => { const params = { navigate: mockNavigate, pathname: '/recent', files: [ { id: 'file-a', name: 'image1.jpg' }, { id: 'file-b', name: 'image2.jpg' } ], path: 'move' } navigateToModalWithMultipleFile(params) expect(mockNavigate).toHaveBeenCalledWith( { pathname: '/recent/move', search: '' }, { state: { fileIds: ['file-a', 'file-b'] } } ) }) it('should handle empty search parameter', () => { const params = { navigate: mockNavigate, pathname: '/folder/456', files: [ { id: 'file-1', name: 'test1.pdf' }, { id: 'file-2', name: 'test2.pdf' } ], path: 'delete', search: '' } navigateToModalWithMultipleFile(params) expect(mockNavigate).toHaveBeenCalledWith( { pathname: '/folder/456/delete', search: '' }, { state: { fileIds: ['file-1', 'file-2'] } } ) }) }) describe('getContextMenuActions', () => { it('should return all actions when all have displayInContextMenu !== false', () => { const actions = [ { download: { displayInContextMenu: true, name: 'Download' } }, { share: { name: 'Share' } }, // undefined displayInContextMenu should be included { rename: { displayInContextMenu: undefined, name: 'Rename' } } ] const result = getContextMenuActions(actions) expect(result).toEqual(actions) expect(result).toHaveLength(3) }) it('should filter out actions with displayInContextMenu: false', () => { const actions = [ { download: { displayInContextMenu: true, name: 'Download' } }, { share: { displayInContextMenu: false, name: 'Share' } }, { rename: { name: 'Rename' } }, { delete: { displayInContextMenu: false, name: 'Delete' } } ] const result = getContextMenuActions(actions) expect(result).toEqual([ { download: { displayInContextMenu: true, name: 'Download' } }, { rename: { name: 'Rename' } } ]) expect(result).toHaveLength(2) }) }) }) ================================================ FILE: src/modules/actions/index.js ================================================ export { share } from './share' export { download } from './download' export { hr } from './divider' export { trash } from './trash' export { rename } from './rename' export { qualify } from './qualify' export { versions } from './versions' export { restore } from './restore' export { select } from './select' export { infos } from './infos' export { addItems } from './addItems' export { selectAllItems } from './selectAll' export { summariseByAI } from './summariseByAI' export { filterActionsByPolicy, hasAnyInfectedFile } from './policies' ================================================ FILE: src/modules/actions/index.spec.js ================================================ import { download } from './index' describe('download', () => { it('should display for a single file', () => { const files = [{ type: 'file' }] const dl = download({ client: {}, t: () => {} }) expect(dl.displayCondition(files)).toBe(true) }) it('should display for a folder', () => { const files = [{ type: 'directory' }] const dl = download({ client: {}, t: () => {} }) expect(dl.displayCondition(files)).toBe(true) }) it('should display for a mixed selection', () => { const files = [{ type: 'file' }, { type: 'directory' }] const dl = download({ client: {}, t: () => {} }) expect(dl.displayCondition(files)).toBe(true) }) it('should not display for an empty selection', () => { const dl = download({ client: {}, t: () => {} }) expect(dl.displayCondition([])).toBe(false) }) }) ================================================ FILE: src/modules/actions/infos.jsx ================================================ import React, { forwardRef } from 'react' import { isFile } from 'cozy-client/dist/models/file' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import InfoIcon from 'cozy-ui/transpiled/react/Icons/Info' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { return ( ) }) Component.displayName = 'infos' return Component } export const infos = ({ t, isMobile, navigate }) => { const icon = InfoIcon const label = isMobile ? t('actions.infosMobile') : t('actions.infos') return { name: 'infos', icon, label, displayCondition: docs => docs.length <= 1 && isFile(docs[0]), Component: makeComponent(label, icon), action: docs => { navigate(`file/${docs[0]._id}`) } } } ================================================ FILE: src/modules/actions/policies.spec.ts ================================================ import type { IOCozyFile } from 'cozy-client/types/types' import { filterActionsByPolicy, hasAnyInfectedFile, buildPolicyContext, ACTION_POLICIES } from './policies' import type { DriveAction, ActionPolicyContext } from './types' // Mock cozy-client isDirectory jest.mock('cozy-client/dist/models/file', () => ({ isDirectory: jest.fn((file: { type?: string }) => file.type === 'directory') })) describe('policies', () => { // Helper to create a wrapped action (as returned by makeActions) const createWrappedAction = ( name: string, options: Partial = {} ): Record => ({ [name]: { name, ...options } }) // Helper to create a mock file const createMockFile = ( id: string, options: { infected?: boolean trashed?: boolean type?: 'file' | 'directory' pending?: boolean } = {} ): Partial => ({ _id: id, type: options.type ?? 'file', trashed: options.trashed ?? false, ...(options.infected && { antivirus_scan: { status: 'infected' } }), ...(options.pending && { antivirus_scan: { status: 'pending' } }) }) describe('buildPolicyContext', () => { it('should detect infected files', () => { const files = [ createMockFile('file1', { infected: true }), createMockFile('file2') ] as IOCozyFile[] const ctx = buildPolicyContext(files) expect(ctx.hasInfectedFile).toBe(true) }) it('should detect multiple files', () => { const files = [ createMockFile('file1'), createMockFile('file2') ] as IOCozyFile[] const ctx = buildPolicyContext(files) expect(ctx.hasMultipleFiles).toBe(true) }) it('should detect folders', () => { const files = [ createMockFile('folder1', { type: 'directory' }) ] as IOCozyFile[] const ctx = buildPolicyContext(files) expect(ctx.hasFolder).toBe(true) }) it('should detect all trashed files', () => { const files = [ createMockFile('file1', { trashed: true }), createMockFile('file2', { trashed: true }) ] as IOCozyFile[] const ctx = buildPolicyContext(files) expect(ctx.allInTrash).toBe(true) }) it('should not mark allInTrash if some files are not trashed', () => { const files = [ createMockFile('file1', { trashed: true }), createMockFile('file2', { trashed: false }) ] as IOCozyFile[] const ctx = buildPolicyContext(files) expect(ctx.allInTrash).toBe(false) }) }) describe('ACTION_POLICIES', () => { it('should have all expected policies registered', () => { const policyNames = ACTION_POLICIES.map(p => p.name) expect(policyNames).toContain('infection') expect(policyNames).toContain('notScanned') expect(policyNames).toContain('multipleFiles') expect(policyNames).toContain('folders') expect(policyNames).toContain('trashed') }) describe('infection policy', () => { const infectionPolicy = ACTION_POLICIES.find(p => p.name === 'infection') if (!infectionPolicy) { throw new Error('infection policy not found') } it('should allow action when no infected files', () => { const action = { allowInfectedFiles: false } const ctx = { hasInfectedFile: false } as ActionPolicyContext expect(infectionPolicy.allows(action, ctx)).toBe(true) }) it('should block action when infected files and not allowed', () => { const action = { allowInfectedFiles: false } const ctx = { hasInfectedFile: true } as ActionPolicyContext expect(infectionPolicy.allows(action, ctx)).toBe(false) }) it('should allow action when infected files and explicitly allowed', () => { const action = { allowInfectedFiles: true } const ctx = { hasInfectedFile: true } as ActionPolicyContext expect(infectionPolicy.allows(action, ctx)).toBe(true) }) }) describe('notScanned policy', () => { const notScannedPolicy = ACTION_POLICIES.find( p => p.name === 'notScanned' ) if (!notScannedPolicy) { throw new Error('notScanned policy not found') } it('should allow action when no pending files', () => { const action = { allowNotScannedFiles: false } const ctx = { hasNotScannedFile: false } as ActionPolicyContext expect(notScannedPolicy.allows(action, ctx)).toBe(true) }) it('should block action when pending files and not allowed', () => { const action = { allowNotScannedFiles: false } const ctx = { hasNotScannedFile: true } as ActionPolicyContext expect(notScannedPolicy.allows(action, ctx)).toBe(false) }) it('should allow action when pending files and explicitly allowed', () => { const action = { allowNotScannedFiles: true } const ctx = { hasNotScannedFile: true } as ActionPolicyContext expect(notScannedPolicy.allows(action, ctx)).toBe(true) }) }) describe('multipleFiles policy', () => { const multipleFilesPolicy = ACTION_POLICIES.find( p => p.name === 'multipleFiles' ) if (!multipleFilesPolicy) { throw new Error('multipleFiles policy not found in ACTION_POLICIES') } it('should allow action for single file by default', () => { const action = {} const ctx = { hasMultipleFiles: false } as ActionPolicyContext expect(multipleFilesPolicy.allows(action, ctx)).toBe(true) }) it('should allow action for multiple files by default', () => { const action = {} const ctx = { hasMultipleFiles: true } as ActionPolicyContext expect(multipleFilesPolicy.allows(action, ctx)).toBe(true) }) it('should block action for multiple files when explicitly disallowed', () => { const action = { allowMultiple: false } const ctx = { hasMultipleFiles: true } as ActionPolicyContext expect(multipleFilesPolicy.allows(action, ctx)).toBe(false) }) }) describe('trashed policy', () => { const trashedPolicy = ACTION_POLICIES.find(p => p.name === 'trashed') if (!trashedPolicy) { throw new Error('trashedPolicy not found in ACTION_POLICIES') } it('should allow action when files not in trash', () => { const action = {} const ctx = { allInTrash: false } as ActionPolicyContext expect(trashedPolicy.allows(action, ctx)).toBe(true) }) it('should block action when files in trash and not allowed', () => { const action = {} const ctx = { allInTrash: true } as ActionPolicyContext expect(trashedPolicy.allows(action, ctx)).toBe(false) }) it('should allow action when files in trash and explicitly allowed', () => { const action = { allowTrashed: true } const ctx = { allInTrash: true } as ActionPolicyContext expect(trashedPolicy.allows(action, ctx)).toBe(true) }) }) }) describe('filterActionsByPolicy', () => { it('should return all actions when no policy restrictions apply', () => { const actions = [ createWrappedAction('download'), createWrappedAction('share'), createWrappedAction('trash') ] const files = [createMockFile('file1')] as IOCozyFile[] const result = filterActionsByPolicy(actions, files) expect(result).toHaveLength(3) }) it('should filter out actions blocked by infection policy', () => { const actions = [ createWrappedAction('download', { allowInfectedFiles: false }), createWrappedAction('share', { allowInfectedFiles: false }), createWrappedAction('trash', { allowInfectedFiles: true }) ] const files = [ createMockFile('file1', { infected: true }) ] as IOCozyFile[] const result = filterActionsByPolicy(actions, files) expect(result).toHaveLength(1) expect(Object.keys(result[0])[0]).toBe('trash') }) it('should filter out actions blocked by multiple files policy', () => { const actions = [ createWrappedAction('download'), createWrappedAction('rename', { allowMultiple: false }), createWrappedAction('trash') ] const files = [ createMockFile('file1'), createMockFile('file2') ] as IOCozyFile[] const result = filterActionsByPolicy(actions, files) expect(result).toHaveLength(2) expect(result.map(a => Object.keys(a)[0])).toEqual(['download', 'trash']) }) it('should filter out actions blocked by trashed policy', () => { const actions = [ createWrappedAction('download'), createWrappedAction('restore', { allowTrashed: true }), createWrappedAction('share') ] const files = [createMockFile('file1', { trashed: true })] as IOCozyFile[] const result = filterActionsByPolicy(actions, files) expect(result).toHaveLength(1) expect(Object.keys(result[0])[0]).toBe('restore') }) it('should handle empty actions array', () => { const actions: Record[] = [] const files = [ createMockFile('file1', { infected: true }) ] as IOCozyFile[] const result = filterActionsByPolicy(actions, files) expect(result).toHaveLength(0) }) it('should handle empty files array', () => { const actions = [ createWrappedAction('download'), createWrappedAction('trash') ] const files: IOCozyFile[] = [] const result = filterActionsByPolicy(actions, files) expect(result).toHaveLength(2) }) it('should allow empty action wrappers (fail-open behavior)', () => { // Test that empty wrappers are allowed through the filter // This verifies the contract that getActionFromWrapper can return null // and isActionAllowedByPolicies is not called for such cases const actions = [ createWrappedAction('download'), {} as Record, // Empty wrapper createWrappedAction('trash') ] const files = [createMockFile('file1')] as IOCozyFile[] const result = filterActionsByPolicy(actions, files) // Empty wrapper should be included in results (fail-open) expect(result).toHaveLength(3) expect(result[1]).toEqual({}) }) it('should apply multiple policies together', () => { const actions = [ createWrappedAction('download', { allowInfectedFiles: false }), createWrappedAction('rename', { allowInfectedFiles: true, allowMultiple: false }), createWrappedAction('trash', { allowInfectedFiles: true }) ] // Multiple infected files const files = [ createMockFile('file1', { infected: true }), createMockFile('file2', { infected: true }) ] as IOCozyFile[] const result = filterActionsByPolicy(actions, files) // download blocked by infection, rename blocked by multiple files expect(result).toHaveLength(1) expect(Object.keys(result[0])[0]).toBe('trash') }) }) describe('hasAnyInfectedFile', () => { it('should return false when no files are infected', () => { const files = [ createMockFile('file1'), createMockFile('file2') ] as IOCozyFile[] const result = hasAnyInfectedFile(files) expect(result).toBe(false) }) it('should return true when at least one file is infected', () => { const files = [ createMockFile('file1', { infected: true }), createMockFile('file2') ] as IOCozyFile[] const result = hasAnyInfectedFile(files) expect(result).toBe(true) }) it('should return false for empty array', () => { const files: IOCozyFile[] = [] const result = hasAnyInfectedFile(files) expect(result).toBe(false) }) }) }) ================================================ FILE: src/modules/actions/policies.ts ================================================ import { isDirectory } from 'cozy-client/dist/models/file' import type { IOCozyFile } from 'cozy-client/types/types' import flag from 'cozy-flags' import type { ActionPolicyContext, ActionPolicyDefinition, DriveAction, DriveActionPolicyFlags } from './types' import { isInfected, isNotScanned } from '@/modules/filelist/helpers' /** * Builds the policy context from the selected files. * This computes all the information needed for policy checks once, * so we don't have to recompute it for each policy. * * @param {IOCozyFile[]} files - The files being acted upon * @returns {ActionPolicyContext} The policy context with computed file information */ export const buildPolicyContext = ( files: IOCozyFile[] ): ActionPolicyContext => { let hasInfected = false let hasNotScanned = false let hasFolder = false let hasSharedFile = false let allInTrash = files.length > 0 for (const file of files) { if (!hasInfected && isInfected(file)) hasInfected = true if (!hasNotScanned && isNotScanned(file)) hasNotScanned = true if (!hasFolder && isDirectory(file)) hasFolder = true if (!hasSharedFile) { hasSharedFile = file.referenced_by?.some(ref => ref.type === 'io.cozy.sharings') ?? false } if (allInTrash && !file.trashed) allInTrash = false } return { files, hasInfectedFile: hasInfected, hasNotScannedFile: flag('drive.not-scanned-file-action.enabled') ? hasNotScanned : false, hasMultipleFiles: files.length > 1, hasFolder, hasSharedFile, allInTrash } } /** * Policy for infected files. * Actions are blocked for infected files unless they explicitly allow it. */ const infectionPolicy: ActionPolicyDefinition = { name: 'infection', allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean => !ctx.hasInfectedFile || action.allowInfectedFiles === true } /** * Policy for files that haven't been scanned yet. * Actions are blocked when files are not scanned unless they explicitly allow it. */ const notScannedPolicy: ActionPolicyDefinition = { name: 'notScanned', allows: ( action: DriveActionPolicyFlags, ctx: ActionPolicyContext ): boolean => { const allowed = !ctx.hasNotScannedFile || action.allowNotScannedFiles === true return allowed } } /** * Policy for multiple file selection. * Actions are blocked for multiple files unless they explicitly allow it. * Default is true (most actions support multiple files). */ const multipleFilesPolicy: ActionPolicyDefinition = { name: 'multipleFiles', allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean => !ctx.hasMultipleFiles || action.allowMultiple !== false } /** * Policy for folders. * Actions are blocked for folders unless they explicitly allow it. * Default is true (most actions support folders). */ const foldersPolicy: ActionPolicyDefinition = { name: 'folders', allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean => !ctx.hasFolder || action.allowFolders !== false } /** * Policy for trashed files. * Actions are blocked for trashed files unless they explicitly allow it. */ const trashedPolicy: ActionPolicyDefinition = { name: 'trashed', allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean => !ctx.allInTrash || action.allowTrashed === true } /** * All registered policies that will be checked for each action. * Add new policies here to have them automatically applied. */ export const ACTION_POLICIES: ActionPolicyDefinition[] = [ infectionPolicy, notScannedPolicy, multipleFilesPolicy, foldersPolicy, trashedPolicy ] /** * Extracts the action object from a drive action wrapper. * Actions from makeActions are wrapped as { [actionName]: actionObject } */ const getActionFromWrapper = ( wrappedAction: Record ): DriveAction | null => { const values = Object.values(wrappedAction) return values.length > 0 ? values[0] : null } /** * Checks if an action is allowed by all policies. * * @param action - The action to check * @param ctx - The policy context * @returns true if all policies allow the action */ const isActionAllowedByPolicies = ( action: DriveAction, ctx: ActionPolicyContext ): boolean => { return ACTION_POLICIES.every(policy => policy.allows(action, ctx)) } /** * Filters actions based on all registered policies. * This is the single source of truth for determining which actions * are available for a given set of files based on their characteristics. * * @param actions - Array of wrapped actions from makeActions * @param files - Array of files to check policies against * @returns Filtered array of actions that are allowed for the given files * * @example * ```typescript * const filteredActions = filterActionsByPolicy(actions, selectedFiles) * ``` */ export const filterActionsByPolicy = >( actions: T[], files: IOCozyFile[] ): T[] => { // Build the policy context once for all checks const ctx = buildPolicyContext(files) const result = actions.filter(wrappedAction => { // makeActions guarantees wrappers contain an action, so empty wrappers // cannot occur. This fail-open behavior is safe and intentional. const action = getActionFromWrapper(wrappedAction) if (!action) return true const isAllowed = isActionAllowedByPolicies(action, ctx) return isAllowed }) return result } /** * Checks if any of the provided files are infected. * Useful for UI components that need to show infection indicators. * * @param files - Array of files to check * @returns true if any file is infected */ export const hasAnyInfectedFile = (files: IOCozyFile[]): boolean => { return files.some(file => isInfected(file)) } /** * Gets the policy context for the given files. * Useful for UI components that need to access policy information. * * @param files - Array of files to build context for * @returns The policy context */ export const getPolicyContext = (files: IOCozyFile[]): ActionPolicyContext => { return buildPolicyContext(files) } ================================================ FILE: src/modules/actions/qualify.jsx ================================================ import React, { forwardRef } from 'react' import { getQualification } from 'cozy-client/dist/models/document' import { getBoundT } from 'cozy-client/dist/models/document/locales' import { isFile } from 'cozy-client/dist/models/file' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import UnqualifyIcon from 'cozy-ui/transpiled/react/Icons/LabelOutlined' import QualifyIcon from 'cozy-ui/transpiled/react/Icons/Qualify' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { navigateToModal } from '@/modules/actions/helpers' const makeComponent = ({ label, scannerT, t }) => { const Component = forwardRef((props, ref) => { const file = props.docs[0] const fileQualif = getQualification(file) return ( {fileQualif && ( )} ) }) Component.displayName = 'Qualify' return Component } export const qualify = ({ t, lang, navigate, pathname }) => { const label = t('SelectionBar.qualify') const scannerT = getBoundT(lang || 'en') return { name: 'qualify', label, icon: QualifyIcon, displayCondition: selection => { return selection.length === 1 && isFile(selection[0]) }, action: files => { return navigateToModal({ navigate, pathname, files, path: 'qualify' }) }, Component: makeComponent({ label, scannerT, t }) } } ================================================ FILE: src/modules/actions/rename.jsx ================================================ import React, { forwardRef } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import RenameIcon from 'cozy-ui/transpiled/react/Icons/Rename' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { startRenamingAsync } from '@/modules/drive/rename' import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { return ( ) }) Component.displayName = 'Rename' return Component } export const rename = ({ t, hasWriteAccess, dispatch, shouldHideIfSharedDriveRecipient }) => { const label = t('SelectionBar.rename') const icon = RenameIcon return { name: 'rename', label, icon, displayCondition: selection => { // special case for rename in sharings tab const isAllowedForSharedDrive = shouldHideIfSharedDriveRecipient ? !isFromSharedDriveRecipient(selection[0]) : true return selection.length === 1 && hasWriteAccess && isAllowedForSharedDrive }, action: files => { // Use setTimeout to defer dispatch until after click event completes // This prevents focus loss on the rename input setTimeout(() => { dispatch(startRenamingAsync(files[0])) }, 0) }, Component: makeComponent(label, icon) } } ================================================ FILE: src/modules/actions/restore.jsx ================================================ import React, { forwardRef } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { restoreFiles } from './utils' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { return ( ) }) Component.displayName = 'Restore' return Component } export const restore = ({ t, refresh, client }) => { const label = t('SelectionBar.restore') const icon = RestoreIcon return { name: 'restore', label, icon, allowTrashed: true, action: async files => { await restoreFiles(client, files) refresh() }, Component: makeComponent(label, icon) } } ================================================ FILE: src/modules/actions/select.jsx ================================================ import React, { forwardRef } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { return ( ) }) Component.displayName = 'Select' return Component } export const select = ({ t, showSelectionBar }) => { const label = t('toolbar.menu_select') const icon = CheckSquareIcon return { name: 'select', label, icon, displayCondition: files => files.length > 1, action: () => showSelectionBar(), Component: makeComponent(label, icon) } } ================================================ FILE: src/modules/actions/selectAll.jsx ================================================ import React, { forwardRef } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare' import CheckboxIcon from 'cozy-ui/transpiled/react/Icons/Checkbox' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { return ( ) }) Component.displayName = 'SelectAllItems' return Component } export const selectAllItems = ({ t, selectAll, isSelectAll, isMobile }) => { const baseKey = isSelectAll ? 'clear_selection' : 'select_all' const label = t(`toolbar.${baseKey}${isMobile ? '_mobile' : ''}`) const icon = isSelectAll ? CheckSquareIcon : CheckboxIcon return { name: 'selectAllItems', label, icon, displayInSelectionBar: true, displayInContextMenu: false, displayCondition: files => files.length > 0, action: () => selectAll(), Component: makeComponent(label, icon) } } ================================================ FILE: src/modules/actions/share.jsx ================================================ import React, { forwardRef } from 'react' import { SharedRecipients } from 'cozy-sharing' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import ShareIcon from 'cozy-ui/transpiled/react/Icons/Share' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import { navigateToModal } from '@/modules/actions/helpers' import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers' const share = ({ t, shouldHideIfSharedDriveRecipient, hasWriteAccess, navigate, pathname, allLoaded }) => { const label = t('Files.share.cta') const icon = ShareIcon return { name: 'share', label, icon, allowInfectedFiles: false, displayCondition: files => { // If shared drive recipient: // - in sharing view, we hide it because it works differently // - in shared drive view, we show it if (files?.length === 1 && isFromSharedDriveRecipient(files[0])) { return !shouldHideIfSharedDriveRecipient } return ( allLoaded && // We need to wait for the sharing context to be completely loaded to avoid race conditions hasWriteAccess && files?.length === 1 ) }, action: files => navigateToModal({ navigate, pathname, files, path: 'share' }), Component: forwardRef(function ShareMenuItemInMenu(props, ref) { const { isMobile } = useBreakpoints() return ( {isMobile && props.docs ? ( ) : null} ) }) } } export { share } ================================================ FILE: src/modules/actions/summariseByAI.jsx ================================================ import React, { forwardRef } from 'react' import flag from 'cozy-flags' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import ArticleIcon from 'cozy-ui/transpiled/react/Icons/Article' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { isFileSummaryCompatible } from 'cozy-viewer/dist/helpers' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { return ( ) }) Component.displayName = 'summariseByAI' return Component } export const summariseByAI = ({ t, hasWriteAccess, navigate, isPublic }) => { const label = t('actions.summariseByAI') const icon = ArticleIcon return { name: 'summariseByAI', label, icon, displayCondition: files => flag('ai.available') && isFileSummaryCompatible(files[0]) && hasWriteAccess && !isPublic, action: files => { const file = files[0] navigate(`file/${file._id}`, { state: { showAIAssistant: true } }) }, Component: makeComponent(label, icon) } } ================================================ FILE: src/modules/actions/trash.jsx ================================================ import React, { forwardRef } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import DeleteConfirm from '@/modules/drive/DeleteConfirm' const makeComponent = ({ icon, t, byDocId, isOwner }) => { const Component = forwardRef((props, ref) => { const sharedWithMe = byDocId !== undefined && byDocId[props.docs[0].id] && !isOwner(props.docs[0].id) const label = sharedWithMe ? t('toolbar.leave') : props.docs.length > 1 ? t('SelectionBar.trash_all') : t('SelectionBar.trash') return ( ) }) Component.displayName = 'Trash' return Component } export const trash = ({ t, pushModal, popModal, hasWriteAccess, refresh, byDocId, isOwner, driveId }) => { const icon = TrashIcon return { name: 'trash', icon, allowInfectedFiles: true, allowNotScannedFiles: true, displayCondition: files => files.length > 0 && hasWriteAccess, action: files => { return pushModal( ) }, Component: makeComponent({ icon, t, byDocId, isOwner }) } } ================================================ FILE: src/modules/actions/types.ts ================================================ import type { ForwardRefExoticComponent, RefAttributes } from 'react' import type { IOCozyFile } from 'cozy-client/types/types' import type { Action as CozyAction } from 'cozy-ui/transpiled/react/ActionsMenu/Actions' /** * Context containing computed information about the selected files. * This is built once and passed to all policy checks for efficiency. */ export interface ActionPolicyContext { /** The files being acted upon */ files: IOCozyFile[] /** Whether any file in the selection is infected */ hasInfectedFile: boolean /** Whether any file has not been scanned yet */ hasNotScannedFile: boolean /** Whether multiple files are selected */ hasMultipleFiles: boolean /** Whether any file is a folder */ hasFolder: boolean /** Whether any file is shared */ hasSharedFile: boolean /** Whether all files are in the trash */ allInTrash: boolean } /** * Interface for defining a policy that determines if an action is allowed. * Each policy checks a specific aspect (infection, read-only, etc.). */ export interface ActionPolicyDefinition { /** Unique name for the policy (for debugging/logging) */ name: string /** * Checks if the action is allowed given the policy context. * @param action - The action being checked * @param ctx - The policy context with computed file information * @returns true if the action is allowed, false otherwise */ allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext) => boolean } /** * Policy flags that can be set on actions to control their availability. * Each flag corresponds to a policy check. */ export interface DriveActionPolicyFlags { allowInfectedFiles?: boolean allowNotScannedFiles?: boolean allowMultiple?: boolean allowFolders?: boolean allowTrashed?: boolean } /** * Context passed to action handlers and components at runtime. */ export interface ActionContext { client?: unknown t?: (key: string, options?: Record) => string lang?: string vaultClient?: unknown pushModal?: (modal: React.ReactNode) => void popModal?: () => void refresh?: () => void navigate?: ( path: string | { pathname: string; search?: string }, options?: unknown ) => void hasWriteAccess?: boolean canMove?: boolean isPublic?: boolean allLoaded?: boolean showAlert?: (options: { message: string; severity: string }) => void isOwner?: (docId: string) => boolean byDocId?: Record isNativeFileSharingAvailable?: boolean shareFilesNative?: (files: IOCozyFile[]) => void isSharingShortcutCreated?: boolean openSharingLinkDisplayed?: boolean syncSharingLink?: () => void isMobile?: boolean fetchBlobFileById?: (client: unknown, fileId: string) => Promise isFile?: (file: IOCozyFile) => boolean addSharingLink?: () => void driveId?: string pathname?: string search?: string canDuplicate?: boolean isSelectAll?: boolean displayedFolder?: IOCozyFile } /** * Props passed to action menu item components. */ export interface ActionComponentProps { docs?: IOCozyFile[] onClick?: (context?: unknown) => void } /** * Drive action definition with policy support. */ export interface DriveAction extends DriveActionPolicyFlags { /** Unique identifier for the action */ name: string /** Display label for the action */ label?: string /** Icon component or icon name */ icon?: React.ComponentType | string /** * Function to determine if the action should be displayed. * This is checked AFTER policy checks. */ displayCondition?: (docs: IOCozyFile[]) => boolean /** Whether to show this action in the selection bar. Default: true */ displayInSelectionBar?: boolean /** Whether to show this action in context menus. Default: true */ displayInContextMenu?: boolean /** The action handler */ action?: (docs: IOCozyFile[], context?: ActionContext) => void /** React component to render the action menu item */ Component?: ForwardRefExoticComponent< ActionComponentProps & RefAttributes > } /** * Extended Action type that includes policy properties. * Use this type when you need to return an action that is compatible * with cozy-ui's Action type but also includes our policy properties. */ export type ActionWithPolicy = CozyAction & DriveActionPolicyFlags ================================================ FILE: src/modules/actions/utils.js ================================================ import { isDirectory } from 'cozy-client/dist/models/file' import { receiveQueryResult } from 'cozy-client/dist/store' import { DOCTYPE_FILES } from '@/lib/doctypes' const isMissingFileError = error => error.status === 404 const downloadFileError = error => { return isMissingFileError(error) ? 'error.download_file.missing' : 'error.download_file.offline' } /** * An instance of cozy-client * @typedef {object} CozyClient */ /** * downloadFiles - Triggers the download of one or multiple files by the browser * * @param {CozyClient} client * @param {array} files One or more files to download */ export const downloadFiles = async (client, files, { showAlert, t } = {}) => { if (files.length === 1 && !isDirectory(files[0])) { const file = files[0] const driveId = file.driveId try { return await client .collection(DOCTYPE_FILES, { driveId }) .download(file, null, file.name) } catch (error) { showAlert({ message: t(downloadFileError(error)), severity: 'error' }) } } else { const ids = files.map(f => f.id) const driveId = files[0].driveId return client.collection(DOCTYPE_FILES, { driveId }).downloadArchive(ids) } } const isAlreadyInTrash = err => { const reasons = err.reason !== undefined ? err.reason.errors : undefined if (reasons) { for (const reason of reasons) { if (reason.detail === 'File or directory is already in the trash') { return true } } } return false } /** * trashFiles - Moves a set of files to the cozy trash * * @param {CozyClient} client * @param {array} files One or more files to trash */ export const trashFiles = async (client, files, { showAlert, t, driveId }) => { try { for (const file of files) { // TODO we should not go through a FileCollection to destroy the file, but // only do client.destroy(), I do not know what it did not update the internal // store correctly when I tried const { data: updatedFile } = await client .collection(DOCTYPE_FILES, { driveId }) .destroy(file) client.store.dispatch( receiveQueryResult(null, { data: updatedFile }) ) client.collection('io.cozy.permissions').revokeSharingLink(file) } showAlert({ message: t('alert.trash_file_success'), severity: 'success' }) } catch (err) { if (!isAlreadyInTrash(err)) { showAlert({ message: t('alert.try_again'), severity: 'error' }) } } } export const restoreFiles = async (client, files) => { for (const file of files) { await client.collection(DOCTYPE_FILES).restore(file.id) } } ================================================ FILE: src/modules/actions/utils.spec.js ================================================ import { createMockClient } from 'cozy-client' import { initQuery, receiveQueryResult } from 'cozy-client/dist/store' import { trashFiles, downloadFiles } from './utils' import { generateFile } from 'test/generate' import { TRASH_DIR_ID } from '@/constants/config' jest.mock('modules/navigation/AppRoute', () => ({ routes: [] })) jest.mock('cozy-stack-client/dist/utils', () => ({ forceFileDownload: jest.fn() })) const showAlert = jest.fn() const t = x => x describe('trashFiles', () => { const setup = () => { const client = new createMockClient({}) const store = client.store store.dispatch( initQuery('files', { doctype: 'io.cozy.files' }) ) const file = generateFile({ i: 0 }) store.dispatch( receiveQueryResult('files', { data: file }) ) return { client, store, file } } it('should destroy the file and update queries', async () => { const { store, client, file } = setup() const mockedDestroy = jest.fn() client.collection = jest.fn(() => ({ destroy: mockedDestroy })) mockedDestroy.mockResolvedValue({ data: { ...file, dir_id: TRASH_DIR_ID } }) const state = store.getState() expect(state.cozy.documents['io.cozy.files'][file._id]._id).toEqual( file._id ) await trashFiles(client, [file], { showAlert, t }) expect(mockedDestroy).toHaveBeenCalledWith(file) const state2 = store.getState() const updatedFile = state2.cozy.documents['io.cozy.files'][file._id] expect(updatedFile.dir_id).toEqual('io.cozy.files.trash-dir') }) }) describe('downloadFiles', () => { const mockClient = createMockClient({}) mockClient.stackClient.uri = 'http://cozy.tools' const mockDownload = jest.fn() const mockDownloadArchive = jest.fn() beforeEach(() => { mockClient.collection = () => ({ download: mockDownload, downloadArchive: mockDownloadArchive }) }) it('downloads a single file', async () => { const file = { id: 'file-id-1', name: 'my-file.pdf', type: 'file' } await downloadFiles(mockClient, [file]) expect(mockDownload).toHaveBeenCalledWith(file, null, file.name) }) it('downloads a folder', async () => { const folder = { id: 'folder-id-1', name: 'Classified', type: 'directory' } await downloadFiles(mockClient, [folder]) expect(mockDownloadArchive).toHaveBeenCalledWith([folder.id]) }) it('downloads multiple files', async () => { const files = [ { id: 'file-id-1', name: 'my-file-1.pdf', type: 'file' }, { id: 'file-id-2', name: 'my-file-2.pdf', type: 'file' } ] await downloadFiles(mockClient, files) expect(mockDownloadArchive).toHaveBeenCalledWith(['file-id-1', 'file-id-2']) }) }) ================================================ FILE: src/modules/actions/versions.jsx ================================================ import React, { forwardRef } from 'react' import { isFile } from 'cozy-client/dist/models/file' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import HistoryIcon from 'cozy-ui/transpiled/react/Icons/History' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { navigateToModal } from '@/modules/actions/helpers' const makeComponent = (label, icon) => { const Component = forwardRef((props, ref) => { return ( ) }) Component.displayName = 'Versions' return Component } export const versions = ({ t, navigate, pathname }) => { const label = t('SelectionBar.history') const icon = HistoryIcon return { name: 'history', label, icon, allowInfectedFiles: false, displayCondition: selection => { return selection.length === 1 && isFile(selection[0]) }, action: files => { return navigateToModal({ navigate, pathname, files, path: 'revision' }) }, Component: makeComponent(label, icon) } } ================================================ FILE: src/modules/breadcrumb/components/Breadcrumb.jsx ================================================ import cx from 'classnames' import PropTypes from 'prop-types' import React, { useState, useRef, useEffect, useCallback } from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import RightIcon from 'cozy-ui/transpiled/react/Icons/Right' import Spinner from 'cozy-ui/transpiled/react/Spinner' import { useI18n } from 'twake-i18n' import styles from '@/modules/breadcrumb/styles/breadcrumb.styl' const Breadcrumb = ({ path, onBreadcrumbClick, opening, inlined, className = '' }) => { const { t } = useI18n() const [deployed, setDeployed] = useState(false) const wrapperRef = useRef(null) const closeMenu = useCallback(() => { setDeployed(false) }, [setDeployed]) const openMenu = useCallback(() => { setDeployed(true) }, [setDeployed]) useEffect(() => { function handleClickOutside(event) { if (wrapperRef.current && !wrapperRef.current.contains(event.target)) { closeMenu() } } document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('mousedown', handleClickOutside) } }, [wrapperRef, closeMenu]) const toggleDeploy = () => (deployed ? closeMenu() : openMenu()) if (!path) return false return (

{path.map((folder, index) => { const folderName = folder._id === 'io.cozy.files.shared-drives-dir' ? t('breadcrumb.title_shared_drives') : folder.name if (index < path.length - 1) { return ( { e.stopPropagation() onBreadcrumbClick(folder) }} key={index} > {folderName} ) } else { return ( { e.stopPropagation() if (path.length >= 2) toggleDeploy() }} key={index} > {folderName} {path.length >= 2 && ( )} {opening && } ) } })}

) } Breadcrumb.propTypes = { path: PropTypes.array, onBreadcrumbClick: PropTypes.func, opening: PropTypes.bool, inlined: PropTypes.bool, className: PropTypes.string } export default Breadcrumb ================================================ FILE: src/modules/breadcrumb/components/Breadcrumb.spec.jsx ================================================ import { fireEvent, render } from '@testing-library/react' import React from 'react' import Breadcrumb from './Breadcrumb' import { TestI18n } from 'test/components/AppLike' import { dummyBreadcrumbPathWithRootLarge } from 'test/dummies/dummyBreadcrumbPath' describe('Breadcrumbs', () => { const dummyPath = dummyBreadcrumbPathWithRootLarge() const setup = ({ path, inlined, onBreadcrumbClick } = {}) => { return render( ) } describe('template', () => { it('should match snapshot', () => { // When const { container } = setup({ path: dummyPath }) // Then expect(container).toMatchSnapshot() }) it('should be empty while path is undefined', () => { // When const { container } = setup() // Then expect(container).toBeEmptyDOMElement() }) it('should add inlined style while inlined prop true', () => { // When const { container } = setup({ path: dummyPath, inlined: true }) // Then expect(container.querySelector('.inlined')).not.toBeEmptyDOMElement() }) }) describe('events', () => { it('should fire on breadcrumb click when link is clicked', () => { // Given const onBreadcrumbClick = jest.fn() const { container } = setup({ path: dummyPath, onBreadcrumbClick }) // When fireEvent.click(container.querySelector('.fil-path-link')) // Then expect(onBreadcrumbClick).toHaveBeenCalledWith({ id: 'io.cozy.files.root-dir', name: 'Drive' }) }) it('should toggle deploy on click on current', () => { // Given document.addEventListener = jest.fn() // Given const { container } = setup({ path: dummyPath }) // When fireEvent.click(container.querySelector('.fil-path-current')) // Then expect(container.querySelector('.deployed')).toBeInTheDocument() expect(document.addEventListener).toHaveBeenCalledWith( 'mousedown', expect.any(Function) ) }) it('should close menu', () => { // Given document.removeEventListener = jest.fn() const { container } = setup({ path: dummyPath }) fireEvent.click(container.querySelector('.fil-path-current')) expect(container.querySelector('.deployed')).toBeInTheDocument() // When fireEvent.click(container.querySelector('.fil-path-current')) // Then expect(container.querySelector('.deployed')).not.toBeInTheDocument() }) }) }) ================================================ FILE: src/modules/breadcrumb/components/DesktopBreadcrumb.jsx ================================================ import React, { useEffect, useMemo, useState } from 'react' import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import BreadcrumbMui from 'cozy-ui/transpiled/react/Breadcrumbs' import Icon from 'cozy-ui/transpiled/react/Icon' import FileTypeSharedDriveIcon from 'cozy-ui/transpiled/react/Icons/FileTypeSharedDriveGrey' import FolderIcon from 'cozy-ui/transpiled/react/Icons/Folder' import RightIcon from 'cozy-ui/transpiled/react/Icons/Right' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useI18n } from 'twake-i18n' import styles from '@/modules/breadcrumb/styles/breadcrumb.styl' import { ROOT_DIR_ID } from '@/constants/config' import { DesktopBreadcrumbItem } from '@/modules/breadcrumb/components/DesktopBreadcrumbItem' const DesktopBreadcrumb = ({ onBreadcrumbClick, path }) => { const { t } = useI18n() const expandText = useMemo(() => t('breadcrumb.label'), [t]) const [dropdownTrigger, setDropdownTrigger] = useState( document.querySelector(`[aria-label="${expandText}"]`) ) const anchorElRef = useMemo( () => ({ current: dropdownTrigger }), [dropdownTrigger] ) const [menuDisplayed, setMenuDisplayed] = useState(false) const closeMenu = () => setMenuDisplayed(false) const handleDropdownTriggerClick = e => { e.stopPropagation() setMenuDisplayed(true) } useEffect(() => { closeMenu() setDropdownTrigger(document.querySelector(`[aria-label="${expandText}"]`)) }, [path]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const trigger = anchorElRef.current if (trigger) { trigger.addEventListener('click', handleDropdownTriggerClick) return () => { closeMenu() trigger.removeEventListener('click', handleDropdownTriggerClick) } } }, [anchorElRef.current]) // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/refs const Separator = ( ) // When we are in a shared drive, we want to display the shared drive icon // in first position to reduce the number of displayed path elements const pathToDisplay = useMemo(() => { const sharedDriveIndex = path.findIndex( item => item.id === 'io.cozy.files.shared-drives-dir' ) if (sharedDriveIndex !== -1 && path.length > 2) { return path.slice(sharedDriveIndex) } return path }, [path]) return ( <> {pathToDisplay.map((breadcrumbPath, index) => { if (pathToDisplay.length > 1 && breadcrumbPath.id === ROOT_DIR_ID) { return ( ) } if ( index === 0 && breadcrumbPath.id === 'io.cozy.files.shared-drives-dir' ) { return ( ) } return ( ) })} {menuDisplayed && ( {path.slice(1, -2).map(breadcrumbPath => ( { e.stopPropagation() onBreadcrumbClick(breadcrumbPath) }} > ))} )} ) } export default DesktopBreadcrumb ================================================ FILE: src/modules/breadcrumb/components/DesktopBreadcrumb.spec.jsx ================================================ import { render, fireEvent, act } from '@testing-library/react' import React from 'react' import { BreakpointsProvider } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { useI18n } from 'twake-i18n' import DesktopBreadcrumb from './DesktopBreadcrumb' import { dummyBreadcrumbPathNoRootLarge, dummyBreadcrumbPathNoRootSmall, dummyBreadcrumbPathWithRootLarge, dummyBreadcrumbPathWithRootSmall, dummyBreadcrumbPathWithSharedDriveLarge, dummyBreadcrumbPathWithSharedDriveSmall } from 'test/dummies/dummyBreadcrumbPath' jest.mock('cozy-ui/transpiled/react/ActionsMenu', () => ({ children }) => (
{children}
)) jest.mock( 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem', () => ({ children }) =>
{children}
) jest.mock('twake-i18n') describe('DesktopBreadcrumb', () => { beforeEach(() => { useI18n.mockReturnValue({ t: () => 'Show path' }) }) describe('template', () => { describe('When parent is ROOT folder', () => { it('should display breadcrumb with | 📁 > "..." > parent > current | when more than 3 nested folders', () => { // When const { container, queryByText } = render( ) // Then expect(container.querySelector('[aria-label="Drive"]')).toBeTruthy() expect(queryByText('grandparent')).toBeFalsy() expect(queryByText('parent')).toBeTruthy() expect(queryByText('current')).toBeTruthy() expect(container.querySelector('[aria-label="Show path"]')).toBeTruthy() expect(container.querySelector('.fil-path-separator')).toBeTruthy() }) it('should display breadcrumb with | 📁 > parent > current | when 3 nested folders or less', () => { // When const { container, queryByText } = render( ) // Then expect(container.querySelector('[aria-label="Drive"]')).toBeTruthy() expect(queryByText('grandparent')).toBeFalsy() expect(queryByText('parent')).toBeTruthy() expect(queryByText('current')).toBeTruthy() expect(container.querySelector('[aria-label="Show path"]')).toBeFalsy() expect(container.querySelector('.fil-path-separator')).toBeTruthy() }) }) describe('When parent is a Shared drive', () => { it('should display breadcrumb with | 📁 > "..." > parent > current | when more than 3 nested folders', () => { // When const { container, queryByText } = render( ) // Then expect( container.querySelector('[aria-label="Shared Drive"]') ).toBeTruthy() expect(queryByText('grandparent')).toBeFalsy() expect(queryByText('parent')).toBeTruthy() expect(queryByText('current')).toBeTruthy() expect(container.querySelector('[aria-label="Show path"]')).toBeTruthy() expect(container.querySelector('.fil-path-separator')).toBeTruthy() }) it('should display breadcrumb with | 📁 > parent > current | when 3 nested folders or less', () => { // When const { container, queryByText } = render( ) // Then expect( container.querySelector('[aria-label="Shared Drive"]') ).toBeTruthy() expect(queryByText('grandparent')).toBeFalsy() expect(queryByText('parent')).toBeTruthy() expect(queryByText('current')).toBeTruthy() expect(container.querySelector('[aria-label="Show path"]')).toBeFalsy() expect(container.querySelector('.fil-path-separator')).toBeTruthy() }) }) describe('When parent is nor ROOT nor Shared drive', () => { it('should display breadcrumb with | Drive > "..." > parent > current | when more than 3 nested folders', () => { // When const { container, queryByText } = render( ) // Then expect(queryByText('Some Main Folder')).toBeTruthy() expect(queryByText('grandparent')).toBeFalsy() expect(queryByText('parent')).toBeTruthy() expect(queryByText('current')).toBeTruthy() expect(container.querySelector('[aria-label="Show path"]')).toBeTruthy() expect(container.querySelector('.fil-path-separator')).toBeTruthy() }) it('should display breadcrumb with | Drive > parent > current | when 3 nested folders or less', () => { // When const { container, queryByText } = render( ) // Then expect(queryByText('Some Main Folder')).toBeTruthy() expect(queryByText('parent')).toBeTruthy() expect(queryByText('current')).toBeTruthy() expect(container.querySelector('[aria-label="Show path"]')).toBeFalsy() expect(container.querySelector('.fil-path-separator')).toBeTruthy() }) }) it('should have convenient style on Public view - on desktop', () => { // When const { container } = render( ) // Then expect(container.querySelector('.fil-path-backdrop')).toBeTruthy() }) }) describe('mount', () => { beforeEach(() => { jest.spyOn(console, 'error').mockImplementation(() => {}) }) afterEach(() => { // eslint-disable-next-line no-console console.error.mockRestore() }) it('should hide menu displayed while navigating', async () => { // Given const { container, queryByTestId, rerender } = await render( ) act(() => { container.querySelector('[aria-label="Show path"]').click() }) expect(queryByTestId('action-menu')).toBeInTheDocument() // When rerender() // Then expect(queryByTestId('action-menu')).not.toBeInTheDocument() }) it('should update dropdown trigger while navigating - on public page', async () => { // Given const { container, rerender } = await render( ) expect(container.querySelector('[aria-label="Show path"]')).toBeNull() rerender() // When act(() => { container.querySelector('[aria-label="Show path"]').click() }) // Then expect(container.querySelector('[aria-label="Show path"]')).not.toBeNull() }) }) describe('events', () => { it('should dispatch on breadcrumb click - on desktop', () => { // Given const onBreadcrumbClick = jest.fn() const path = dummyBreadcrumbPathWithRootLarge() const { queryByText } = render( ) // When queryByText('parent').click() // Then expect(onBreadcrumbClick).toHaveBeenCalledWith(path[2]) }) it('should display action menu on click on "..." on desktop', () => { // Given const path = dummyBreadcrumbPathWithRootLarge() const { container, queryByTestId } = render( ) // When act(() => { container.querySelector('[aria-label="Show path"]').click() }) // Then expect(queryByTestId('action-menu')).toBeInTheDocument() expect(queryByTestId('action-menu-item')).toBeInTheDocument() }) it('should add grandParents only in dropdown - on click on ... on desktop', () => { // Given const path = dummyBreadcrumbPathWithRootLarge() const { container, queryByText } = render( ) // When act(() => { container.querySelector('[aria-label="Show path"]').click() }) // Then expect(container.querySelectorAll('.MuiBreadcrumbs-li')[1]).not.toEqual( 'grandParents' ) expect(queryByText('grandParent')).toBeInTheDocument() }) it('should handle on click outside on desktop - removing dropdown', () => { // Given const path = dummyBreadcrumbPathWithRootLarge() const { container, queryByText } = render(
) // When fireEvent.click(container.querySelector('button')) // Then expect(queryByText('grandParent')).not.toBeInTheDocument() expect(container.querySelector('.dropdown')).not.toBeInTheDocument() }) }) }) ================================================ FILE: src/modules/breadcrumb/components/DesktopBreadcrumbItem.jsx ================================================ import classNames from 'classnames' import React, { useCallback } from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import { useI18n } from 'twake-i18n' import styles from '@/modules/breadcrumb/styles/breadcrumb.styl' const DesktopBreadcrumbItem = ({ item, isCurrent, onClick, icon }) => { const { t } = useI18n() const handleClick = useCallback( e => { e.stopPropagation() onClick(item) }, [onClick, item] ) const itemName = item.id === 'io.cozy.files.shared-drives-dir' ? t('breadcrumb.title_shared_drives') : item.name return ( {icon ? ( ) : ( itemName )} ) } export { DesktopBreadcrumbItem } ================================================ FILE: src/modules/breadcrumb/components/MobileAwareBreadcrumb.jsx ================================================ import React from 'react' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import DesktopBreadcrumb from '@/modules/breadcrumb/components/DesktopBreadcrumb' import MobileBreadcrumb from '@/modules/breadcrumb/components/MobileBreadcrumb' export const MobileAwareBreadcrumb = props => { const { isMobile } = useBreakpoints() return isMobile ? ( ) : ( ) } export default MobileAwareBreadcrumb ================================================ FILE: src/modules/breadcrumb/components/MobileAwareBreadcrumb.spec.jsx ================================================ import { render } from '@testing-library/react' import React from 'react' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import MobileAwareBreadcrumb from './MobileAwareBreadcrumb' jest.mock('cozy-ui/transpiled/react/providers/Breakpoints') jest.mock('modules/breadcrumb/components/DesktopBreadcrumb', () => () => (
)) jest.mock('modules/breadcrumb/components/MobileBreadcrumb', () => () => (
)) describe('MobileAwareBreadcrumb', () => { it('should return mobile breadcrumb on mobile', () => { // Given useBreakpoints.mockReturnValue({ isMobile: true }) // When const { getByTestId } = render() // Then expect(getByTestId('mobile-breadcrumb')).toBeInTheDocument() }) it('should return mobile breadcrumb on desktop', () => { // Given useBreakpoints.mockReturnValue({ isMobile: false }) // When const { getByTestId } = render() // Then expect(getByTestId('desktop-breadcrumb')).toBeInTheDocument() }) }) ================================================ FILE: src/modules/breadcrumb/components/MobileBreadcrumb.jsx ================================================ import React, { useCallback } from 'react' import { BarCenter, BarLeft } from 'cozy-bar' import BackButton from '@/components/Button/BackButton' import Breadcrumb from '@/modules/breadcrumb/components/Breadcrumb' const MobileBreadcrumb = ({ onBreadcrumbClick, path, ...props }) => { const navigateBack = useCallback(() => { const parentFolder = path[path.length - 2] onBreadcrumbClick(parentFolder) }, [onBreadcrumbClick, path]) return (
{path.length >= 2 && ( )}
) } export default MobileBreadcrumb ================================================ FILE: src/modules/breadcrumb/components/MobileBreadcrumb.spec.jsx ================================================ import { render, fireEvent } from '@testing-library/react' import React from 'react' import { createMockClient } from 'cozy-client' import MobileBreadcrumb from './MobileBreadcrumb' import AppLike from 'test/components/AppLike' describe('MobileBreadcrumb', () => { it('works', async () => { const path = [ { id: '1', name: 'root folder' }, { id: '2', name: 'parent folder' }, { id: '3', name: 'current folder' } ] const onBreadcrumbClick = jest.fn() const { findByText } = render( ) // renders the path const rootLink = await findByText('root folder') await findByText('parent folder') await findByText('current folder') fireEvent.click(rootLink) expect(onBreadcrumbClick).toHaveBeenCalledWith({ id: '1', name: 'root folder' }) const backButton = document.querySelector('button') fireEvent.click(backButton) expect(onBreadcrumbClick).toHaveBeenCalledWith({ id: '2', name: 'parent folder' }) }) }) ================================================ FILE: src/modules/breadcrumb/components/__snapshots__/Breadcrumb.spec.jsx.snap ================================================ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Breadcrumbs template should match snapshot 1`] = `

Drive grandParent parent current

`; ================================================ FILE: src/modules/breadcrumb/hooks/useBreadcrumbPath.jsx ================================================ import { useEffect, useState } from 'react' import { useClient } from 'cozy-client' import log from 'cozy-logger' import { SHARED_DRIVES_DIR_ID } from '@/constants/config' import { fetchFolder, useFolder } from '@/modules/breadcrumb/utils/fetchFolder' /** * @typedef {Object} BreadcrumbPath * @property {string} name - The name of the folder. * @property {string} id - The ID of the folder. */ /** * Custom hook that retrieves the breadcrumb path for a given folder. * * @param {Object} options - The options for retrieving the breadcrumb path. * @param {string} options.currentFolderId - The ID of the current folder. * @param {BreadcrumbPath} options.rootBreadcrumbPath - The root breadcrumb path object. * @param {string[]} [options.sharedDocumentIds] - The IDs of shared documents. * @returns {BreadcrumbPath[]} - The breadcrumb path as an array of objects. */ export const useBreadcrumbPath = ({ currentFolderId, rootBreadcrumbPath, sharedDocumentIds, driveId }) => { const client = useClient() const [paths, setPaths] = useState([]) const folder = useFolder({ folderId: currentFolderId, driveId }) const folderAttributes = { id: folder?.id, name: folder?.name, dirId: folder?.dir_id } useEffect(() => { if (rootBreadcrumbPath && currentFolderId === rootBreadcrumbPath.id) { // eslint-disable-next-line react-hooks/set-state-in-effect setPaths([rootBreadcrumbPath]) return } if (!folderAttributes.id || !folderAttributes.name) { // Optionally set loading state or clear paths setPaths([]) return } const hasAccessToSharedDocument = id => { if (!sharedDocumentIds) return true return !sharedDocumentIds.includes(id) } let isSubscribed = true const returnedPaths = [ { name: folderAttributes.name, id: folderAttributes.id } ] const shouldContinueLoop = id => { return ( !!id && id !== rootBreadcrumbPath?.id && id !== SHARED_DRIVES_DIR_ID ) } const processFolder = async id => { const folder = await fetchFolder({ client, driveId, folderId: id }) if (!folder) return undefined returnedPaths.unshift({ name: folder.name, id: folder.id }) return hasAccessToSharedDocument(folder.id) ? folder.dir_id : undefined } const shouldAddRootPath = () => { return ( rootBreadcrumbPath?.name !== 'Public' && returnedPaths[0]?.id !== rootBreadcrumbPath.id ) } const handleBreadcrumbError = error => { if (rootBreadcrumbPath?.name === 'Public') { if (isSubscribed) { setPaths(returnedPaths) } } else { if (isSubscribed && rootBreadcrumbPath) { setPaths([rootBreadcrumbPath]) } log( 'error', `Error while fetching folder for breadcrumbs of folder id: ${folderAttributes.id}, here is the error: ${error}` ) } } const fetchBreadcrumbs = async () => { let id = folderAttributes.dirId while (shouldContinueLoop(id)) { id = await processFolder(id) } if (isSubscribed) { if (shouldAddRootPath()) { returnedPaths.unshift(rootBreadcrumbPath) } setPaths(returnedPaths) } } fetchBreadcrumbs().catch(handleBreadcrumbError) return () => { isSubscribed = false } }, [ client, sharedDocumentIds, rootBreadcrumbPath, driveId, folderAttributes.id, folderAttributes.name, folderAttributes.dirId, currentFolderId ]) return paths } ================================================ FILE: src/modules/breadcrumb/hooks/useBreadcrumbPath.spec.jsx ================================================ import { act, renderHook } from '@testing-library/react' import { useClient } from 'cozy-client' import log from 'cozy-logger' import { useBreadcrumbPath } from './useBreadcrumbPath' import { dummyBreadcrumbPathWithRootLarge, dummyRootBreadcrumbPath } from 'test/dummies/dummyBreadcrumbPath' import { fetchFolder, useFolder } from '@/modules/breadcrumb/utils/fetchFolder' jest.mock('cozy-logger') jest.mock('cozy-client') jest.mock('modules/breadcrumb/utils/fetchFolder') describe('useBreadcrumbPath', () => { const rootBreadcrumbPath = dummyRootBreadcrumbPath() const createFolder = ({ id, name, dirId }) => ({ id, name, dir_id: dirId }) beforeEach(() => { jest.resetAllMocks() useFolder.mockReturnValue(null) }) it('should get useClient from cozy-client', () => { // When renderHook(() => useBreadcrumbPath({})) // Then expect(useClient).toHaveBeenCalledWith() }) it('should return only Drive link when id undefined', async () => { useFolder.mockReturnValue( createFolder({ id: rootBreadcrumbPath.id, name: rootBreadcrumbPath.name, dirId: undefined }) ) // When const { result } = await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath }) ) // Then expect(result.current).toEqual([rootBreadcrumbPath]) }) it('should return only Drive link when id is root_breadcrumb_path id', async () => { useFolder.mockReturnValue( createFolder({ id: rootBreadcrumbPath.id, name: rootBreadcrumbPath.name, dirId: undefined }) ) // When let render await act(async () => { render = await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId: rootBreadcrumbPath.id }) ) }) // Then expect(render.result.current).toEqual([dummyRootBreadcrumbPath()]) }) it('should return only rootBreadcrumbPath when currentFolderId equals rootBreadcrumbPath.id (early return)', async () => { const someFolderId = 'some-folder-id' useFolder.mockReturnValue( createFolder({ id: someFolderId, name: 'Some Folder', dirId: rootBreadcrumbPath.id }) ) let render await act(async () => { render = await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId: rootBreadcrumbPath.id }) ) }) expect(render.result.current).toEqual([rootBreadcrumbPath]) expect(fetchFolder).not.toHaveBeenCalled() }) it('should call fetch folder', async () => { // Given const currentFolderId = '1234' useClient.mockReturnValue('cozy-client') const parentFolderId = 'parentFolderId' useFolder.mockReturnValue( createFolder({ id: currentFolderId, name: 'current', dirId: parentFolderId }) ) fetchFolder.mockReturnValueOnce({ dir_id: rootBreadcrumbPath.id }) // When await act(async () => { await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId }) ) }) // Then expect(fetchFolder).toHaveBeenCalledWith({ client: 'cozy-client', folderId: parentFolderId }) }) it('should log error when fetchFolder rejects error', async () => { // Given const currentFolderId = '1234' useClient.mockReturnValue('cozy-client') fetchFolder.mockRejectedValue('error') const parentFolderId = 'parentFolderId' useFolder.mockReturnValue( createFolder({ id: currentFolderId, name: 'current', dirId: parentFolderId }) ) // When let render await act(async () => { render = await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId }) ) }) // Then expect(render.result.current).toEqual([rootBreadcrumbPath]) expect(log).toHaveBeenCalledWith( 'error', 'Error while fetching folder for breadcrumbs of folder id: 1234, here is the error: error' ) }) it('should not loop when fetchFolder returns undefined', async () => { // Given const currentFolderId = '1234' useClient.mockReturnValue('cozy-client') fetchFolder.mockReturnValueOnce(undefined) const parentFolderId = 'parentFolderId' useFolder.mockReturnValue( createFolder({ id: currentFolderId, name: 'current', dirId: parentFolderId }) ) // When await act(async () => { await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId }) ) }) // Then expect(fetchFolder).toHaveBeenCalledTimes(1) }) it('should fetch several folder until rootBreadcrumbPath.id', async () => { // Given const currentFolderId = 'currentFolderId' const parentFolderId = 'parentFolderId' const grandParentFolderId = 'grandParentFolderId' useClient.mockReturnValue('cozy-client') useFolder.mockReturnValue( createFolder({ id: currentFolderId, name: 'current', dirId: parentFolderId }) ) fetchFolder.mockReturnValueOnce({ id: parentFolderId, name: 'parent', dir_id: grandParentFolderId }) fetchFolder.mockReturnValueOnce({ id: grandParentFolderId, name: 'grandParent', dir_id: rootBreadcrumbPath.id }) // When let render await act(async () => { render = await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId }) ) }) // Then expect(fetchFolder).toHaveBeenCalledTimes(2) expect(fetchFolder).toHaveBeenCalledWith({ client: 'cozy-client', folderId: parentFolderId }) expect(fetchFolder).toHaveBeenNthCalledWith(2, { client: 'cozy-client', folderId: grandParentFolderId }) expect(render.result.current).toEqual(dummyBreadcrumbPathWithRootLarge()) }) it('should not call fetch folder, on rerender', async () => { // Given const currentFolderId = '1234' useClient.mockReturnValue('cozy-client') fetchFolder.mockReturnValueOnce({ dir_id: rootBreadcrumbPath.id }) const parentFolderId = 'parentFolderId' useFolder.mockReturnValue( createFolder({ id: currentFolderId, name: 'current', dirId: parentFolderId }) ) // When let render await act(async () => { render = await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId }) ) }) expect(fetchFolder).toHaveBeenCalledTimes(1) render.rerender() // Then expect(fetchFolder).toHaveBeenCalledTimes(1) }) it('should not add rootBreadcrumbPath when name undefined on PublicView', async () => { // Given const publicViewRootBreadcrumbPath = { id: rootBreadcrumbPath.id, name: 'Public' } const currentFolderId = 'currentFolderId' useClient.mockReturnValue('cozy-client') useFolder.mockReturnValue( createFolder({ id: currentFolderId, name: 'current', dirId: publicViewRootBreadcrumbPath.id }) ) // When let render await act(async () => { render = await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath: publicViewRootBreadcrumbPath, currentFolderId }) ) }) // Then expect(render.result.current).toEqual([ { id: 'currentFolderId', name: 'current' } ]) }) it('should fetch folder until first shared documents on SharingView', async () => { // Given const currentFolderId = 'currentFolderId' const parentFolderId = 'parentFolderId' const notSharedFolderId = 'notSharedFolderId' const sharedDocumentIds = [parentFolderId, 'another-id'] useClient.mockReturnValue('cozy-client') useFolder.mockReturnValue( createFolder({ id: currentFolderId, name: 'current', dirId: parentFolderId }) ) fetchFolder.mockReturnValueOnce({ id: parentFolderId, name: 'parent', dir_id: notSharedFolderId }) const sharingsViewRootBreadcrumbPath = { id: rootBreadcrumbPath.id, name: 'Sharings' } // When let render await act(async () => { render = await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath: sharingsViewRootBreadcrumbPath, currentFolderId, sharedDocumentIds }) ) }) // Then expect(render.result.current).toEqual([ sharingsViewRootBreadcrumbPath, { id: 'parentFolderId', name: 'parent' }, { id: 'currentFolderId', name: 'current' } ]) expect(fetchFolder).toHaveBeenCalledTimes(1) expect(fetchFolder).toHaveBeenCalledWith({ client: 'cozy-client', folderId: parentFolderId }) }) it('should stop at the first shared document even when current is shared', async () => { // Given const currentFolderId = 'currentFolderId' const parentFolderId = 'parentFolderId' const notSharedFolderId = 'notSharedFolderId' const sharedDocumentIds = [parentFolderId, currentFolderId, 'another-id'] useClient.mockReturnValue('cozy-client') useFolder.mockReturnValue( createFolder({ id: currentFolderId, name: 'current', dirId: parentFolderId }) ) fetchFolder.mockReturnValueOnce({ id: parentFolderId, name: 'parent', dir_id: notSharedFolderId }) const sharingsViewRootBreadcrumbPath = { id: rootBreadcrumbPath.id, name: 'Sharings' } // When let render await act(async () => { render = await renderHook(() => useBreadcrumbPath({ rootBreadcrumbPath: sharingsViewRootBreadcrumbPath, currentFolderId, sharedDocumentIds }) ) }) // Then expect(render.result.current).toEqual([ sharingsViewRootBreadcrumbPath, { id: 'parentFolderId', name: 'parent' }, { id: 'currentFolderId', name: 'current' } ]) expect(fetchFolder).toHaveBeenCalledTimes(1) expect(fetchFolder).toHaveBeenCalledWith({ client: 'cozy-client', folderId: parentFolderId }) }) }) ================================================ FILE: src/modules/breadcrumb/styles/breadcrumb.styl ================================================ @require 'components/popover.styl' @require 'settings/breakpoints.styl' @require 'settings/z-index.styl' @require '../../../styles/coz-bar-size.styl' .fil-path-backdrop flex 1 1 auto width 1% min-width 0 &:not([override]) margin-right 2rem ol flex-wrap nowrap min-width 0 overflow hidden text-overflow ellipsis li &:last-child min-width 0 .fil-path-title margin 0 font-size 1.5rem overflow hidden white-space nowrap text-overflow ellipsis display block .fil-path-link display inline-flex align-items baseline font-weight normal color var(--actionColorActive) text-decoration none cursor pointer .fil-path-separator margin 0 .25rem &:hover color var(--primaryTextColor) .fil-path-down display none min-width .875rem height .625rem margin-left .4375rem border 0 background embedurl('../../../assets/icons/icon-arrow-down.svg') center center no-repeat .fil-path-current-name text-overflow ellipsis overflow hidden white-space nowrap color var(--primaryTextColor) font-weight bold +small-screen() // @stylint ignore .fil-path-backdrop min-width 0 width auto .fil-path-title display flex flex-direction column-reverse font-size 1.3rem .fil-path-link .fil-path-current display flex align-items center box-sizing border-box height $coz-bar-size padding 0 .25rem .fil-path-down display inline-block .fil-path-link .fil-path-separator display none .fil-path-backdrop.deployed margin 0 position fixed top 0 right 0 bottom 0 left 0 z-index $overlay-index &.inlined position absolute .fil-path-title z-index $popover-index box-shadow 0 .0625rem 0 0 var(--actionColorDisabled), 0 .375rem 1.5rem 0 rgba(50, 54, 63, .24) .fil-path-link .fil-path-current padding-left 'calc(%s + .25rem)' % $coz-bar-size .fil-path-link display flex color var(--primaryTextColor) background var(--paperBackgroundColor) .fil-path-link-name text-overflow ellipsis overflow hidden white-space nowrap .fil-path-current height $coz-bar-size box-shadow inset 0 -.0625rem 0 0 var(--actionColorDisabled) padding-right 2.35rem .fil-path-backdrop.mobile left 0 ================================================ FILE: src/modules/breadcrumb/utils/fetchFolder.js ================================================ import { useQuery } from 'cozy-client' import { buildFileOrFolderByIdQuery, buildSharedDriveFolderQuery } from '@/queries' export const fetchFolder = async ({ client, folderId, driveId }) => { const folderQuery = driveId ? buildSharedDriveFolderQuery({ driveId, folderId }) : buildFileOrFolderByIdQuery(folderId) const { options, definition } = folderQuery const folderQueryResults = await client.fetchQueryAndGetFromState({ definition: definition(), options }) return folderQueryResults.data } /** * Hook to fetch a folder from cozy stack * * @param {Object} params - The parameters for the function. * @param {string} params.folderId - The ID of the folder to fetch. * @param {string} [params.driveId] - The ID of the shared drive to fetch the folder from. * @returns {import('cozy-client/types/types').IOCozyFolder} The folder data. */ export const useFolder = ({ folderId, driveId }) => { const folderQuery = driveId ? buildSharedDriveFolderQuery({ driveId, folderId }) : buildFileOrFolderByIdQuery(folderId) const { options, definition } = folderQuery const folderQueryResults = useQuery(definition, options) return folderQueryResults.data } ================================================ FILE: src/modules/breadcrumb/utils/fetchFolder.spec.js ================================================ import { fetchFolder } from './fetchFolder' import { buildFileOrFolderByIdQuery } from '@/queries' jest.mock('queries') describe('fetchFolder', () => { const folderReturnedByCozyClient = 'folder' const result = { data: folderReturnedByCozyClient } const client = { fetchQueryAndGetFromState: jest.fn().mockReturnValue(result) } const folderId = '1234' const definition = jest.fn().mockReturnValue('definition') beforeEach(() => { buildFileOrFolderByIdQuery.mockReturnValue({ definition: definition, options: 'options' }) }) it('should return answer from fetchQueryAndGetFromState', async () => { // When const folder = await fetchFolder({ client, folderId }) // Then expect(folder).toEqual(folderReturnedByCozyClient) }) it('should call fetchQueryAndGetFromState with correct definition and options', async () => { // When await fetchFolder({ client, folderId }) // Then expect(definition).toHaveBeenCalledWith() expect(client.fetchQueryAndGetFromState).toHaveBeenCalledWith({ definition: 'definition', options: 'options' }) }) }) ================================================ FILE: src/modules/certifications/CertificationTooltip.jsx ================================================ import React from 'react' import Tooltip from 'cozy-ui/transpiled/react/Tooltip' import Typography from 'cozy-ui/transpiled/react/Typography' const CertificationTooltip = ({ body, caption, content }) => { return ( {body} {caption} } > {content} ) } export default CertificationTooltip ================================================ FILE: src/modules/certifications/index.jsx ================================================ import PropTypes from 'prop-types' import { CarbonCopy as CarbonCopyCell, ElectronicSafe as ElectronicSafeCell } from '@/modules/filelist/cells' import { CarbonCopy as CarbonCopyHeader, ElectronicSafe as ElectronicSafeHeader } from '@/modules/filelist/headers' export const extraColumnsSpecs = { carbonCopy: { query: ({ queryBuilder, currentFolderId, sharedDocumentIds, attribute }) => queryBuilder({ currentFolderId, sharedDocumentIds, attribute }), condition: ({ conditionBuilder, files, attribute }) => conditionBuilder({ files, attribute }), label: 'carbonCopy', HeaderComponent: CarbonCopyHeader, CellComponent: CarbonCopyCell }, electronicSafe: { query: ({ queryBuilder, currentFolderId, sharedDocumentIds, attribute }) => queryBuilder({ currentFolderId, sharedDocumentIds, attribute }), condition: ({ conditionBuilder, files, attribute }) => conditionBuilder({ files, attribute }), label: 'electronicSafe', HeaderComponent: ElectronicSafeHeader, CellComponent: ElectronicSafeCell } } const extraColumnPropTypes = PropTypes.shape({ query: PropTypes.func, condition: PropTypes.func, label: PropTypes.string, HeaderComponent: PropTypes.func, CellComponent: PropTypes.func }) export const extraColumnsPropTypes = PropTypes.arrayOf(extraColumnPropTypes) /** * Returns the columns names according to the media * @param {object} params - Params * @param {boolean} params.isMobile - Whether the breakpoint is mobile * @param {string[]} params.mobileExtraColumnsNames - Names of the columns to be shown in mobile * @param {string[]} params.desktopExtraColumnsNames - Names of the columns to be shown in desktop * @returns {string[]} Names of the columns */ export const makeExtraColumnsNamesFromMedia = ({ isMobile, mobileExtraColumnsNames, desktopExtraColumnsNames }) => (isMobile ? mobileExtraColumnsNames : desktopExtraColumnsNames) ================================================ FILE: src/modules/certifications/useExtraColumns.jsx ================================================ import { useEffect, useMemo } from 'react' import { useClient } from 'cozy-client' import { extraColumnsSpecs } from '@/modules/certifications/' /** * @typedef {object} ExtraColumn * @property {function} query - The query function. * @property {function} condition - The condition function. * @property {string} label - The label of the column. * @property {function} HeaderComponent - The header component. * @property {function} CellComponent - The cell component. */ // TODO: some ways to improve: // instead of passing currentFolderId, sharedDocumentIds (related to the query) // and files (related to the condition), maybe we could pass // the query/condition with its parameters /** * Custom hook that adds extra columns to a table based on the provided configuration. * * @param {object} options - The options for configuring the extra columns. * @param {string[]} [options.columnsNames] - The names of the columns to add. * @param {function} [options.queryBuilder] - The query builder for fetching data. * @param {function} [options.conditionBuilder] - The condition builder for filtering data. * @param {string} [options.currentFolderId] - The ID of the current folder. * @param {string[]} [options.sharedDocumentIds] - The IDs of the shared documents. * @param {object[]} [options.files] - The files to display in the table. * @returns {object[]} - The extra columns to add to the table. */ export const useExtraColumns = ({ columnsNames, queryBuilder, conditionBuilder, currentFolderId, sharedDocumentIds, files }) => { const client = useClient() const columnsSpecs = useMemo( () => columnsNames.map(columnName => extraColumnsSpecs[columnName]), [columnsNames] ) useEffect(() => { if (!queryBuilder) { return } for (let columnSpec of columnsSpecs) { if (!columnSpec.query) { continue } const opts = { queryBuilder, currentFolderId, sharedDocumentIds, attribute: columnSpec.label } const def = columnSpec.query(opts).definition() client.query(def, columnSpec.query(opts).options) } }, [client, columnsSpecs, currentFolderId, sharedDocumentIds, queryBuilder]) return columnsSpecs.filter(columnSpec => { if (conditionBuilder) { const opts = { conditionBuilder, files, attribute: columnSpec.label } return columnSpec.condition(opts) } else if (queryBuilder) { const opts = { queryBuilder, currentFolderId, sharedDocumentIds, attribute: columnSpec.label } const { fetchStatus, data } = client.getQueryFromState( columnSpec.query(opts).options.as ) return fetchStatus === 'loaded' && data.length > 0 } else { throw new Error( 'useExtraColumns must have queryBuilder or conditionBuilder' ) } }) } ================================================ FILE: src/modules/certifications/useExtraColumns.spec.jsx ================================================ import { renderHook } from '@testing-library/react' import React from 'react' import { createMockClient, models } from 'cozy-client' import { useExtraColumns } from './useExtraColumns' import AppLike from 'test/components/AppLike' const client = createMockClient({}) client.query = jest.fn() const setup = ({ columnsNames, queryBuilder, conditionBuilder, files }) => { const wrapper = ({ children }) => ( {children} ) return renderHook( () => useExtraColumns({ columnsNames, queryBuilder, conditionBuilder, currentFolderId: '123', sharedDocumentIds: '456', files }), { wrapper } ) } describe('useExtraColumns', () => { it('should return error if no queryBuilder or conditionBuilder passed', () => { jest.spyOn(console, 'error').mockImplementation() expect(() => setup({ columnsNames: ['carbonCopy'] })).toThrow( 'useExtraColumns must have queryBuilder or conditionBuilder' ) }) }) describe('useExtraColumns : queryBuilder', () => { it('should not query anything if no queryBuilder passed', () => { jest.spyOn(console, 'error').mockImplementation() expect(() => setup({ columnsNames: ['carbonCopy'] })).toThrow() expect(client.query).not.toHaveBeenCalled() }) it('should execute query if queryBuilder passed', async () => { setup({ columnsNames: ['carbonCopy'], queryBuilder: () => ({ definition: () => 'queryDefinition', options: 'queryOptions' }) }) expect(client.query).toHaveBeenCalled() }) it('should return carbonCopy column if the query result returns at least one file', async () => { // mock returned value for query checking if at least one file as carbonCopy metadata client.getQueryFromState = jest.fn(() => ({ fetchStatus: 'loaded', data: [{ id: '01', metadata: { carbonCopy: true } }] })) const { result } = setup({ columnsNames: ['carbonCopy'], queryBuilder: () => ({ definition: () => 'queryDefinition', options: 'queryOptions' }) }) expect( result.current.some(extraColumn => extraColumn.label === 'carbonCopy') ).toBeTruthy() }) }) describe('useExtraColumns : conditionBuilder', () => { const conditionBuilder = ({ files, attribute }) => files.some(file => models.file.hasMetadataAttribute({ file, attribute })) it('should return empty array if no files', async () => { const { result } = setup({ columnsNames: ['carbonCopy'], conditionBuilder, files: [] }) expect(result.current).toMatchObject([]) }) it('should return empty array if no columns names', async () => { const { result } = setup({ columnsNames: [], conditionBuilder, files: [{ id: '01' }] }) expect(result.current).toMatchObject([]) }) it('should return empty array if no files with matching metadata', async () => { const { result } = setup({ columnsNames: ['carbonCopy'], conditionBuilder, files: [{ id: '01' }] }) expect(result.current).toMatchObject([]) }) it('should return carbonCopy column if at least one file has carbonCopy metadata', async () => { const { result } = setup({ columnsNames: ['carbonCopy'], conditionBuilder, files: [{ id: '01', metadata: { carbonCopy: true } }] }) expect( result.current.some(extraColumn => extraColumn.label === 'carbonCopy') ).toBeTruthy() }) it('should not return carbonCopy column if this column is not wanted, even if a file has carbonCopy metadata', async () => { const { result } = setup({ columnsNames: ['electronicSafe'], conditionBuilder, files: [{ id: '01', metadata: { carbonCopy: true } }] }) expect(result.current).toMatchObject([]) }) }) ================================================ FILE: src/modules/drive/AddMenu/AddMenu.jsx ================================================ import React from 'react' import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu' import AddMenuContent from '@/modules/drive/AddMenu/AddMenuContent' const AddMenu = ({ anchorRef, handleClose, isUploadDisabled, canCreateFolder, canUpload, refreshFolderContent, isPublic, displayedFolder, isReadOnly, ...actionMenuProps }) => { return ( ) } export default AddMenu ================================================ FILE: src/modules/drive/AddMenu/AddMenuContent.jsx ================================================ import React, { forwardRef } from 'react' import flag from 'cozy-flags' import ActionsMenuMobileHeader from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuMobileHeader' import Divider from 'cozy-ui/transpiled/react/Divider' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import { useI18n } from 'twake-i18n' import AddFolderItem from '@/modules/drive/Toolbar/components/AddFolderItem' import CreateDocsItem from '@/modules/drive/Toolbar/components/CreateDocsItem' import CreateNoteItem from '@/modules/drive/Toolbar/components/CreateNoteItem' import CreateOnlyOfficeItem from '@/modules/drive/Toolbar/components/CreateOnlyOfficeItem' import CreateShortcut from '@/modules/drive/Toolbar/components/CreateShortcut' import { ScannerMenuItem } from '@/modules/drive/Toolbar/components/Scanner/ScannerMenuItem' import { useScannerContext } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider' import UploadItem from '@/modules/drive/Toolbar/components/UploadItem' import { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers' import { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider' import { isOfficeEditingEnabled } from '@/modules/views/OnlyOffice/helpers' const AddMenuContent = forwardRef( ( { isUploadDisabled, canCreateFolder, canUpload, refreshFolderContent, isPublic, displayedFolder, onClick, isReadOnly }, ref // eslint-disable-line no-unused-vars ) => { const { t } = useI18n() const { isDesktop } = useBreakpoints() const { hasScanner } = useScannerContext() const { showAlert } = useAlert() const handleReadOnlyClick = e => { e.stopPropagation() e.preventDefault() showAlert( t( 'AddMenu.readOnlyFolder', 'This is a read-only folder. You cannot perform this action.' ), 'warning' ) onClick() } const createActionOnClick = isReadOnly ? handleReadOnlyClick : onClick return ( <> {canCreateFolder && ( )} {!isPublic && ( )} {!isPublic && flag('drive.lasuitedocs.enabled') && ( )} {canUpload && isOfficeEditingEnabled(isDesktop) && ( <> )} {!isFromSharedDriveRecipient(displayedFolder) && ( )} {canUpload && !isUploadDisabled && ( )} {hasScanner && } ) } ) AddMenuContent.displayName = 'AddMenuContent' export default AddMenuContent ================================================ FILE: src/modules/drive/AddMenu/AddMenuContent.spec.jsx ================================================ import { render, waitFor } from '@testing-library/react' import React from 'react' import { useAppLinkWithStoreFallback } from 'cozy-client' import AddMenuContent from './AddMenuContent' import AppLike from 'test/components/AppLike' import { setupFolderContent, mockCozyClientRequestQuery } from 'test/setup' import { ScannerProvider } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider' jest.mock('cozy-client/dist/hooks/useAppLinkWithStoreFallback', () => jest.fn()) jest.mock('cozy-keys-lib', () => ({ useVaultClient: jest.fn() })) mockCozyClientRequestQuery() const setup = async ( { folderId = 'directory-foobar0' } = {}, { isUploadDisabled = false, canCreateFolder = false, canUpload = true, refreshFolderContent = true, isPublic = false, isReadOnly = false } = {} ) => { const { client, store } = await setupFolderContent({ folderId }) const displayedFolder = folderId ? { id: folderId } : folderId client.stackClient.uri = 'http://cozy.localhost' const root = render( {}} isReadOnly={isReadOnly} /> ) return { root } } describe('AddMenuContent', () => { describe('Menu', () => { beforeAll(() => { useAppLinkWithStoreFallback.mockReturnValue({ fetchStatus: 'loaded', isInstalled: true }) }) it('does not display createNote on public Page', async () => { await waitFor(async () => { const { root } = await setup( { folderId: 'directory-foobar0' }, { isPublic: true } ) const { queryByText } = root expect(queryByText('Note')).toBeNull() }) }) it('displays createNote on private Page', async () => { await waitFor(async () => { const { root } = await setup( { folderId: 'directory-foobar0' }, { isPublic: false } ) const { queryByText } = root expect(queryByText('Note')).toBeTruthy() }) }) }) }) ================================================ FILE: src/modules/drive/AddMenu/AddMenuProvider.jsx ================================================ import React, { useState, useCallback, useRef, useMemo, createContext } from 'react' import useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import logger from '@/lib/logger' import AddMenu from '@/modules/drive/AddMenu/AddMenu' import { closeMenu, toggleMenu } from '@/modules/drive/Toolbar/components/MoreMenu' import { ScannerProvider } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider' export const AddMenuContext = createContext() const AddMenuProvider = ({ disabled, canCreateFolder, canUpload, refreshFolderContent, children, isPublic, displayedFolder, isSelectionBarVisible, componentsProps, isReadOnly }) => { const [menuIsVisible, setMenuVisible] = useState(false) const isOffline = useBrowserOffline() const anchorRef = useRef() const { showAlert } = useAlert() const { t } = useI18n() const handleClose = useCallback( () => closeMenu(setMenuVisible), [setMenuVisible] ) const handleToggle = useCallback( () => toggleMenu(menuIsVisible, setMenuVisible), [menuIsVisible, setMenuVisible] ) const isDisabled = useMemo( () => disabled || isSelectionBarVisible, [disabled, isSelectionBarVisible] ) const handleOfflineClick = useCallback( e => { e.stopPropagation() showAlert({ message: t('alert.offline'), severity: 'error' }) logger.error( `Offline click on AddMenu button detected. Here is the value of window.navigator.onLine: ${window.navigator.onLine}` ) }, [showAlert, t] ) return ( {children} {menuIsVisible && ( )} ) } export default React.memo(AddMenuProvider) ================================================ FILE: src/modules/drive/AddMenu/AddMenuProvider.spec.jsx ================================================ import { fireEvent, render } from '@testing-library/react' import React, { useContext } from 'react' import { createMockClient } from 'cozy-client' import AddMenuProvider, { AddMenuContext } from './AddMenuProvider' import AppLike from 'test/components/AppLike' import logger from '@/lib/logger' jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: jest.fn() })) jest.mock('lib/logger', () => ({ error: jest.fn() })) const client = createMockClient({}) describe('AddMenuContext', () => { it('should log exception on click offline on add button', () => { // Given const Component = () => { const { handleOfflineClick } = useContext(AddMenuContext) return
) } export default React.memo(AddButton) ================================================ FILE: src/modules/drive/Toolbar/components/AddFolderItem.jsx ================================================ import React from 'react' import { connect } from 'react-redux' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import IconFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { showNewFolderInput } from '@/modules/filelist/duck' const AddFolderItem = ({ addFolder, onClick, isReadOnly }) => { const { t } = useI18n() const { showAlert } = useAlert() const handleClick = () => { if (isReadOnly) { showAlert({ message: t( 'AddMenu.readOnlyFolder', 'This is a read-only folder. You cannot perform this action.' ), severity: 'warning' }) onClick() return } addFolder() onClick() } return ( ) } const mapDispatchToProps = dispatch => ({ addFolder: () => setTimeout(() => { dispatch(showNewFolderInput()) }, 0) }) export default connect(null, mapDispatchToProps)(AddFolderItem) ================================================ FILE: src/modules/drive/Toolbar/components/AddMenuItem.jsx ================================================ import React, { useContext } from 'react' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useI18n } from 'twake-i18n' import { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider' const AddMenuItem = ({ onClick }) => { const { t } = useI18n() const { anchorRef, handleToggle, isDisabled, handleOfflineClick, isOffline, a11y } = useContext(AddMenuContext) const handleClick = () => { isOffline ? handleOfflineClick() : handleToggle() onClick() } return ( } /> ) } export default AddMenuItem ================================================ FILE: src/modules/drive/Toolbar/components/CreateDocsItem.jsx ================================================ import get from 'lodash/get' import React from 'react' import { useClient, generateWebLink, useCapabilities } from 'cozy-client' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import IconDocs from '@/assets/icons/icon-docs.svg' import { displayedFolderOrRootFolder } from '@/hooks/helpers' const CreateDocsItem = ({ displayedFolder, isReadOnly, onClick }) => { const client = useClient() const { t } = useI18n() const { capabilities } = useCapabilities(client) const isFlatDomain = get(capabilities, 'flat_subdomains') const { showAlert } = useAlert() const _displayedFolder = displayedFolderOrRootFolder(displayedFolder) const handleClick = async () => { if (isReadOnly) { showAlert({ message: t( 'AddMenu.readOnlyFolder', 'This is a read-only folder. You cannot perform this action.' ), severity: 'warning' }) onClick() return } const url = generateWebLink({ slug: 'docs', cozyUrl: client.getStackClient().uri, subDomainType: isFlatDomain ? 'flat' : 'nested', pathname: '', hash: `/bridge/docs/new/${_displayedFolder._id}` }) window.location.href = url } return ( ) } export default CreateDocsItem ================================================ FILE: src/modules/drive/Toolbar/components/CreateNoteItem.jsx ================================================ import get from 'lodash/get' import React from 'react' import { useNavigate } from 'react-router-dom' import { withClient, generateWebLink, models, useAppLinkWithStoreFallback, useCapabilities } from 'cozy-client' import { isFlagshipApp } from 'cozy-device-helper' import flag from 'cozy-flags' import { useWebviewIntent } from 'cozy-intent' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import IconNote from 'cozy-ui/transpiled/react/Icons/FileTypeNote' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { generateUniversalLink } from 'cozy-ui-plus/dist/AppLinker/native' import { translate } from 'twake-i18n' import { displayedFolderOrRootFolder } from '@/hooks/helpers' const CreateNoteItem = ({ client, t, displayedFolder, isReadOnly, onClick }) => { const { capabilities } = useCapabilities(client) const isFlatDomain = get(capabilities, 'flat_subdomains') const webviewIntent = useWebviewIntent() const { showAlert } = useAlert() const navigate = useNavigate() const _displayedFolder = displayedFolderOrRootFolder(displayedFolder) const { driveId, id: folderId } = _displayedFolder const { fetchStatus, url, isInstalled } = useAppLinkWithStoreFallback( 'notes', client ) if (fetchStatus !== 'loaded' || !isInstalled) { return null } const notesAppUrl = url let returnUrl = '' if ( (isFlagshipApp() && webviewIntent) || flag('cozy.universal-link.disabled') ) { returnUrl = generateWebLink({ slug: 'drive', cozyUrl: client.getStackClient().uri, subDomainType: isFlatDomain ? 'flat' : 'nested', pathname: '', hash: `/files/${folderId}` }) } else { returnUrl = generateUniversalLink({ slug: 'drive', cozyUrl: client.getStackClient().uri, subDomainType: isFlatDomain ? 'flat' : 'nested', nativePath: driveId ? `/shareddrive/${driveId}/files/${folderId}` : `/files/${folderId}` }) } const handleClick = async () => { if (isReadOnly) { showAlert({ message: t( 'AddMenu.readOnlyFolder', 'This is a read-only folder. You cannot perform this action.' ), severity: 'warning' }) onClick() return } if (notesAppUrl === undefined) return const { data: file } = await client .collection('io.cozy.notes', { driveId }) .create({ dir_id: folderId }) if (driveId) { navigate(`/note/${driveId}/${file.id}`) return } const privateUrl = await models.note.generatePrivateUrl(notesAppUrl, file, { returnUrl }) /** * Not using AppLinker here because it would require too much refactoring and would be risky * Instead we use the webviewIntent programmatically to open the cozy-note app on the note href */ if (isFlagshipApp() && webviewIntent) return webviewIntent.call('openApp', privateUrl, { slug: 'notes' }) window.location.href = privateUrl } return ( ) } export default translate()(withClient(CreateNoteItem)) ================================================ FILE: src/modules/drive/Toolbar/components/CreateOnlyOfficeItem.jsx ================================================ import React, { useCallback, useMemo } from 'react' import { useParams, useNavigate } from 'react-router-dom' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config' import { makeOnlyOfficeIconByClass, canWriteOfficeDocument } from '@/modules/views/OnlyOffice/helpers' const CreateOnlyOfficeItem = ({ fileClass, isReadOnly, onClick }) => { const { folderId = ROOT_DIR_ID, driveId = undefined } = useParams() const { t } = useI18n() const navigate = useNavigate() const { showAlert } = useAlert() const _folderId = folderId === TRASH_DIR_ID ? ROOT_DIR_ID : folderId const handleClick = useCallback(() => { if (isReadOnly) { showAlert({ message: t( 'AddMenu.readOnlyFolder', 'This is a read-only folder. You cannot perform this action.' ), severity: 'warning' }) onClick() return } if (canWriteOfficeDocument()) { navigate( driveId ? `/onlyoffice/create/${driveId}/${_folderId}/${fileClass}` : `/onlyoffice/create/${_folderId}/${fileClass}` ) } else { navigate( driveId ? `/onlyoffice/${driveId}/${_folderId}/paywall` : `/folder/${_folderId}/paywall` ) } }, [ isReadOnly, showAlert, t, onClick, navigate, driveId, _folderId, fileClass ]) const ClassIcon = useMemo( () => makeOnlyOfficeIconByClass(fileClass), [fileClass] ) return ( ) } export default React.memo(CreateOnlyOfficeItem) ================================================ FILE: src/modules/drive/Toolbar/components/CreateShortcut.jsx ================================================ import React from 'react' import { connect } from 'react-redux' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import DeviceBrowserIcon from 'cozy-ui/transpiled/react/Icons/DeviceBrowser' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import ShortcutCreationModal from './ShortcutCreationModal' import { showModal } from '@/lib/react-cozy-helpers' const CreateShortcutWrapper = ({ openModal, onClick, isReadOnly }) => { const { t } = useI18n() const { showAlert } = useAlert() const handleClick = () => { if (isReadOnly) { showAlert({ message: t( 'AddMenu.readOnlyFolder', 'This is a read-only folder. You cannot perform this action.' ), severity: 'warning' }) onClick() return } openModal() onClick() } return ( ) } const mapDispatchToProps = (dispatch, ownProps) => ({ openModal: () => dispatch( showModal() ) }) export default connect(null, mapDispatchToProps)(CreateShortcutWrapper) ================================================ FILE: src/modules/drive/Toolbar/components/DownloadButtonItem.jsx ================================================ import React from 'react' import { useClient } from 'cozy-client' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { downloadFiles } from '@/modules/actions/utils' const DownloadButtonItem = ({ files }) => { const { showAlert } = useAlert() const { t } = useI18n() const client = useClient() const handleClick = () => { downloadFiles(client, files, { showAlert, t }) } return ( ) } export default DownloadButtonItem ================================================ FILE: src/modules/drive/Toolbar/components/FavoritesItem.jsx ================================================ import React from 'react' import { useClient } from 'cozy-client' import { splitFilename } from 'cozy-client/dist/models/file' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import StarIcon from 'cozy-ui/transpiled/react/Icons/Star' import StarOutlineIcon from 'cozy-ui/transpiled/react/Icons/StarOutline' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' const FavoritesItem = ({ displayedFolder }) => { const { showAlert } = useAlert() const { t } = useI18n() const client = useClient() const isFavorite = displayedFolder?.cozyMetadata?.favorite const labelKey = isFavorite ? 'remove' : 'add' const handleClick = async () => { if (!displayedFolder) return try { await client.save({ ...displayedFolder, cozyMetadata: { ...displayedFolder.cozyMetadata, favorite: !isFavorite } }) const { filename } = splitFilename(displayedFolder) showAlert({ message: t(`favorites.success.${labelKey}`, { filename, smart_count: 1 }), severity: 'success' }) } catch (_error) { showAlert({ message: t('favorites.error'), severity: 'error' }) } } const icon = isFavorite ? StarIcon : StarOutlineIcon return ( ) } export default FavoritesItem ================================================ FILE: src/modules/drive/Toolbar/components/InsideRegularFolder.jsx ================================================ import { ROOT_DIR_ID } from '@/constants/config' /** * Displays its children only if we are in a normal folder (eg. not the root folder or a special view like sharings or recent) */ const InsideRegularFolder = ({ children, displayedFolder, folderId }) => { const insideRegularFolder = folderId && displayedFolder && displayedFolder.id !== ROOT_DIR_ID if (insideRegularFolder) { return children } return null } export default InsideRegularFolder ================================================ FILE: src/modules/drive/Toolbar/components/InsideRegularFolder.spec.jsx ================================================ import { render } from '@testing-library/react' import React from 'react' import InsideRegularFolder from './InsideRegularFolder' jest.mock('hooks') describe('InsideRegularFolder', () => { it('should return null when insideRegularFolder undefined', () => { const { container } = render(
) expect(container).toBeEmptyDOMElement() }) it('should return children when insideRegularFolder true', () => { const { container } = render(
) expect(container).not.toBeEmptyDOMElement() }) }) ================================================ FILE: src/modules/drive/Toolbar/components/LeaveSharedDriveButtonItem.jsx ================================================ import React from 'react' import { useNavigate } from 'react-router-dom' import { useClient } from 'cozy-client' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import LogoutIcon from 'cozy-ui/transpiled/react/Icons/Logout' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { getSharingIdFromRelationships } from '@/modules/shareddrives/helpers' const LeaveSharedDriveButtonItem = ({ files }) => { const { t } = useI18n() const client = useClient() const navigate = useNavigate() const { showAlert } = useAlert() const handleClick = async () => { const file = files[0] const sharingId = getSharingIdFromRelationships(file) if (sharingId) { await client.collection('io.cozy.sharings').revokeSelf({ _id: sharingId }) showAlert({ message: t('Files.share.revokeSelf.success'), severity: 'success' }) navigate('/sharings') } } return ( ) } export default LeaveSharedDriveButtonItem ================================================ FILE: src/modules/drive/Toolbar/components/MoreMenu.jsx ================================================ import React, { useState, useCallback, useRef } from 'react' import { useSharingContext } from 'cozy-sharing' import ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu' import Divider from 'cozy-ui/transpiled/react/Divider' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import { MoreButton } from '@/components/Button' import AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider' import AddMenuItem from '@/modules/drive/Toolbar/components/AddMenuItem' import DownloadButtonItem from '@/modules/drive/Toolbar/components/DownloadButtonItem' import FavoritesItem from '@/modules/drive/Toolbar/components/FavoritesItem' import InsideRegularFolder from '@/modules/drive/Toolbar/components/InsideRegularFolder' import LeaveSharedDriveButtonItem from '@/modules/drive/Toolbar/components/LeaveSharedDriveButtonItem' import DeleteItem from '@/modules/drive/Toolbar/delete/DeleteItem' import MoveItem from '@/modules/drive/Toolbar/move/MoveItem' import PersonalizeFolderItem from '@/modules/drive/Toolbar/personalizeFolder/PersonalizeFolderItem' import SelectableItem from '@/modules/drive/Toolbar/selectable/SelectableItem' import ShareItem from '@/modules/drive/Toolbar/share/ShareItem' export const openMenu = setMenuVisible => { setMenuVisible(true) } export const closeMenu = setMenuVisible => { setMenuVisible(false) } export const toggleMenu = (menuIsVisible, setMenuVisible) => { if (menuIsVisible) return closeMenu(setMenuVisible) openMenu(setMenuVisible) } const MoreMenu = ({ isDisabled, hasWriteAccess, canUpload, canCreateFolder, displayedFolder, folderId, showSelectionBar, isSelectionBarVisible, isSharedWithMe, isSharedDriveRecipient }) => { const [menuIsVisible, setMenuVisible] = useState(false) const anchorRef = useRef() const { isMobile } = useBreakpoints() const { allLoaded } = useSharingContext() // We need to wait for the sharing context to be completely loaded to avoid race conditions const handleToggle = useCallback( () => toggleMenu(menuIsVisible, setMenuVisible), [menuIsVisible, setMenuVisible] ) const handleClose = useCallback( () => closeMenu(setMenuVisible), [setMenuVisible] ) return (
{menuIsVisible && ( {allLoaded && ( )} {!isSharedDriveRecipient && ( )} {isMobile && hasWriteAccess && } {hasWriteAccess && !isSharedDriveRecipient && ( )} {hasWriteAccess && ( )} {isSharedDriveRecipient && isSharedWithMe && ( )} )}
) } export default React.memo(MoreMenu) ================================================ FILE: src/modules/drive/Toolbar/components/MoreMenu.spec.jsx ================================================ import { render, fireEvent } from '@testing-library/react' import React from 'react' import MoreMenu from './MoreMenu' import AppLike from 'test/components/AppLike' import { setupFolderContent, mockCozyClientRequestQuery } from 'test/setup' import { downloadFiles } from '@/modules/actions/utils' jest.mock('modules/actions/utils', () => ({ downloadFiles: jest.fn().mockResolvedValue() })) mockCozyClientRequestQuery() describe('MoreMenu', () => { const setup = async ({ folderId = 'directory-foobar0' } = {}) => { const { client, store } = await setupFolderContent({ folderId }) client.stackClient.uri = 'http://cozy.tools' const result = render( ) const { getByTestId } = result fireEvent.click(getByTestId('more-button')) return { ...result, store, client } } describe('DownloadButton', () => { it('download files', async () => { // TODO: remove it when DeleteItem get props jest.spyOn(console, 'error').mockImplementation() // TODO : Fix https://github.com/cozy/cozy-drive/issues/2913 jest.spyOn(console, 'warn').mockImplementation() const { getByText } = await setup() fireEvent.click(getByText('Download folder')) expect(downloadFiles).toHaveBeenCalled() }) }) }) ================================================ FILE: src/modules/drive/Toolbar/components/Scanner/Scanner.spec.tsx ================================================ import { render, fireEvent, waitFor } from '@testing-library/react' import React from 'react' import { createMockClient } from 'cozy-client' import { useWebviewIntent } from 'cozy-intent' // @ts-expect-error Component is not typed import AppLike from 'test/components/AppLike' import { ScannerMenuItem } from '@/modules/drive/Toolbar/components/Scanner/ScannerMenuItem' import { ScannerProvider } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider' import { uploadFiles } from '@/modules/navigation/duck' const MockApp = ({ id = 'test', onClick = jest.fn() }): JSX.Element => ( ) jest.mock('cozy-device-helper', () => ({ ...jest.requireActual('cozy-device-helper'), isFlagshipApp: (): boolean => true })) const mockUseWebviewIntent = useWebviewIntent as jest.Mock jest.mock('cozy-intent', () => ({ useWebviewIntent: jest.fn() })) const mockUploadFiles = uploadFiles as jest.Mock jest.mock('modules/navigation/duck', () => ({ uploadFiles: jest .fn() // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment .mockImplementation(arg => ({ type: 'test', payload: arg })) })) jest.spyOn(console, 'log').mockImplementation(() => jest.fn()) // Test suite for the Scanner functionality describe('Scanner', () => { // Before each test, clear all mocks to ensure a clean state beforeEach(() => { jest.clearAllMocks() }) // Test case: Ensure that nothing is rendered if the scanner is not available it('renders nothing if the scanner is not available', () => { // Mock the useWebviewIntent hook to always return false for scanner availability mockUseWebviewIntent.mockReturnValue({ call: jest.fn().mockResolvedValue(false) }) // Render the component under test const { queryByTestId } = render() // Assert that the scan-doc element is not present in the DOM expect(queryByTestId('scan-doc')).toBeNull() }) // Test case: Check if an ActionMenuItem is rendered when the scanner is available it('renders an ActionMenuItem if the folder is available', async () => { // Mock the useWebviewIntent hook to simulate scanner availability mockUseWebviewIntent.mockReturnValue({ call: jest.fn((method, arg) => { if (method === 'isAvailable' && arg === 'scanner') { return Promise.resolve(true) } return Promise.resolve(false) }) }) // Render the component under test const { queryByTestId } = render() // Wait for the scanner to become available and assert that the scan-doc element is present await waitFor(() => { expect(queryByTestId('scan-doc')).not.toBeNull() }) }) // Test case: Simulate a click event and verify the startScanner function is called it('calls the startScanner function on click', async () => { // Mock the useWebviewIntent hook with custom logic for scanner availability and document scanning mockUseWebviewIntent.mockReturnValue({ call: jest.fn((method, arg) => { if (method === 'isAvailable' && arg === 'scanner') { return Promise.resolve(true) } if (method === 'scanDocument') { return Promise.resolve('base64jpeg') } return Promise.resolve(false) }) }) const onClickMock = jest.fn() // Render the component under test const { queryByTestId } = render() // Wait for the scan-doc element to be clickable and then simulate a click event await waitFor(() => { queryByTestId('scan-doc') as HTMLButtonElement fireEvent.click( queryByTestId('scan-doc')?.firstChild as HTMLButtonElement ) }) // Create a mock File object const mockFile = new File([], 'testfile') // Assert that mockUploadFiles was called once with the expected arguments expect(mockUploadFiles).toHaveBeenCalledTimes(1) const calls = mockUploadFiles.mock.calls as unknown[][] expect(onClickMock).toHaveBeenCalledTimes(1) expect(calls[0][0]).toEqual([mockFile]) // File expect(calls[0][1]).toBe('test') // Directory ID expect(calls[0][2]).toEqual({ isScanned: true }) // Upload options expect(typeof calls[0][3]).toBe('function') // Success callback // Dependencies expect(calls[0][4]).toMatchObject({ client: expect.anything() as Record, t: expect.anything() as (key: string) => string }) }) // Test case: Handle unexpected errors gracefully it('handles unexpected errors', async () => { const mockConsoleError = jest .spyOn(console, 'log') .mockImplementation(() => { // noop }) // Mock the useWebviewIntent hook to throw an error mockUseWebviewIntent.mockReturnValue({ call: jest.fn((method, arg) => { if (method === 'isAvailable' && arg === 'scanner') { return Promise.resolve(true) } if (method === 'scanDocument') { return Promise.reject(new Error('test error')) } return Promise.resolve(false) }) }) // Render the component under test const { queryByTestId } = render() // Wait for the scan-doc element to be clickable and then simulate a click event await waitFor(() => { queryByTestId('scan-doc') as HTMLButtonElement fireEvent.click( queryByTestId('scan-doc')?.firstChild as HTMLButtonElement ) }) // Wait for the component to react to the error and assert that the scan-doc element is not present await waitFor(() => { expect( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call mockConsoleError.mock.calls.some(call => call[0].includes('test error')) ).toBe(true) expect(queryByTestId('scan-doc')).not.toBeNull() }) }) }) ================================================ FILE: src/modules/drive/Toolbar/components/Scanner/ScannerMenuItem.tsx ================================================ import React from 'react' import logger from 'cozy-logger' import ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import Icon from 'cozy-ui/transpiled/react/Icon' import CameraIcon from 'cozy-ui/transpiled/react/Icons/Camera' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import { useI18n } from 'twake-i18n' import { useScannerContext } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider' const log = logger.namespace('Toolbar/components/Scanner/ScannerMenuItem') /** * Renders a scanner menu item. * @returns The JSX element representing the scanner menu item. */ interface ScannerMenuItemProps { onClick: () => void } export const ScannerMenuItem = ({ onClick }: ScannerMenuItemProps): JSX.Element | null => { const { t } = useI18n() const { hasScanner, startScanner } = useScannerContext() const handleClick = (): void => { if (startScanner) { startScanner().catch((error: Error) => { log('error', `Failed to start scanner: ${error.message}`) }) } onClick() } return hasScanner ? ( ) : null } ================================================ FILE: src/modules/drive/Toolbar/components/Scanner/ScannerProvider.tsx ================================================ import React, { useContext } from 'react' import { useScannerService } from '@/modules/drive/Toolbar/components/Scanner/useScannerService' interface ScannerContextValue { startScanner?: () => Promise hasScanner: boolean } interface ScannerProviderProps { children: React.ReactNode displayedFolder: { id: string } } /** * Context object for the Scanner component. */ export const ScannerContext = React.createContext({ startScanner: undefined, hasScanner: false }) export const useScannerContext = (): ScannerContextValue => useContext(ScannerContext) /** * Provides the scanner functionality. * * @param props - The component props. * @returns The scanner provider component. */ export const ScannerProvider = ({ children, displayedFolder }: ScannerProviderProps): JSX.Element => { const scanner = useScannerService(displayedFolder) return ( {children} ) } ================================================ FILE: src/modules/drive/Toolbar/components/Scanner/useScannerService.ts ================================================ import { useState, useEffect, useCallback } from 'react' import { useDispatch } from 'react-redux' import { useClient } from 'cozy-client' import { useWebviewIntent } from 'cozy-intent' import logger from 'cozy-logger' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { getErrorMessage, getFileFromBase64, getUniqueNameFromPrefix } from '@/modules/drive/helpers' import { uploadFiles } from '@/modules/navigation/duck' /** * Custom hook that provides scanner functionality. * @returns An object with the following properties: * - hasScanner: A boolean indicating whether the scanner is available. * - scanDocument: A function that returns a promise resolving to a string representing the scanned document in base64 format. */ export const useScannerService = (displayedFolder: { id: string driveId: string }): { hasScanner: boolean startScanner: () => Promise } => { const [hasScanner, setHasScanner] = useState(false) const webviewIntent = useWebviewIntent() const dispatch = useDispatch() const client = useClient() const { t } = useI18n() const { showAlert } = useAlert() useEffect(() => { const initScanner = async (): Promise => { try { const res = await webviewIntent?.call('isAvailable', 'scanner') setHasScanner(Boolean(res)) } catch (error) { logger('error', `scanner won't be available, ${getErrorMessage(error)}`) } } if (webviewIntent) { void initScanner() } }, [webviewIntent]) const scanDocument = useCallback(async (): Promise => { logger('info', 'Starting scanner') const base64 = (await webviewIntent?.call( 'scanDocument' )) as unknown as string if (!base64) throw new Error('No base64 returned by scanDocument') logger('info', `Scan done, base64 trimmed: ${base64.slice(0, 20)}...`) return base64 }, [webviewIntent]) const startScanner = useCallback(async () => { try { if (!displayedFolder) return const base64 = await scanDocument() const payload = uploadFiles( [ getFileFromBase64( base64, getUniqueNameFromPrefix('scan'), 'image/jpeg' ) ], displayedFolder.id, { isScanned: true }, () => logger('info', `File uploaded successfully`), { client, showAlert, t }, displayedFolder.driveId, undefined ) dispatch(payload) } catch (error) { logger('error', `startScanner error, ${getErrorMessage(error)}`) showAlert({ message: t('ImportToDrive.error'), severity: 'error' }) } }, [displayedFolder, scanDocument, dispatch, client, t, showAlert]) return { hasScanner, startScanner } } ================================================ FILE: src/modules/drive/Toolbar/components/SearchButton.jsx ================================================ import React, { useCallback } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import Magnifier from 'cozy-ui/transpiled/react/Icons/Magnifier' import { useI18n } from 'twake-i18n' const SearchButton = () => { const { t } = useI18n() const navigate = useNavigate() const { pathname } = useLocation() const goToSearch = useCallback(() => { navigate(`/search?returnPath=${pathname}`) }, [navigate, pathname]) return ( ) } export default SearchButton ================================================ FILE: src/modules/drive/Toolbar/components/ShortcutCreationModal.jsx ================================================ import React, { useCallback, useEffect, useState } from 'react' import { useClient } from 'cozy-client' import { isIOS } from 'cozy-device-helper' import Button from 'cozy-ui/transpiled/react/Buttons' import { FixedDialog } from 'cozy-ui/transpiled/react/CozyDialogs' import InputAdornment from 'cozy-ui/transpiled/react/InputAdornment' import Stack from 'cozy-ui/transpiled/react/Stack' import TextField from 'cozy-ui/transpiled/react/TextField' import useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { useDisplayedFolder } from '@/hooks' import { displayedFolderOrRootFolder } from '@/hooks/helpers' import { DOCTYPE_FILES_SHORTCUT } from '@/lib/doctypes' import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider' const ENTER_KEY = 13 const isURLValid = url => { try { new URL(url) return true } catch (_e) { return false } } const makeURLValid = str => { if (isURLValid(str)) return str else if (isURLValid(`https://${str}`)) return `https://${str}` return false } const ShortcutCreationModal = ({ onClose, onCreated }) => { const { displayedFolder } = useDisplayedFolder() const { t } = useI18n() const [fileName, setFilename] = useState('') const [url, setUrl] = useState('') const client = useClient() const { showAlert } = useAlert() const isOffline = useBrowserOffline() const { addItems } = useNewItemHighlightContext() const _displayedFolder = displayedFolderOrRootFolder(displayedFolder) const createShortcut = useCallback(async () => { if (!fileName || !url) { showAlert({ message: t('Shortcut.needs_info'), severity: 'error' }) return } const makedURL = makeURLValid(url) if (!makedURL) { showAlert({ message: t('Shortcut.url_badformat'), severity: 'error' }) return } try { if (isOffline) { showAlert({ message: t('alert.offline'), severity: 'error' }) } else { const response = await client.save({ _type: DOCTYPE_FILES_SHORTCUT, dir_id: _displayedFolder.id, name: fileName.endsWith('.url') ? fileName : fileName + '.url', url: makedURL }) const createdShortcut = response?.data ?? response if (createdShortcut) { addItems([createdShortcut]) } showAlert({ message: t('Shortcut.created'), severity: 'success' }) if (onCreated) onCreated() } onClose() } catch (error) { if ( error.message.includes( 'NetworkError when attempting to fetch resource.' ) ) { showAlert({ message: t('upload.alert.network'), severity: 'error' }) } else if ( error.message.includes( 'Invalid filename containing illegal character(s):' ) ) { showAlert({ message: t('alert.file_name_illegal_characters', { fileName, characters: error.message.split( 'Invalid filename containing illegal character(s): ' )[1] }), severity: 'error', duration: 2000 }) } else if (error.message.includes('Invalid filename:')) { showAlert({ message: t('alert.file_name_illegal_name', { fileName }), severity: 'error' }) } else if (error.message.includes('Missing name argument')) { showAlert({ message: t('alert.file_name_missing'), severity: 'error' }) } else { showAlert({ message: t('Shortcut.errored'), severity: 'error' }) } } }, [ client, fileName, onClose, onCreated, t, url, _displayedFolder, isOffline, showAlert, addItems ]) const handleKeyDown = e => { if (e.keyCode === ENTER_KEY) { createShortcut() } } useEffect(() => { const timeout = setTimeout(() => { if (isIOS()) window.scrollTo(0, 0) }, 30) return () => clearTimeout(timeout) }, []) return (
setUrl(e.target.value)} onKeyDown={e => handleKeyDown(e)} fullWidth margin="normal" autoFocus />
setFilename(e.target.value)} fullWidth margin="normal" onKeyDown={e => handleKeyDown(e)} InputProps={{ endAdornment: ( .url ) }} />
} actions={ <>
) } export default translate()(FilenameInput) ================================================ FILE: src/modules/filelist/FilenameInput.spec.jsx ================================================ 'use strict' import '@testing-library/jest-dom' import { render, fireEvent, screen, act } from '@testing-library/react' import React from 'react' import { createMockClient } from 'cozy-client' import FilenameInput from './FilenameInput' import AppLike from 'test/components/AppLike' describe('FilenameInput', () => { const client = createMockClient({ clientOptions: { uri: 'http://cozy.localhost:8080/' } }) const setup = ({ name = '', file = null, onSubmit = jest.fn(), onAbort = jest.fn(), onChange = jest.fn() } = {}) => { const root = render( ) return { root, onSubmit, onAbort, onChange } } describe('handleKeyDown behavior', () => { it('should call submit when ENTER_KEY is pressed with non-empty value', async () => { const { onSubmit } = setup() const input = screen.getByRole('textbox') // Type some text await act(async () => { fireEvent.change(input, { target: { value: 'test-file' } }) fireEvent.keyDown(input, { keyCode: 13 }) }) expect(onSubmit).toHaveBeenCalledWith('test-file') }) it('should call abort with accidental=true when ENTER_KEY is pressed with empty value', async () => { const { onAbort } = setup() const input = screen.getByRole('textbox') // Press Enter with empty value await act(async () => { fireEvent.keyDown(input, { keyCode: 13 }) }) expect(onAbort).toHaveBeenCalledWith(true) }) it('should call abort when ESC_KEY is pressed', async () => { const { onAbort } = setup() const input = screen.getByRole('textbox') // Press Escape await act(async () => { fireEvent.keyDown(input, { keyCode: 27 }) }) expect(onAbort).toHaveBeenCalled() }) }) describe('handleBlur behavior', () => { it('should call submit when blurred with non-empty value', async () => { const { onSubmit } = setup() const input = screen.getByRole('textbox') // Type some text and blur await act(async () => { fireEvent.change(input, { target: { value: 'test-file' } }) fireEvent.blur(input) }) expect(onSubmit).toHaveBeenCalledWith('test-file') }) it('should call abort when blurred with empty value', async () => { const { onAbort } = setup() const input = screen.getByRole('textbox') // Blur with empty value await act(async () => { fireEvent.blur(input) }) expect(onAbort).toHaveBeenCalled() }) }) describe('handleChange behavior', () => { it('should update state and call onChange when input changes', async () => { const { onChange } = setup() const input = screen.getByRole('textbox') await act(async () => { fireEvent.change(input, { target: { value: 'new-value' } }) }) expect(onChange).toHaveBeenCalledWith('new-value') expect(input.value).toBe('new-value') }) }) describe('race condition fix verification', () => { it('should not show unwanted notification for empty filename on ENTER_KEY', async () => { const { onAbort } = setup() const input = screen.getByRole('textbox') // Simulate pressing Enter with empty value await act(async () => { fireEvent.keyDown(input, { keyCode: 13 }) }) // Should call abort with accidental=true, not show unwanted notification expect(onAbort).toHaveBeenCalledWith(true) expect(onAbort).toHaveBeenCalledTimes(1) }) it('should handle blur correctly without race condition', async () => { const { onSubmit, onAbort } = setup() const input = screen.getByRole('textbox') // Type some text and blur await act(async () => { fireEvent.change(input, { target: { value: 'valid-file' } }) fireEvent.blur(input) }) // Should submit without any race condition issues expect(onSubmit).toHaveBeenCalledWith('valid-file') expect(onAbort).not.toHaveBeenCalled() }) }) describe('edge cases', () => { it('should handle whitespace-only value as non-empty', async () => { const { onSubmit } = setup() const input = screen.getByRole('textbox') // Type whitespace and press Enter await act(async () => { fireEvent.change(input, { target: { value: ' ' } }) fireEvent.keyDown(input, { keyCode: 13 }) }) // Whitespace is considered non-empty by the component expect(onSubmit).toHaveBeenCalledWith(' ') }) it('should handle Enter followed by blur correctly', async () => { const { onSubmit, onAbort } = setup() const input = screen.getByRole('textbox') // Type a value and press Enter await act(async () => { fireEvent.change(input, { target: { value: 'test-file' } }) fireEvent.keyDown(input, { keyCode: 13 }) fireEvent.blur(input) }) // Should only submit once, not twice expect(onSubmit).toHaveBeenCalledTimes(1) expect(onSubmit).toHaveBeenCalledWith('test-file') expect(onAbort).not.toHaveBeenCalled() }) }) }) ================================================ FILE: src/modules/filelist/HeaderCell.jsx ================================================ import cx from 'classnames' import React, { useCallback } from 'react' import { TableHeader } from 'cozy-ui/transpiled/react/deprecated/Table' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' const HeaderCell = ({ label, css, attr, order = null, className, defaultOrder, onSort }) => { const { t } = useI18n() const sortCallback = useCallback( () => onSort && onSort(attr, order ? (order === 'asc' ? 'desc' : 'asc') : defaultOrder), [onSort, attr, order, defaultOrder] ) return ( {t(`table.head_${label}`)} ) } export default HeaderCell ================================================ FILE: src/modules/filelist/LoadMore.jsx ================================================ import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' import Buttons from 'cozy-ui/transpiled/react/Buttons' import Spinner from 'cozy-ui/transpiled/react/Spinner' import { TableRow } from 'cozy-ui/transpiled/react/deprecated/Table' import { translate } from 'twake-i18n' import styles from '@/styles/filelist.styl' const LoadMore = ({ onClick, isLoading, text }) => ( : text} /> ) LoadMore.propTypes = { onClick: PropTypes.func, isLoading: PropTypes.bool, text: PropTypes.string.isRequired } LoadMore.defaultProps = { onClick: null, isLoading: false } const withTranslation = BaseComponent => // eslint-disable-next-line ({ t, ...props }) => export default translate()(withTranslation(LoadMore)) ================================================ FILE: src/modules/filelist/LoadMoreV2.jsx ================================================ import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' import LoadMore from 'cozy-ui/transpiled/react/LoadMore' import { TableRow } from 'cozy-ui/transpiled/react/deprecated/Table' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' const LoadMoreFiles = ({ fetchMore }) => { const { t } = useI18n() return ( ) } LoadMoreFiles.propTypes = { fetchMore: PropTypes.func.isRequired } export default LoadMoreFiles ================================================ FILE: src/modules/filelist/MobileSortMenu.jsx ================================================ import React from 'react' import ActionMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem' import ActionMenuWrapper from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuWrapper' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import ListItemText from 'cozy-ui/transpiled/react/ListItemText' import Radio from 'cozy-ui/transpiled/react/Radios' import { useI18n } from 'twake-i18n' import { SORTABLE_ATTRIBUTES } from '@/config/sort' const MobileSortMenu = ({ sort, onSort, onClose }) => { const { t } = useI18n() return ( {SORTABLE_ATTRIBUTES.map(({ attr }) => [ { attr, order: 'asc' }, { attr, order: 'desc' } ]) .reduce((acc, val) => [...acc, ...val], []) .map(({ attr, order }) => { const labelId = `sort_by_${attr}_${order}` return ( { onSort(attr, order) onClose() }} > ) })} ) } export default MobileSortMenu ================================================ FILE: src/modules/filelist/cells/CarbonCopy.jsx ================================================ import cx from 'classnames' import get from 'lodash/get' import React from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import CheckIcon from 'cozy-ui/transpiled/react/Icons/Check' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import AppIcon from 'cozy-ui-plus/dist/AppIcon' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' import CertificationTooltip from '@/modules/certifications/CertificationTooltip' const CarbonCopyIcon = ({ file }) => { const hasElectronicSafe = get(file, 'metadata.electronicSafe') const konnectorName = get(file, 'cozyMetadata.uploadedBy.slug') if (hasElectronicSafe) { return } return } const CarbonCopy = ({ file }) => { const { t } = useI18n() const hasDataToshow = get(file, 'metadata.carbonCopy') return ( {hasDataToshow ? ( } /> ) : ( '—' )} ) } export default CarbonCopy ================================================ FILE: src/modules/filelist/cells/CertificationsIcons.jsx ================================================ import get from 'lodash/get' import React from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import CarbonCopyIcon from 'cozy-ui/transpiled/react/Icons/CarbonCopy' import AppIcon from 'cozy-ui-plus/dist/AppIcon' import styles from '@/styles/filelist.styl' const CertificationsIcons = ({ attributes }) => { const isCarbonCopy = get(attributes, 'metadata.carbonCopy') const isElectronicSafe = get(attributes, 'metadata.electronicSafe') const slug = get(attributes, 'cozyMetadata.uploadedBy.slug') return (
{(isCarbonCopy || isElectronicSafe) && ( {' - '} )} {isCarbonCopy && (isElectronicSafe ? ( ) : ( ))} {isElectronicSafe && ( )}
) } export default CertificationsIcons ================================================ FILE: src/modules/filelist/cells/CertificationsIcons.spec.js ================================================ import { render } from '@testing-library/react' import React from 'react' import { createMockClient } from 'cozy-client' import CertificationsIcons from './CertificationsIcons' import AppLike from 'test/components/AppLike' const client = new createMockClient({}) const setup = ({ attributes }) => { const root = render( ) return { root } } describe('CertificationsIcons', () => { it('should render only carbon copy app icon', () => { const { root } = setup({ attributes: { metadata: { carbonCopy: true, electronicSafe: false }, cozyMetadata: { uploadedBy: { slug: 'pajemploi' } } } }) const { queryByTestId } = root expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeTruthy() expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy() expect( queryByTestId('certificationsIcons-electronicSafeAppIcon') ).toBeFalsy() }) it('should render only electronic safe app icon', () => { const { root } = setup({ attributes: { metadata: { carbonCopy: false, electronicSafe: true }, cozyMetadata: { uploadedBy: { slug: 'pajemploi' } } } }) const { queryByTestId } = root expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy() expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy() expect( queryByTestId('certificationsIcons-electronicSafeAppIcon') ).toBeTruthy() }) it('should render carbon copy icon and electronic safe app icon', () => { const { root } = setup({ attributes: { metadata: { carbonCopy: true, electronicSafe: true }, cozyMetadata: { uploadedBy: { slug: 'pajemploi' } } } }) const { queryByTestId } = root expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy() expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeTruthy() expect( queryByTestId('certificationsIcons-electronicSafeAppIcon') ).toBeTruthy() }) it('should render no certifications icon', () => { const { root } = setup({ attributes: { metadata: { carbonCopy: false, electronicSafe: false }, cozyMetadata: { uploadedBy: { slug: 'pajemploi' } } } }) const { queryByTestId } = root expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy() expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy() expect( queryByTestId('certificationsIcons-electronicSafeAppIcon') ).toBeFalsy() }) it('should render no certifications icon and not throw error with empty attributes', () => { const { root } = setup({ attributes: {} }) const { queryByTestId } = root expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy() expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy() expect( queryByTestId('certificationsIcons-electronicSafeAppIcon') ).toBeFalsy() }) }) ================================================ FILE: src/modules/filelist/cells/ElectronicSafe.jsx ================================================ import cx from 'classnames' import get from 'lodash/get' import React from 'react' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import AppIcon from 'cozy-ui-plus/dist/AppIcon' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' import CertificationTooltip from '@/modules/certifications/CertificationTooltip' const ElectronicSafe = ({ file }) => { const { t } = useI18n() const hasDataToshow = get(file, 'metadata.electronicSafe') const konnectorName = get(file, 'cozyMetadata.uploadedBy.slug') return ( {hasDataToshow ? ( } /> ) : ( '—' )} ) } export default ElectronicSafe ================================================ FILE: src/modules/filelist/cells/Empty.jsx ================================================ import cx from 'classnames' import React from 'react' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import styles from '@/styles/filelist.styl' const Empty = ({ className }) => { return ( ) } export default Empty ================================================ FILE: src/modules/filelist/cells/FileAction.jsx ================================================ import cx from 'classnames' import React, { forwardRef } from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import styles from '@/styles/filelist.styl' const FileAction = forwardRef(function FileAction( { t, onClick, disabled, isInSyncFromSharing }, ref ) { return ( ) }) export default FileAction ================================================ FILE: src/modules/filelist/cells/FileName.jsx ================================================ import cx from 'classnames' import React from 'react' import { Link } from 'react-router-dom' import { isDirectory } from 'cozy-client/dist/models/file' import MidEllipsis from 'cozy-ui/transpiled/react/MidEllipsis' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext' import RenameInput from '@/modules/drive/RenameInput' import CertificationsIcons from '@/modules/filelist/cells/CertificationsIcons' import { getFileNameAndExtension, makeParentFolderPath } from '@/modules/filelist/helpers' const FileName = ({ attributes, isRenaming, interactive, withFilePath, isMobile, formattedSize, formattedUpdatedAt, refreshFolderContent, isInSyncFromSharing }) => { const { t } = useI18n() const { viewType } = useViewSwitcherContext() const classes = cx( styles['fil-content-cell'], styles['fil-content-file'], { [styles['fil-content-file-openable']]: !isRenaming && interactive }, { [styles['fil-content-row-disabled']]: isInSyncFromSharing }, { [styles['fil-content-grid-view']]: viewType === 'grid' } ) const { title, filename, extension } = getFileNameAndExtension(attributes, t) const parentFolderPath = makeParentFolderPath(attributes) return ( {isRenaming ? ( ) : (
{filename} {extension && ( {extension} )}
{withFilePath && parentFolderPath && (isMobile ? (
) : ( ))} {!withFilePath && (isDirectory(attributes) || (
{`${formattedUpdatedAt}${ formattedSize ? ` - ${formattedSize}` : '' }`} {isMobile && }
))}
)}
) } export default FileName ================================================ FILE: src/modules/filelist/cells/LastUpdate.jsx ================================================ import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' const LastUpdate = ({ date, formatted = '—' }) => { const { f, t } = useI18n() return ( ) } LastUpdate.propTypes = { date: PropTypes.string, formatted: PropTypes.string } export default React.memo(LastUpdate) ================================================ FILE: src/modules/filelist/cells/SelectBox.jsx ================================================ import cx from 'classnames' import React from 'react' import Checkbox from 'cozy-ui/transpiled/react/Checkbox' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import styles from '@/styles/filelist.styl' const SelectBox = ({ withSelectionCheckbox, selected, onClick, disabled, viewType }) => { return ( {withSelectionCheckbox && !disabled && ( { // handled by onClick on the }} /> )} ) } export default SelectBox ================================================ FILE: src/modules/filelist/cells/ShareContent.jsx ================================================ import cx from 'classnames' import React from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { SharedStatus, useSharingContext } from 'cozy-sharing' import styles from '@/styles/filelist.styl' import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext' import { joinPath } from '@/lib/path' const ShareContent = ({ file, disabled, isInSyncFromSharing }) => { const navigate = useNavigate() const { pathname } = useLocation() const { byDocId } = useSharingContext() const { viewType } = useViewSwitcherContext() const handleClick = e => { // Avoid to trigger row click from FileOpener e.preventDefault() e.stopPropagation() if (!disabled) { // should be only disabled navigate(joinPath(pathname, `file/${file._id}/share`)) } } const isShared = byDocId[file.id] !== undefined return (
{isInSyncFromSharing || !isShared ? ( viewType === 'list' ? ( ) : null ) : ( )}
) } export { ShareContent } ================================================ FILE: src/modules/filelist/cells/SharingShortcutBadge.jsx ================================================ import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' import { isSharingShortcutNew } from 'cozy-client/dist/models/file' import Avatar from 'cozy-ui/transpiled/react/Avatar' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' const SharingShortcutBadge = ({ file }) => { const { t } = useI18n() return ( {isSharingShortcutNew(file) ? ( 1 ) : null} ) } SharingShortcutBadge.propTypes = { file: PropTypes.object, isInSyncFromSharing: PropTypes.bool } export { SharingShortcutBadge } ================================================ FILE: src/modules/filelist/cells/Size.jsx ================================================ import cx from 'classnames' import React from 'react' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import styles from '@/styles/filelist.styl' const _Size = ({ filesize = '—' }) => ( {filesize} ) const Size = React.memo(_Size) export default Size ================================================ FILE: src/modules/filelist/cells/Status.jsx ================================================ import cx from 'classnames' import React from 'react' import { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table' import styles from '@/styles/filelist.styl' import { ShareContent } from '@/modules/filelist/cells/ShareContent' const Status = ({ file, disabled, isInSyncFromSharing }) => { return ( ) } export default Status ================================================ FILE: src/modules/filelist/cells/index.jsx ================================================ export { default as SelectBox } from './SelectBox' export { default as FileName } from './FileName' export { default as LastUpdate } from './LastUpdate' export { default as Size } from './Size' export { default as Status } from './Status' export { default as FileAction } from './FileAction' export { default as CarbonCopy } from './CarbonCopy' export { default as ElectronicSafe } from './ElectronicSafe' export { default as Empty } from './Empty' export { SharingShortcutBadge } from './SharingShortcutBadge' ================================================ FILE: src/modules/filelist/duck.js ================================================ const SHOW_NEW_FOLDER_INPUT = 'SHOW_NEW_FOLDER_INPUT' const HIDE_NEW_FOLDER_INPUT = 'HIDE_NEW_FOLDER_INPUT' export const showNewFolderInput = () => ({ type: SHOW_NEW_FOLDER_INPUT }) export const hideNewFolderInput = () => ({ type: HIDE_NEW_FOLDER_INPUT }) const initialState = { isTypingNewFolderName: false } const filelist = (state = initialState, action) => { switch (action.type) { case SHOW_NEW_FOLDER_INPUT: return { ...state, isTypingNewFolderName: true } case HIDE_NEW_FOLDER_INPUT: return { ...state, isTypingNewFolderName: false } default: return state } } export default filelist export const isTypingNewFolderName = state => state.filelist.isTypingNewFolderName ================================================ FILE: src/modules/filelist/fileopener.styl ================================================ @supports (display: contents) .file-opener display contents @supports not (display: contents) // @stylint ignore .file-opener display flex flex 1 1 auto align-items center width 100% .file-opener__a text-decoration none color var(--secondaryTextColor) ================================================ FILE: src/modules/filelist/getCaretPositionFromPoint.js ================================================ /** * Get the caret offset in a text input from mouse coordinates. * * Uses `document.caretPositionFromPoint` (standard, Firefox) or * `document.caretRangeFromPoint` (legacy, Chrome/Safari) to determine * which character position the user clicked on. * * @param {number} x - clientX from the mouse event * @param {number} y - clientY from the mouse event * @returns {number|null} The character offset, or null if it cannot be determined */ export const getCaretPositionFromPoint = (x, y) => { if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(x, y) if (pos) return pos.offset } else if (document.caretRangeFromPoint) { const range = document.caretRangeFromPoint(x, y) if (range) return range.startOffset } return null } ================================================ FILE: src/modules/filelist/headers/CarbonCopy.jsx ================================================ import React from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import CarbonCopyIcon from 'cozy-ui/transpiled/react/Icons/CarbonCopy' import { TableHeader } from 'cozy-ui/transpiled/react/deprecated/Table' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' import CertificationTooltip from '@/modules/certifications/CertificationTooltip' const CarbonCopyHeader = () => { const { t } = useI18n() return ( } /> ) } export default CarbonCopyHeader ================================================ FILE: src/modules/filelist/headers/ElectronicSafe.jsx ================================================ import React from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import SafeIcon from 'cozy-ui/transpiled/react/Icons/Safe' import { TableHeader } from 'cozy-ui/transpiled/react/deprecated/Table' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' import CertificationTooltip from '@/modules/certifications/CertificationTooltip' const ElectronicSafeHeader = () => { const { t } = useI18n() return ( } /> ) } export default ElectronicSafeHeader ================================================ FILE: src/modules/filelist/headers/index.jsx ================================================ export { default as CarbonCopy } from './CarbonCopy' export { default as ElectronicSafe } from './ElectronicSafe' ================================================ FILE: src/modules/filelist/helpers.ts ================================================ import { splitFilename } from 'cozy-client/dist/models/file' import type { IOCozyFile } from 'cozy-client/types/types' import type { File } from '@/components/FolderPicker/types' import { TRASH_DIR_ID, ROOT_DIR_ID, SHARED_DRIVES_DIR_ID, SHARINGS_VIEW_ROUTE } from '@/constants/config' import { isNextcloudShortcut } from '@/modules/nextcloud/helpers' export const isDriveBackedFile = (file: File): boolean => !!file.driveId export const makeParentFolderPath = (file: File): string => { if (file.dir_id === SHARED_DRIVES_DIR_ID) { return SHARINGS_VIEW_ROUTE } if (!file.path) return '' return file.dir_id === ROOT_DIR_ID ? file.path.replace(file.name, '') : file.path.replace(`/${file.name}`, '') } export const getFileNameAndExtension = ( file: File, t: (key: string) => string ): { title: string filename: string extension?: string } => { if (file._id === TRASH_DIR_ID) { return { title: t('FileName.trash'), filename: t('FileName.trash') } } // we can have ROOT_DIR_ID in some case, like in sharing view when fetching docs for the first time // in that case we want to do the same trick as for SHARED_DRIVES_DIR_ID if (file._id === SHARED_DRIVES_DIR_ID || file._id === ROOT_DIR_ID) { return { title: t('FileName.sharedDrive'), filename: t('FileName.sharedDrive') } } const { filename, extension } = splitFilename(file) if (file._type === 'io.cozy.files' && isNextcloudShortcut(file)) { return { title: filename, filename: filename } } return { title: file.name, filename, extension } } export interface FileWithAntivirusScan { antivirus_scan?: { status?: 'clean' | 'infected' | 'skipped' | 'error' | 'pending' } } export const isInfected = ( file?: (FileWithAntivirusScan & Partial) | null ): boolean => { return file?.antivirus_scan?.status === 'infected' } export const isNotScanned = ( file?: (FileWithAntivirusScan & Partial) | null ): boolean => { const status = file?.antivirus_scan?.status return status === 'pending' || status === 'skipped' || status === 'error' } ================================================ FILE: src/modules/filelist/icons/BadgeKonnector.jsx ================================================ import PropTypes from 'prop-types' import React from 'react' import { isQueryLoading, isReferencedBy, useQuery } from 'cozy-client' import Badge from 'cozy-ui/transpiled/react/Badge' import { makeStyles } from 'cozy-ui/transpiled/react/styles' import AppIcon from 'cozy-ui-plus/dist/AppIcon' import { DOCTYPE_KONNECTORS } from '@/lib/doctypes' import { getKonnectorSlugFromFile } from '@/lib/konnectors' import { buildFileOrFolderByIdQuery } from '@/queries' const useStyle = makeStyles({ badge: { backgroundColor: 'var(--white)', height: '1.5rem', minWidth: '1.5rem', borderRadius: '0.375rem', border: '1px solid var(--borderMainColor)' }, appIcon: { width: '75%', height: '75%' }, anchorOriginBottomRightCircular: { bottom: '10px' } }) export const BadgeKonnector = ({ file, children }) => { const { badge, anchorOriginBottomRightCircular, appIcon } = useStyle() const konnectorSlug = getKonnectorSlugFromFile(file) // Check if the parent folder is a konnector folder, because if have no file in your account folder, its considered as a konnector folder const parentFolderQuery = buildFileOrFolderByIdQuery(file.dir_id) const { data: parentFolder, ...parentFolderQueryLeft } = useQuery( parentFolderQuery.definition, parentFolderQuery.options ) const isParentQueryLoading = isQueryLoading(parentFolderQueryLeft) const hasKonnectorParentFolder = isReferencedBy(parentFolder, DOCTYPE_KONNECTORS) || // To guarantee the exclusion of account folders (isReferencedBy(file, DOCTYPE_KONNECTORS) && isReferencedBy(file, 'io.cozy.accounts.sourceAccountIdentifier')) const withoutKonnectorBadge = isParentQueryLoading || hasKonnectorParentFolder || !isReferencedBy(file, DOCTYPE_KONNECTORS) if (withoutKonnectorBadge) { return <>{children} } return ( } > {children} ) } BadgeKonnector.propTypes = { file: PropTypes.object.isRequired } ================================================ FILE: src/modules/filelist/icons/FileIcon.jsx ================================================ import React from 'react' import FileImageLoader from 'cozy-ui-plus/dist/FileImageLoader' import styles from '@/styles/filelist.styl' import { isDriveBackedFile } from '@/modules/filelist/helpers' import FileIconMime from '@/modules/filelist/icons/FileIconMime' import FileIconShortcut from '@/modules/filelist/icons/FileIconShortcut' const FileIcon = ({ file, size, viewType = 'list' }) => { const isImage = file.class === 'image' const isShortcut = file.class === 'shortcut' && !isDriveBackedFile(file) if (isImage || file.class === 'pdf') return ( ( )} renderFallback={() => } /> ) else if (isShortcut) return else return } export default FileIcon ================================================ FILE: src/modules/filelist/icons/FileIcon.spec.jsx ================================================ import { render } from '@testing-library/react' import React from 'react' import FileIcon from './FileIcon' jest.mock('cozy-flags', () => () => true) jest.mock('cozy-ui-plus/dist/FileImageLoader', () => () => (
)) describe('FileIcon', () => { it('should return file image loader when file is image', () => { // Given const file = { class: 'image' } // When const { getByTestId } = render() // Then expect(getByTestId('FileImageLoader')).toBeInTheDocument() }) it('should return file image loader when file is pdf', () => { // Given const file = { class: 'pdf' } // When const { getByTestId } = render() // Then expect(getByTestId('FileImageLoader')).toBeInTheDocument() }) }) ================================================ FILE: src/modules/filelist/icons/FileIconMime.jsx ================================================ import PropTypes from 'prop-types' import React from 'react' import { isDirectory } from 'cozy-client/dist/models/file' import Icon from 'cozy-ui/transpiled/react/Icon' import getMimeTypeIcon from '@/lib/getMimeTypeIcon' import { CustomizedIcon } from '@/modules/views/Folder/CustomizedIcon' const FileIconMime = ({ file, size = 32 }) => { const isDir = isDirectory(file) if ( isDir && (file.metadata?.decorations?.color || file.metadata?.decorations?.icon) ) { return ( ) } else { return ( ) } } FileIconMime.propTypes = { file: PropTypes.shape({ type: PropTypes.string, mime: PropTypes.string, name: PropTypes.string }).isRequired, size: PropTypes.number } export default FileIconMime ================================================ FILE: src/modules/filelist/icons/FileIconShortcut.jsx ================================================ import React, { useState } from 'react' import { useClient, useFetchShortcut } from 'cozy-client' import Icon from 'cozy-ui/transpiled/react/Icon' import GlobeIcon from 'cozy-ui/transpiled/react/Icons/Globe' const FileIconShortcut = ({ file, size = 32 }) => { const client = useClient() const { shortcutImg } = useFetchShortcut(client, file.id) const [isBroken, setBroken] = useState(null) return ( <>
{ setBroken(true) }} />
) } export default FileIconShortcut ================================================ FILE: src/modules/filelist/icons/FileThumbnail.tsx ================================================ import React from 'react' import { isReferencedBy, models } from 'cozy-client' import { isDirectory } from 'cozy-client/dist/models/file' import { SharedBadge, SharingOwnerAvatar } from 'cozy-sharing' import Badge from 'cozy-ui/transpiled/react/Badge' import Box from 'cozy-ui/transpiled/react/Box' import Icon from 'cozy-ui/transpiled/react/Icon' import FileTypeServerIcon from 'cozy-ui/transpiled/react/Icons/FileTypeServer' import LinkIcon from 'cozy-ui/transpiled/react/Icons/Link' import TrashDuotoneIcon from 'cozy-ui/transpiled/react/Icons/TrashDuotone' import Spinner from 'cozy-ui/transpiled/react/Spinner' import styles from '@/styles/filelist.styl' import type { File, FolderPickerEntry } from '@/components/FolderPicker/types' import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext' import { DOCTYPE_KONNECTORS } from '@/lib/doctypes' import { isInfected, isDriveBackedFile } from '@/modules/filelist/helpers' import { BadgeKonnector } from '@/modules/filelist/icons/BadgeKonnector' import FileIcon from '@/modules/filelist/icons/FileIcon' import FileIconMime from '@/modules/filelist/icons/FileIconMime' import { SharingShortcutIcon } from '@/modules/filelist/icons/SharingShortcutIcon' import { isNextcloudShortcut, isNextcloudFile } from '@/modules/nextcloud/helpers' interface FileThumbnailProps { file: File | FolderPickerEntry size?: number isInSyncFromSharing?: boolean showSharedBadge?: boolean componentsProps?: { sharedBadge?: object } } const FileThumbnail: React.FC = ({ file, size, isInSyncFromSharing, showSharedBadge = false, componentsProps = { sharedBadge: {} } }) => { const { viewType } = useViewSwitcherContext() const fileIcon = if (isNextcloudFile(file)) { return } if (file._id?.endsWith('.trash-dir')) { return size && size >= 48 ? ( ) : ( ) } if (isNextcloudShortcut(file)) { return ( ) } const isSharingShortcut = models.file.isSharingShortcut(file) && !isInSyncFromSharing && !isDriveBackedFile(file) const isRegularShortcut = !isSharingShortcut && file.class === 'shortcut' && !isInSyncFromSharing && !isDriveBackedFile(file) const isSimpleFile = !isSharingShortcut && !isRegularShortcut && !isInSyncFromSharing const isFolder = isSimpleFile && isDirectory(file) const isKonnectorFolder = isReferencedBy(file, DOCTYPE_KONNECTORS) if (isFolder) { if (size && size >= 48) { return ( {isKonnectorFolder ? ( {fileIcon} {file.class !== 'shortcut' && showSharedBadge && viewType === 'grid' && ( )} ) : ( <> {fileIcon} {file.class !== 'shortcut' && showSharedBadge && viewType === 'grid' && ( )} )} ) } } if (isKonnectorFolder) { return {fileIcon} } const infected = isInfected(file) const fileIconWithInfection = infected ? ( } withBorder={false} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > {fileIcon} ) : ( fileIcon ) return ( <> {isSimpleFile && fileIconWithInfection} {isRegularShortcut && ( <> {viewType !== 'grid' ? (
} > {fileIcon} ) : ( fileIcon )} )} {isSharingShortcut && (
} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > )} {isInSyncFromSharing && ( )} {/** * @todo * Since for shortcut we already display a kind of badge we're currently just * not displaying the sharedBadge. Besides on desktop we have added sharing avatars. * The next functionnal's task is to work on sharing and we'll remove * this badge from here. In the meantime, we take this workaround */} {file.class !== 'shortcut' && showSharedBadge && !isInSyncFromSharing && viewType === 'grid' && ( )} ) } export default FileThumbnail ================================================ FILE: src/modules/filelist/icons/SharingShortcutIcon.jsx ================================================ import React from 'react' import { getSharingShortcutTargetMime, getSharingShortcutTargetDoctype } from 'cozy-client/dist/models/file' import Icon from 'cozy-ui/transpiled/react/Icon' import { DOCTYPE_FILES } from '@/lib/doctypes' import getMimeTypeIcon from '@/lib/getMimeTypeIcon' import FileIconShortcut from '@/modules/filelist/icons/FileIconShortcut' const SharingShortcutIcon = ({ file, size }) => { const targetMimeType = getSharingShortcutTargetMime(file) const targetDoctype = getSharingShortcutTargetDoctype(file) const isShortcut = targetMimeType === 'application/internet-shortcut' const targetIsDirectory = targetMimeType === '' && targetDoctype === DOCTYPE_FILES return isShortcut ? ( ) : ( ) } export { SharingShortcutIcon } ================================================ FILE: src/modules/filelist/useFormattedUpdatedAt.js ================================================ import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { useI18n } from 'twake-i18n' /** * Returns the formatted "last updated" string for a file row, or undefined * when the date is falsy. * * The guard matters: twake-i18n's `f()` calls date-fns `format()`, which * throws on falsy/invalid dates. The library catches the throw but logs it * via `console.error('Error in initFormat', ...)`, which our Sentry config * captures. Synthetic rows in the file list (shared-drive entries, sharing * placeholders) often lack `updated_at`/`created_at`, so we'd otherwise * emit a Sentry event for every such row. * * @param {string | undefined} updatedAt * @returns {string | undefined} */ export const useFormattedUpdatedAt = updatedAt => { const { f, t } = useI18n() const { isExtraLarge } = useBreakpoints() if (!updatedAt) return undefined return f( updatedAt, isExtraLarge ? t('table.row_update_format_full') : t('table.row_update_format') ) } ================================================ FILE: src/modules/filelist/virtualized/AddFolderRow.jsx ================================================ import React from 'react' import FilenameInput from '@/modules/filelist/FilenameInput' import FileIconMime from '@/modules/filelist/icons/FileIconMime' const AddFolderRow = ({ onSubmit, onAbort }) => { return (
) } export default AddFolderRow ================================================ FILE: src/modules/filelist/virtualized/GridFile.jsx ================================================ import cx from 'classnames' import { filesize } from 'filesize' import get from 'lodash/get' import PropTypes from 'prop-types' import React, { useState, useRef } from 'react' import { useSelector } from 'react-redux' import { isDirectory } from 'cozy-client/dist/models/file' import Box from 'cozy-ui/transpiled/react/Box' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import { useI18n } from 'twake-i18n' import { SelectBox, FileName, Status, FileAction, SharingShortcutBadge } from '../cells' import styles from '@/styles/filelist.styl' import { useClipboardContext } from '@/contexts/ClipboardProvider' import { ActionMenuWithHeader } from '@/modules/actionmenu/ActionMenuWithHeader' import { getContextMenuActions } from '@/modules/actions/helpers' import { extraColumnsPropTypes } from '@/modules/certifications' import { isRenaming as isRenamingReducer, getRenamingFile } from '@/modules/drive/rename' import FileOpener from '@/modules/filelist/FileOpener' import FileThumbnail from '@/modules/filelist/icons/FileThumbnail' import { useFormattedUpdatedAt } from '@/modules/filelist/useFormattedUpdatedAt' import { useSelectionContext } from '@/modules/selection/SelectionProvider' import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider' const GridFile = ({ t, attributes, actions, isRenaming, withSelectionCheckbox, withFilePath, disabled, refreshFolderContent, isInSyncFromSharing, breakpoints: { isMobile }, disableSelection = false, canInteractWith, onContextMenu, isOver, onInteractWithFile }) => { const [actionMenuVisible, setActionMenuVisible] = useState(false) const filerowMenuToggleRef = useRef() const { toggleSelectedItem, isItemSelected, isSelectionBarVisible } = useSelectionContext() const { isItemCut } = useClipboardContext() const { isNew } = useNewItemHighlightContext() const toggleActionMenu = () => { if (actionMenuVisible) return hideActionMenu() else showActionMenu() } const showActionMenu = () => { setActionMenuVisible(true) } const hideActionMenu = () => { setActionMenuVisible(false) } const toggle = e => { toggleSelectedItem(attributes) onInteractWithFile?.(attributes?._id, e) } const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing const selected = isItemSelected(attributes._id) const isCut = isItemCut(attributes._id) const formattedSize = !isDirectory(attributes) && attributes.size ? filesize(attributes.size, { base: 10 }) : undefined const updatedAt = attributes.updated_at || attributes.created_at const formattedUpdatedAt = useFormattedUpdatedAt(updatedAt) // We don't allow any action on shared drives and trash // because they are magic folder created by the stack let canInteractWithFile = attributes._id && attributes._id !== 'io.cozy.files.shared-drives-dir' && !attributes._id.endsWith('.trash-dir') if (typeof canInteractWith === 'function') { canInteractWithFile &&= canInteractWith(attributes) } const contextMenuActions = getContextMenuActions(actions) return ( 0 } selected={selected} onClick={e => toggle(e)} disabled={ !canInteractWithFile || isRowDisabledOrInSyncFromSharing || disableSelection } />
{contextMenuActions && canInteractWithFile && ( { toggleActionMenu() }} /> )} {contextMenuActions && actionMenuVisible && ( )}
) } GridFile.propTypes = { t: PropTypes.func, attributes: PropTypes.object.isRequired, actions: PropTypes.array, isRenaming: PropTypes.bool, withSelectionCheckbox: PropTypes.bool.isRequired, withFilePath: PropTypes.bool, onContextMenu: PropTypes.func, /** Disables row actions */ disabled: PropTypes.bool, /** Apply disabled style on row */ breakpoints: PropTypes.object.isRequired, refreshFolderContent: PropTypes.func, isInSyncFromSharing: PropTypes.bool, extraColumns: extraColumnsPropTypes, /** Disables the ability to select a file */ disableSelection: PropTypes.bool, isOver: PropTypes.bool } export const DumbGridFile = props => { const { t } = useI18n() const breakpoints = useBreakpoints() return } export const GridFileWithSelection = props => { const isRenaming = useSelector( state => isRenamingReducer(state) && get(getRenamingFile(state), 'id') === props.attributes.id ) return } ================================================ FILE: src/modules/filelist/virtualized/cells/Cell.jsx ================================================ import { filesize } from 'filesize' import get from 'lodash/get' import React, { useContext, useReducer, useRef } from 'react' import { useSelector } from 'react-redux' import { isDirectory } from 'cozy-client/dist/models/file' import { isSharingShortcut } from 'cozy-client/dist/models/file' import { useVaultClient } from 'cozy-keys-lib' import { useSharingContext } from 'cozy-sharing' import AcceptingSharingContext from '@/lib/AcceptingSharingContext' import { ActionMenuWithHeader } from '@/modules/actionmenu/ActionMenuWithHeader' import { getContextMenuActions } from '@/modules/actions/helpers' import { filterActionsByPolicy } from '@/modules/actions/policies' import { isRenaming as isRenamingSelector, getRenamingFile } from '@/modules/drive/rename' import AddFolder from '@/modules/filelist/AddFolder' import FileOpener from '@/modules/filelist/FileOpener' import { useFormattedUpdatedAt } from '@/modules/filelist/useFormattedUpdatedAt' import FileAction from '@/modules/filelist/virtualized/cells/FileAction' import FileName from '@/modules/filelist/virtualized/cells/FileName' import LastUpdate from '@/modules/filelist/virtualized/cells/LastUpdate' import Share from '@/modules/filelist/virtualized/cells/Share' import Size from '@/modules/filelist/virtualized/cells/Size' import { useSelectionContext } from '@/modules/selection/SelectionProvider' import { isReferencedByShareInSharingContext } from '@/modules/views/Folder/syncHelpers' const Cell = ({ row, column, cell, currentFolderId, withFilePath, actions, onInteractWithFile, refreshFolderContent, driveId }) => { const vaultClient = useVaultClient() const { sharingsValue } = useContext(AcceptingSharingContext) const { byDocId } = useSharingContext() const filerowMenuToggleRef = useRef() const { toggleSelectedItem } = useSelectionContext() const [showActionMenu, toggleShowActionMenu] = useReducer( state => !state, false ) const isRenaming = useSelector( state => isRenamingSelector(state) && get(getRenamingFile(state), 'id') === row.id ) const updatedAt = row.updated_at || row.created_at const formattedUpdatedAt = useFormattedUpdatedAt(updatedAt) if (row.type === 'tempDirectory') { if (column.id === 'name') { return ( ) } if (column.id === 'menu') { return null } return '—' } const formattedSize = !isDirectory(row) && row.size ? filesize(row.size, { base: 10 }) : undefined const isSharingContextEmpty = Object.keys(sharingsValue).length <= 0 const isInSyncFromSharing = !isSharingContextEmpty && isSharingShortcut(row) && isReferencedByShareInSharingContext(row, sharingsValue) if (column.id === 'name') { if (!cell) { return '—' } const toggle = e => { e.stopPropagation() toggleSelectedItem(row) } return ( ) } if (column.id === 'updated_at') { if (!cell) { return '—' } return } if (column.id === 'size') { if (!cell) { return '—' } return } if (column.id === 'share') { const isShared = byDocId[row.id] !== undefined if (isInSyncFromSharing || !isShared) { return '—' } return } if (column.id === 'menu') { // We don't allow any action on shared drives and trash // because they are magic folder created by the stack const canInteractWithFile = row._id && row._id !== 'io.cozy.files.shared-drives-dir' && !row._id.endsWith('.trash-dir') if (!actions || !canInteractWithFile) { return null } const filteredActions = filterActionsByPolicy(actions, [row]) const contextMenuActions = getContextMenuActions(filteredActions) return ( <> {contextMenuActions && showActionMenu && ( )} ) } return <>{cell} } const CellMemo = React.memo(Cell) const CellWrapper = ({ row, column, cell, currentFolderId, withFilePath, actions, onInteractWithFile, refreshFolderContent, driveId }) => { return ( ) } export default CellWrapper ================================================ FILE: src/modules/filelist/virtualized/cells/FileAction.jsx ================================================ import React, { forwardRef } from 'react' import Icon from 'cozy-ui/transpiled/react/Icon' import IconButton from 'cozy-ui/transpiled/react/IconButton' import DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots' import ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon' import { useI18n } from 'twake-i18n' const FileAction = forwardRef(function FileAction({ onClick, disabled }, ref) { const { t } = useI18n() return ( ) }) export default FileAction ================================================ FILE: src/modules/filelist/virtualized/cells/FileName.jsx ================================================ import React from 'react' import { isDirectory } from 'cozy-client/dist/models/file' import Filename from 'cozy-ui/transpiled/react/Filename' import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' import { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext' import RenameInput from '@/modules/drive/RenameInput' import { getFileNameAndExtension, isInfected, makeParentFolderPath } from '@/modules/filelist/helpers' import FileThumbnail from '@/modules/filelist/icons/FileThumbnail' import FileNamePath from '@/modules/filelist/virtualized/cells/FileNamePath' const FileThumbnailComponent = ({ file, isInSyncFromSharing, isMobile }) => { const { isBigThumbnail } = useThumbnailSizeContext() return (
) } const FileName = ({ attributes, isRenaming, withFilePath, formattedSize, formattedUpdatedAt, refreshFolderContent, isInSyncFromSharing }) => { const { t } = useI18n() const { title, filename, extension } = getFileNameAndExtension(attributes, t) const { isMobile } = useBreakpoints() const parentFolderPath = makeParentFolderPath(attributes) const hidePath = withFilePath ? !parentFolderPath : isDirectory(attributes) || !isMobile const infected = isInfected(attributes) if (isRenaming) { return (
) } return ( } variant="body1" filename={filename} extension={extension} midEllipsis path={ hidePath ? undefined : ( ) } /> ) } export default FileName ================================================ FILE: src/modules/filelist/virtualized/cells/FileNamePath.jsx ================================================ import React from 'react' import { Link } from 'react-router-dom' import MidEllipsis from 'cozy-ui/transpiled/react/MidEllipsis' import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints' import { useI18n } from 'twake-i18n' import styles from '@/styles/filelist.styl' import { SHARINGS_VIEW_ROUTE } from '@/constants/config' import CertificationsIcons from '@/modules/filelist/cells/CertificationsIcons.jsx' import { getFileNameAndExtension } from '@/modules/filelist/helpers' import { getFolderPath } from '@/modules/routeUtils' const FileNamePath = ({ attributes, withFilePath, formattedSize, formattedUpdatedAt, parentFolderPath }) => { const { isMobile } = useBreakpoints() const { t } = useI18n() const { filename, extension } = getFileNameAndExtension(attributes, t) if (!withFilePath) { return (
{`${formattedUpdatedAt}${formattedSize ? ` - ${formattedSize}` : ''}`}
) } if (isMobile) { return (
) } const to = attributes.driveId ? SHARINGS_VIEW_ROUTE : getFolderPath(attributes.dir_id) return ( ) } export default FileNamePath ================================================ FILE: src/modules/filelist/virtualized/cells/LastUpdate.jsx ================================================ import PropTypes from 'prop-types' import React from 'react' import { useI18n } from 'twake-i18n' const LastUpdate = ({ date, formatted }) => { const { f, t } = useI18n() return ( ) } LastUpdate.propTypes = { date: PropTypes.string, formatted: PropTypes.string } export default React.memo(LastUpdate) ================================================ FILE: src/modules/filelist/virtualized/cells/Share.jsx ================================================ import React from 'react' import ShareContent from './ShareContent' import SharingShortcutBadge from './SharingShortcutBadge' const Share = ({ row, isRowDisabledOrInSyncFromSharing }) => { return ( <> ) } export default Share ================================================ FILE: src/modules/filelist/virtualized/cells/ShareContent.jsx ================================================ import cx from 'classnames' import React from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { SharedStatus } from 'cozy-sharing' import styles from '@/styles/filelist.styl' import { joinPath } from '@/lib/path' const ShareContent = ({ file, disabled }) => { const navigate = useNavigate() const { pathname } = useLocation() const handleClick = e => { // Avoid to trigger row click from FileOpener e.preventDefault() e.stopPropagation() if (!disabled) { // should be only disabled navigate(joinPath(pathname, `file/${file._id}/share`)) } } return (
) } export default ShareContent ================================================ FILE: src/modules/filelist/virtualized/cells/SharingShortcutBadge.jsx ================================================ import PropTypes from 'prop-types' import React from 'react' import { isSharingShortcutNew } from 'cozy-client/dist/models/file' import Avatar from 'cozy-ui/transpiled/react/Avatar' import { useI18n } from 'twake-i18n' const SharingShortcutBadge = ({ file }) => { const { t } = useI18n() if (isSharingShortcutNew(file)) { return ( 1 ) } return null } SharingShortcutBadge.propTypes = { file: PropTypes.object, isInSyncFromSharing: PropTypes.bool } export default SharingShortcutBadge ================================================ FILE: src/modules/filelist/virtualized/cells/Size.jsx ================================================ import React from 'react' const _Size = ({ filesize }) => <>{filesize} const Size = React.memo(_Size) export default Size ================================================ FILE: src/modules/folder/components/FolderBody.jsx ================================================ import cx from 'classnames' import React, { useCallback } from 'react' import { useVaultClient } from 'cozy-keys-lib' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import styles from '@/styles/folder-view.styl' import { EmptyDrive } from '@/components/Error/Empty' import Oops from '@/components/Error/Oops' import RightClickFileMenu from '@/components/RightClick/RightClickFileMenu' import { useFolderSort } from '@/hooks' import { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext' import { useViewSwitcherContext } from '@/lib/ViewSwitcherContext' import AddFolder from '@/modules/filelist/AddFolder' import { FileWithSelection as File } from '@/modules/filelist/File' import { FileList } from '@/modules/filelist/FileList' import FileListBody from '@/modules/filelist/FileListBody' import { FileListHeader } from '@/modules/filelist/FileListHeader' import FileListRowsPlaceholder from '@/modules/filelist/FileListRowsPlaceholder' import LoadMore from '@/modules/filelist/LoadMoreV2' import { useNeedsToWait } from '@/modules/folder/hooks/useNeedsToWait' import { useScrollToTop } from '@/modules/folder/hooks/useScrollToTop' import SelectionBar from '@/modules/selection/SelectionBar' /** * Renders the body of a folder, displaying the list of files and folders within it. * * @component * @param {Object} props - The component props. * @param {string} props.folderId - The ID of the folder. * @param {Array} props.queryResults - The results of the queries for the folder content. * @param {Object} [props.actions] - The actions available for the folder. * @param {import('modules/certifications/useExtraColumns').ExtraColumn[]} props.extraColumns - The extra columns to display in the file list. * @param {boolean} [props.canSort] - Indicates whether sorting is enabled for the file list. * @param {Function} [props.refreshFolderContent] - The function to refresh the folder content. * @param {boolean} [props.withFilePath] - Indicates whether to display the file path. * @param {boolean} [props.isInSyncFromSharing] - Indicates whether the folder is in sync from sharing. * @param {Function} [props.renderEmptyComponent] - The function to render the empty component. * @param {Function} [props.canInteractWith] - Indicates whether the user can interact with the file. */ const FolderBody = ({ folderId, queryResults, actions, extraColumns, canSort, refreshFolderContent, withFilePath, isInSyncFromSharing, renderEmptyComponent = () => { return }, canInteractWith, driveId }) => { const vaultClient = useVaultClient() const { isDesktop } = useBreakpoints() useScrollToTop(folderId) const [sortOrder, setSortOrder, isSettingsLoaded] = useFolderSort(folderId) const isError = queryResults.some(query => query.fetchStatus === 'failed') const hasData = !isError && queryResults.some(query => query.data && query.data.length > 0) const isLoading = !hasData && queryResults.some( query => query.fetchStatus === 'loading' && !query.lastUpdate ) && !isSettingsLoaded const isEmpty = !isError && !isLoading && !hasData const needsToWait = useNeedsToWait({ isLoading }) const { isBigThumbnail } = useThumbnailSizeContext() const { viewType, switchView } = useViewSwitcherContext() const changeSortOrder = useCallback( (folderId_legacy, attribute, order) => setSortOrder({ attribute, order }), [setSortOrder] ) return ( <> {hasData ? ( ) : null} {!hasData && !needsToWait && (
)} {isError ? : null} {isLoading || needsToWait ? : null} {isEmpty ? renderEmptyComponent() : null} {hasData && !needsToWait ? (
{queryResults.map((query, queryIndex) => ( {query.data.map(file => { return ( ) })} {query.hasMore && } ))}
) : null}
) } export { FolderBody } ================================================ FILE: src/modules/folder/hooks/useNeedsToWait.jsx ================================================ import { useEffect, useState } from 'react' /** * When we mount the component when we already have data in cache, * the mount is time consuming since we'll render at least 100 lines * of File. * * React seems to batch together the fact that : * - we change a route * - we want to render 100 files * resulting in a non smooth transition between views (Drive / Recent / ...) * * In order to bypass this batch, we use a state to first display a much * more simpler component and then the files */ const useNeedsToWait = ({ isLoading }) => { const [needsToWait, setNeedsToWait] = useState(true) useEffect(() => { let timeout = null if (!isLoading) { timeout = setTimeout(() => { setNeedsToWait(false) }, 50) } return () => clearTimeout(timeout) }, [isLoading]) return needsToWait } export { useNeedsToWait } ================================================ FILE: src/modules/folder/hooks/useScrollToTop.jsx ================================================ import { useEffect } from 'react' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' /** * Since we are not able to restore the scroll correctly, * and force the scroll to top every time we change the * current folder. This is to avoid this kind of weird * behavior: * - If I go to a sub-folder, if this subfolder has a lot * of data and I scrolled down until the bottom. If I go * back, then my folder will also be scrolled down. * * This is an ugly hack, yeah. * */ const useScrollToTop = folderId => { const { isDesktop } = useBreakpoints() useEffect(() => { if (isDesktop) { const scrollable = document.querySelectorAll( '[data-testid=fil-content-body]' )[0] if (scrollable) { scrollable.scroll({ top: 0 }) } } else { window.scroll({ top: 0 }) } }, [isDesktop, folderId]) } export { useScrollToTop } ================================================ FILE: src/modules/layout/DummyLayout.tsx ================================================ import React from 'react' import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite' import { Layout } from 'cozy-ui/transpiled/react/Layout' const DummyLayout: React.FC = ({ children }) => { return ( {children} ) } export { DummyLayout } ================================================ FILE: src/modules/layout/Layout.jsx ================================================ import React, { useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Outlet, useNavigate } from 'react-router-dom' import { BarComponent } from 'cozy-bar' import CozyDevtools from 'cozy-devtools' import flag from 'cozy-flags' import FlagSwitcher from 'cozy-flags/dist/FlagSwitcher' import { useSharingContext } from 'cozy-sharing' import Sprite from 'cozy-ui/transpiled/react/Icon/Sprite' import { Layout as LayoutUI } from 'cozy-ui/transpiled/react/Layout' import Sidebar from 'cozy-ui/transpiled/react/Sidebar' import useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints' import Storage from 'cozy-ui-plus/dist/Storage' import { useI18n } from 'twake-i18n' import Drive from '@/components/Icons/Drive' import DriveText from '@/components/Icons/DriveText' import ButtonClient from '@/components/pushClient/Button' import { ROOT_DIR_ID } from '@/constants/config' import { useDisplayedFolder } from '@/hooks' import { initFlags } from '@/lib/flags' import AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider' import AddButton from '@/modules/drive/Toolbar/components/AddButton' import Nav from '@/modules/navigation/Nav' import { NavProvider, useNavContext } from '@/modules/navigation/NavContext' import { wasOperationRedirected, RESET_OPERATION_REDIRECTED } from '@/modules/navigation/duck/reducer' import { SelectionProvider } from '@/modules/selection/SelectionProvider' import { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider' import UploadButton from '@/modules/upload/UploadButton' import UploadQueue from '@/modules/upload/UploadQueue' initFlags() const LayoutContent = () => { const navigate = useNavigate() const dispatch = useDispatch() const { isMobile, isDesktop } = useBreakpoints() const { displayedFolder } = useDisplayedFolder() const { hasWriteAccess } = useSharingContext() const { t } = useI18n() const shouldRedirect = useSelector(wasOperationRedirected) const [, setLastClicked] = useNavContext() useEffect(() => { if (shouldRedirect) { // Update lastClicked state to ensure sidebar shows the correct active item setLastClicked(`/folder/${ROOT_DIR_ID}`) navigate(`/folder/${ROOT_DIR_ID}`) dispatch({ type: RESET_OPERATION_REDIRECTED }) } }, [shouldRedirect, navigate, dispatch, setLastClicked]) const isFolderReadOnly = displayedFolder ? !hasWriteAccess(displayedFolder._id, displayedFolder.driveId) : false return ( ev.preventDefault()}>
{isDesktop ? (
) : null}
{isDesktop && (
)}
{flag('debug') && }
) } const Layout = () => { return ( ) } export default Layout ================================================ FILE: src/modules/layout/Main.jsx ================================================ import PropTypes from 'prop-types' import React from 'react' import { RealTimeQueries } from 'cozy-client' import { Main as MainUI } from 'cozy-ui/transpiled/react/Layout' import { MigrationProgressBanner } from '@/components/Migration/MigrationProgressBanner' import PushBanner from '@/components/PushBanner' import { NEXTCLOUD_MIGRATIONS_DOCTYPE } from '@/lib/doctypes' const Main = ({ children, isPublic = false }) => ( {!isPublic && ( <> )} {children} ) Main.propTypes = { isPublic: PropTypes.bool, children: PropTypes.array } export default Main ================================================ FILE: src/modules/layout/Topbar.jsx ================================================ import classNames from 'classnames' import React from 'react' import styles from '@/styles/topbar.styl' const Topbar = ({ children, hideOnMobile = true }) => (
{children}
) export default Topbar ================================================ FILE: src/modules/move/MoveInsideSharedFolderModal.jsx ================================================ import PropTypes from 'prop-types' import React from 'react' import { useQuery } from 'cozy-client' import Buttons from 'cozy-ui/transpiled/react/Buttons' import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs' import { useI18n } from 'twake-i18n' import { LoaderModal } from '@/components/LoaderModal' import { getEntriesTypeTranslated } from '@/lib/entries' import { buildFileOrFolderByIdQuery, buildSharedDriveFileOrFolderByIdQuery } from '@/queries' /** * Alert the user when is trying to move a folder/file inside of a shared folder */ const MoveInsideSharedFolderModal = ({ entries, folderId, driveId, onCancel, onConfirm }) => { const { t } = useI18n() const folderQuery = driveId ? buildSharedDriveFileOrFolderByIdQuery({ fileId: folderId, driveId }) : buildFileOrFolderByIdQuery(folderId) const { fetchStatus, data } = useQuery( folderQuery.definition, folderQuery.options ) if (fetchStatus === 'loaded') { const type = getEntriesTypeTranslated(t, entries) return ( } /> ) } return } MoveInsideSharedFolderModal.propTypes = { /** List of files or folder to move */ entries: PropTypes.array.isRequired, /** Function called when the user cancels the move action */ onCancel: PropTypes.func.isRequired, /** Function called when the user confirms the move action */ onConfirm: PropTypes.func.isRequired } export { MoveInsideSharedFolderModal } ================================================ FILE: src/modules/move/MoveModal.jsx ================================================ import PropTypes from 'prop-types' import React, { useState } from 'react' import { useClient } from 'cozy-client' import { useSharingContext } from 'cozy-sharing' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { useMove } from './hooks/useMove' import { FolderPicker } from '@/components/FolderPicker/FolderPicker' import logger from '@/lib/logger' import { joinPath, getParentPath } from '@/lib/path' import { MoveInsideSharedFolderModal } from '@/modules/move/MoveInsideSharedFolderModal' import { MoveOutsideSharedFolderModal } from '@/modules/move/MoveOutsideSharedFolderModal' import { MoveSharedFolderInsideAnotherModal } from '@/modules/move/MoveSharedFolderInsideAnotherModal' import { hasOneOfEntriesShared } from '@/modules/move/helpers' import { useCancelable } from '@/modules/move/hooks/useCancelable' import { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers' import { executeMove } from '@/modules/paste' /** * Modal to move a folder to an other */ const MoveModal = ({ onClose, currentFolder, entries, showNextcloudFolder, onMovingSuccess, isPublic, showSharedDriveFolder, driveId }) => { const client = useClient() const { sharedPaths, refresh: refreshSharing, getSharedParentPath, hasSharedParent, isOwner, revokeSelf, revokeAllRecipients, byDocId, allLoaded } = useSharingContext() const { registerCancelable } = useCancelable() const { showSuccess } = useMove({ entries }) const { t } = useI18n() const { showAlert } = useAlert() const [folderSelected, setFolderSelected] = useState(null) const [isMoveInProgress, setMoveInProgress] = useState(false) const [isMovingOutsideSharedFolder, setMovingOutsideSharedFolder] = useState(false) const [ isMovingSharedFolderInsideAnother, setMovingSharedFolderInsideAnother ] = useState(false) const [isMovingInsideSharedFolder, setMovingInsideSharedFolder] = useState(false) const handleConfirm = async folder => { setFolderSelected(folder) const sharedParentPath = getSharedParentPath(entries[0].path) const targetPath = joinPath(folder.path, entries[0].name) const areMovedFilesShared = hasOneOfEntriesShared(entries, byDocId) const isOriginParentShared = hasSharedParent(entries[0].path) || !!driveId const isTargetShared = hasSharedParent(targetPath) || (!!folder.driveId && folder.driveId !== driveId) const isInsideSameSharedFolder = (sharedParentPath && targetPath.startsWith(sharedParentPath)) || (!!folder.driveId && !!driveId && folder.driveId === driveId) || isPublic if (isInsideSameSharedFolder) { moveEntries(folder) return } if (isOriginParentShared && !isTargetShared) { setMovingOutsideSharedFolder(true) return } if (!areMovedFilesShared && isTargetShared) { setMovingInsideSharedFolder(true) return } if (areMovedFilesShared && isTargetShared) { setMovingSharedFolderInsideAnother(true) return } moveEntries(folder) } const moveEntries = async folder => { try { setMoveInProgress(true) const trashedFiles = [] const force = !sharedPaths.includes(folder.path) await Promise.all( entries.map(async entry => { const moveResponse = await registerCancelable( executeMove(client, entry, currentFolder, folder, force) ) if (moveResponse.deleted) { trashedFiles.push(moveResponse.deleted) } }) ) const isMovingInsideNextcloud = folder._type === 'io.cozy.remote.nextcloud.files' const isMovingOutsideNextcloud = !isMovingInsideNextcloud && entries[0]._type === 'io.cozy.remote.nextcloud.files' refreshNextcloudQueries({ isMovingInsideNextcloud, isMovingOutsideNextcloud, folder }) showSuccess({ folder, trashedFiles, refreshSharing, canCancel: !isMovingInsideNextcloud && !isMovingOutsideNextcloud }) if (refreshSharing) refreshSharing() onMovingSuccess?.() } catch (e) { logger.warn(e) showAlert({ message: t('Move.error', { smart_count: entries.length }), severity: 'error' }) } finally { setMoveInProgress(false) onClose() } } /** * The content from nextcloud queries must be refreshed when moving files * This is only a proxy to Nextcloud queries so we don't have real-time or mutations updates */ const refreshNextcloudQueries = ({ isMovingOutsideNextcloud, isMovingInsideNextcloud, folder }) => { if (isMovingInsideNextcloud) { client.resetQuery( computeNextcloudFolderQueryId({ sourceAccount: folder.cozyMetadata.sourceAccount, path: folder.path }) ) } if (isMovingOutsideNextcloud) { client.resetQuery( computeNextcloudFolderQueryId({ sourceAccount: entries[0].cozyMetadata.sourceAccount, path: getParentPath(entries[0].path) }) ) } } const handleCancelMovingOutside = () => { setMovingOutsideSharedFolder(false) } const handleConfirmMovingOutside = () => { setMovingOutsideSharedFolder(false) moveEntries(folderSelected) } const handleCancelMovingInside = () => { setMovingInsideSharedFolder(false) } const handleConfirmMovingInside = () => { setMovingInsideSharedFolder(false) moveEntries(folderSelected) } const handleMovingSharedFolderInsideAnother = async () => { setMoveInProgress(true) entries.forEach(async entry => { if (byDocId[entry._id] !== undefined) { if (isOwner(entry._id)) { await revokeAllRecipients(entry) } else { await revokeSelf(entry) } } }) refreshSharing() moveEntries(folderSelected) setMovingSharedFolderInsideAnother(false) } return ( <> {isMovingOutsideSharedFolder ? ( ) : null} {isMovingSharedFolderInsideAnother ? ( setMovingSharedFolderInsideAnother(false)} onConfirm={handleMovingSharedFolderInsideAnother} /> ) : null} {isMovingInsideSharedFolder ? ( ) : null} ) } MoveModal.propTypes = { /** List of files or folder to move */ entries: PropTypes.array, onMovingSuccess: PropTypes.func } export { MoveModal } export default MoveModal ================================================ FILE: src/modules/move/MoveModal.spec.jsx ================================================ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import React from 'react' import { createMockClient, useQuery } from 'cozy-client' import { move } from 'cozy-client/dist/models/file' import { useSharingContext } from 'cozy-sharing' import { MoveModal } from './MoveModal' import AppLike from 'test/components/AppLike' import { ROOT_DIR_ID } from '@/constants/config' import { CozyFile } from '@/models' jest.mock('cozy-sharing', () => ({ ...jest.requireActual('cozy-sharing'), useSharingContext: jest.fn() })) jest.mock('cozy-doctypes') CozyFile.doctype = 'io.cozy.files' const onCloseSpy = jest.fn() const refreshSpy = jest.fn() jest.mock('cozy-client/dist/models/file', () => ({ move: jest.fn(), isFile: jest.fn(), moveRelateToSharedDrive: jest.fn() })) jest.mock('cozy-client', () => ({ ...jest.requireActual('cozy-client'), useQuery: jest.fn() })) CozyFile.splitFilename.mockImplementation(({ name }) => ({ filename: name, extension: '' })) jest.mock('components/FolderPicker/FolderPicker', () => ({ FolderPicker: ({ onConfirm, currentFolder, isBusy }) => { const handleClick = () => { onConfirm(currentFolder) } return (

{currentFolder.name}

) } })) describe('MoveModal component', () => { const defaultEntries = [ { _id: 'bill_201901', dir_id: 'bills', name: 'bill_201901.pdf', path: '/bills/bill_201901.pdf' }, { _id: 'bill_201902', dir_id: 'bills', name: 'bill_201902.pdf', path: '/bills/bill_201902.pdf' }, // shared file: { _id: 'bill_201903', dir_id: 'bills', name: 'bill_201903.pdf', path: '/bills/bill_201903.pdf' } ] const destinationFolder = { id: 'destinationFolder', _id: 'destinationFolder', _type: 'io.cozy.files', name: 'Destination Folder', path: '/Destination Folder' } const mockClient = createMockClient({ queries: { 'moveOrImport-destinationFolder': { doctype: 'io.cozy.files', data: [] }, 'io.cozy.files/destinationFolder': { doctype: 'io.cozy.files', data: [ { _id: 'destinationFolder', dir_id: ROOT_DIR_ID, name: 'Destination Folder', type: 'directory' } ] }, 'io.cozy.files/path/bills': { doctype: 'io.cozy.files', data: [ { _id: 'bills', dir_id: ROOT_DIR_ID, name: 'Bills', type: 'directory' } ] } } }) const setup = ({ entries = defaultEntries, sharedPaths = ['/sharedFolder'], byDocId = {}, getSharedParentPath = () => null, allLoaded = true, sharingContext = {}, currentFolder = destinationFolder } = {}) => { const props = { entries, onClose: onCloseSpy, classes: { paper: {} } } // Mock the useQuery hook for shared folder data const sharedParentPath = getSharedParentPath(entries[0]?.path || '') if (sharedParentPath) { const folderName = sharedParentPath.split('/').pop() || 'Bills' useQuery.mockReturnValue({ fetchStatus: 'loaded', data: [{ name: folderName }] }) } else { useQuery.mockReturnValue({ fetchStatus: 'loaded', data: [] }) } useSharingContext.mockReturnValue({ sharedPaths, refresh: refreshSpy, getSharedParentPath, hasSharedParent: path => sharedPaths.filter(sharedPath => path.includes(sharedPath)).length > 0, byDocId, allLoaded, ...sharingContext }) CozyFile.getFullpath.mockImplementation( (destinationFolder, name) => `/${destinationFolder}/${name}` ) move.mockImplementation(id => { if (id === 'bill_201902') { return Promise.resolve({ deleted: 'other_bill_201902', moved: { id } }) } else { return Promise.resolve({ deleted: null, moved: { id } }) } }) return render( ) } describe('MoveModal', () => { it('should wait for shares to load before authorising moves', async () => { await waitFor(async () => { setup({ allLoaded: false }) }) const moveButton = await screen.findByRole('button', { name: 'Move' }) expect(moveButton).toBeDisabled() }) it('should move entries to destination', async () => { CozyFile.getFullpath.mockImplementation((destinationFolder, name) => Promise.resolve( name === 'bill_201903.pdf' ? '/bills/bill_201903.pdf' : '/whatever' ) ) setup() const moveButton = await screen.findByText('Move') fireEvent.click(moveButton) await waitFor(() => { expect(move).toHaveBeenNthCalledWith( 1, mockClient, defaultEntries[0], destinationFolder, { force: true } ) expect(move).toHaveBeenNthCalledWith( 2, mockClient, defaultEntries[1], destinationFolder, { force: true } ) // don't force a shared file expect(move).toHaveBeenNthCalledWith( 3, mockClient, defaultEntries[2], destinationFolder, { force: true } ) expect(onCloseSpy).toHaveBeenCalled() expect(refreshSpy).toHaveBeenCalled() // TODO: check that trashedFiles are passed to cancel button }) }) }) describe('move outside shared folder', () => { it('should display an alert when moving files outside a shared folder', async () => { setup({ sharedPaths: ['/bills'], getSharedParentPath: path => path.includes('/bills') ? '/bills' : null, byDocId: {} }) const moveButton = await screen.findByText('Move') fireEvent.click(moveButton) await waitFor(() => { expect( screen.getByText('Moving outside the bills folder') ).toBeInTheDocument() }) }) it('should move files when user confirms', async () => { setup({ sharedPaths: ['/bills'], getSharedParentPath: path => path.includes('/bills') ? '/bills' : null, byDocId: {} }) const moveButton = await screen.findByText('Move') fireEvent.click(moveButton) await waitFor(() => { const confirmButton = screen.getByText('I understand') fireEvent.click(confirmButton) }) await waitFor(() => { expect(move).toHaveBeenCalled() expect(onCloseSpy).toHaveBeenCalled() expect(refreshSpy).toHaveBeenCalled() }) }) }) describe('move inside shared folder', () => { it('should display an alert when moving files inside a shared folder', async () => { setup({ sharedPaths: ['/Destination Folder'], getSharedParentPath: path => path.includes('/Destination Folder') ? '/Destination Folder' : null, byDocId: {} }) const moveButton = await screen.findByText('Move') fireEvent.click(moveButton) const modalTitle = await screen.findByText('Move to a shared folder?') expect(modalTitle).toBeInTheDocument() }) it('should move files when user confirms', async () => { setup({ sharedPaths: ['/Destination Folder'], getSharedParentPath: path => path.includes('/Destination Folder') ? '/Destination Folder' : null, byDocId: {} }) const moveButton = await screen.findByText('Move') fireEvent.click(moveButton) const confirmButton = await screen.findByText('Ok') fireEvent.click(confirmButton) await waitFor(() => { expect(move).toHaveBeenCalled() expect(onCloseSpy).toHaveBeenCalled() expect(refreshSpy).toHaveBeenCalled() }) }) }) describe('move shared folder inside another', () => { it('should display an alert when move shared folder inside another', async () => { CozyFile.getFullpath.mockImplementation((destinationFolder, name) => Promise.resolve(`/${destinationFolder}/${name}`) ) setup({ sharedPaths: ['/bills', '/Destination Folder'], byDocId: { bill_201903: { permissions: [], sharings: ['sharing-id-1'] } }, getSharedParentPath: path => path.includes('/bills') ? '/bills' : path.includes('/Destination Folder') ? '/Destination Folder' : null }) const moveButton = await screen.findByText('Move') fireEvent.click(moveButton) await waitFor(() => { expect(screen.getByText('Cannot be moved')).toBeInTheDocument() }) }) it('should move files after revoke all recipients when folder owner confirms', async () => { CozyFile.getFullpath.mockImplementation((destinationFolder, name) => Promise.resolve(`/${destinationFolder}/${name}`) ) const revokeAllSpy = jest.fn() const revokeSelfSpy = jest.fn() setup({ sharedPaths: ['/bills', '/Destination Folder'], byDocId: { bill_201903: { permissions: [], sharings: ['sharing-id-1'] } }, getSharedParentPath: path => path.includes('/bills') ? '/bills' : path.includes('/Destination Folder') ? '/Destination Folder' : null, sharingContext: { isOwner: () => true, revokeAllRecipients: revokeAllSpy, revokeSelf: revokeSelfSpy } }) const moveButton = await screen.findByText('Move') fireEvent.click(moveButton) await waitFor(() => { const confirmButton = screen.getByText('Stop sharing') fireEvent.click(confirmButton) }) await waitFor(() => { expect(move).toHaveBeenCalled() expect(revokeAllSpy).toHaveBeenCalled() expect(revokeSelfSpy).not.toHaveBeenCalled() expect(onCloseSpy).toHaveBeenCalled() expect(refreshSpy).toHaveBeenCalled() }) }) it('should move files after revoke self when user confirms', async () => { CozyFile.getFullpath.mockImplementation((destinationFolder, name) => Promise.resolve(`/${destinationFolder}/${name}`) ) const revokeAllSpy = jest.fn() const revokeSelfSpy = jest.fn() setup({ sharedPaths: ['/bills', '/Destination Folder'], byDocId: { bill_201903: { permissions: [], sharings: ['sharing-id-1'] } }, getSharedParentPath: path => path.includes('/bills') ? '/bills' : path.includes('/Destination Folder') ? '/Destination Folder' : null, sharingContext: { isOwner: () => false, revokeAllRecipients: revokeAllSpy, revokeSelf: revokeSelfSpy } }) const moveButton = await screen.findByText('Move') fireEvent.click(moveButton) await waitFor(() => { const confirmButton = screen.getByText('Stop sharing') fireEvent.click(confirmButton) }) await waitFor(() => { expect(move).toHaveBeenCalled() expect(revokeSelfSpy).toHaveBeenCalled() expect(revokeAllSpy).not.toHaveBeenCalled() expect(onCloseSpy).toHaveBeenCalled() expect(refreshSpy).toHaveBeenCalled() }) }) }) }) ================================================ FILE: src/modules/move/MoveOutsideSharedFolderModal.jsx ================================================ import PropTypes from 'prop-types' import React from 'react' import { useQuery } from 'cozy-client' import { useSharingContext } from 'cozy-sharing' import Buttons from 'cozy-ui/transpiled/react/Buttons' import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs' import Typography from 'cozy-ui/transpiled/react/Typography' import { useI18n } from 'twake-i18n' import { LoaderModal } from '@/components/LoaderModal' import { getEntriesTypeTranslated } from '@/lib/entries' import { buildFolderByPathQuery } from '@/queries' /** * Alert the user when is trying to move a folder/file outside of a shared folder */ const MoveOutsideSharedFolderModal = ({ entries, driveId, onCancel, onConfirm }) => { const { t } = useI18n() const { getSharedParentPath } = useSharingContext() const sharedParentPath = getSharedParentPath(entries[0]?.path || '') const folderByPathQuery = buildFolderByPathQuery(sharedParentPath) const { fetchStatus, data } = useQuery( folderByPathQuery.definition, folderByPathQuery.options ) if (fetchStatus === 'loaded') { const type = getEntriesTypeTranslated(t, entries) const sharedFolderName = !driveId ? data[0]?.name : (entries[0]?.path?.split('/')?.[2] ?? '') return ( {t('Move.outsideSharedFolder.content_1', { sharedFolder: sharedFolderName, name: entries[0]?.name, type, smart_count: entries.length })} {t('Move.outsideSharedFolder.content_2', { name: entries[0]?.name, type, smart_count: entries.length })} } actions={ <> } /> ) } return } MoveOutsideSharedFolderModal.propTypes = { /** List of files or folder to move */ entries: PropTypes.array.isRequired, /** Function called when the user cancels the move action */ onCancel: PropTypes.func.isRequired, /** Function called when the user confirms the move action */ onConfirm: PropTypes.func.isRequired } export { MoveOutsideSharedFolderModal } ================================================ FILE: src/modules/move/MoveSharedFolderInsideAnotherModal.jsx ================================================ import PropTypes from 'prop-types' import React from 'react' import { useQuery } from 'cozy-client' import { useSharingContext } from 'cozy-sharing' import Buttons from 'cozy-ui/transpiled/react/Buttons' import { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs' import Typography from 'cozy-ui/transpiled/react/Typography' import { useI18n } from 'twake-i18n' import { LoaderModal } from '@/components/LoaderModal' import { getEntriesName } from '@/modules/move/helpers' import { buildFileOrFolderByIdQuery, buildSharedDriveFileOrFolderByIdQuery } from '@/queries' /** * Alert the user when is trying to move a shared folder/file inside another shared folder */ const MoveSharedFolderInsideAnotherModal = ({ entries, folderId, driveId, onCancel, onConfirm }) => { const { t } = useI18n() const { byDocId } = useSharingContext() const folderQuery = driveId ? buildSharedDriveFileOrFolderByIdQuery({ fileId: folderId, driveId }) : buildFileOrFolderByIdQuery(folderId) const { fetchStatus, data } = useQuery( folderQuery.definition, folderQuery.options ) if (fetchStatus === 'loaded') { const sharedEntries = entries.filter( ({ _id }) => byDocId[_id] !== undefined ) return ( {t('Move.sharedFolderInsideAnother.content_1')} {t('Move.sharedFolderInsideAnother.content_2', { source: getEntriesName(entries, t), destination: data.name })}
    {sharedEntries.map(({ _id, name }) => (
  • {name}
  • ))}
} actions={ <> } /> ) } return } MoveSharedFolderInsideAnotherModal.propTypes = { /** List of files or folder to move */ entries: PropTypes.array.isRequired, /** Id of the destination folder */ folderId: PropTypes.string.isRequired, /** Function called when the user cancels the move action */ onCancel: PropTypes.func.isRequired, /** Function called when the user confirms the move action */ onConfirm: PropTypes.func.isRequired } export { MoveSharedFolderInsideAnotherModal } ================================================ FILE: src/modules/move/components/MoveModalSuccessAction.tsx ================================================ import React, { useState } from 'react' import { NavigateFunction } from 'react-router-dom' import { useClient } from 'cozy-client' import Button from 'cozy-ui/transpiled/react/Buttons' import { useAlert } from 'cozy-ui/transpiled/react/providers/Alert' import { useI18n } from 'twake-i18n' import { OpenFolderButton } from '@/components/Button/OpenFolderButton' import { File, FolderPickerEntry } from '@/components/FolderPicker/types' import { cancelMove } from '@/modules/move/helpers' import { useCancelable } from '@/modules/move/hooks/useCancelable' interface MoveModalSuccessActionProps { folder: File entries: FolderPickerEntry[] trashedFiles: File[] canCancel?: boolean refreshSharing: () => void navigate: NavigateFunction } const MoveModalSuccessAction: React.FC = ({ folder, entries, trashedFiles, canCancel = true, refreshSharing, navigate }) => { const { t } = useI18n() const client = useClient() const { registerCancelable } = useCancelable() const [isCancelling, setCancelling] = useState(false) const { showAlert } = useAlert() const handleCancel = async (): Promise => { setCancelling(true) await cancelMove({ entries, trashedFiles, client, registerCancelable, showAlert, t, refreshSharing }) } return ( <> {canCancel ? (