file-tree-actionable.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  1. import {
  2. createContext,
  3. useCallback,
  4. useMemo,
  5. useReducer,
  6. useContext,
  7. useEffect,
  8. useState,
  9. FC,
  10. } from 'react'
  11. import { mapSeries } from '../../../infrastructure/promise'
  12. import {
  13. syncRename,
  14. syncDelete,
  15. syncMove,
  16. syncCreateEntity,
  17. NewDocEntity,
  18. NewLinkedFileEntity,
  19. NewEntity,
  20. syncRootDocId,
  21. } from '../util/sync-mutation'
  22. import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
  23. import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
  24. import { isBlockedFilename, isCleanFilename } from '../util/safe-path'
  25. import { useProjectContext } from '../../../shared/context/project-context'
  26. import { useFileTreeData } from '../../../shared/context/file-tree-data-context'
  27. import { useFileTreeSelectable } from './file-tree-selectable'
  28. import {
  29. InvalidFilenameError,
  30. BlockedFilenameError,
  31. DuplicateFilenameError,
  32. DuplicateFilenameMoveError,
  33. } from '../errors'
  34. import { Folder } from '../../../../../types/folder'
  35. import { useReferencesContext } from '@/features/ide-react/context/references-context'
  36. import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
  37. import { FileTreeEntity } from '@ol-types/file-tree-entity'
  38. import { Doc } from '@ol-types/doc'
  39. import { isValidTeXFile } from '@/main/is-valid-tex-file'
  40. type DroppedFile = File & {
  41. relativePath?: string
  42. }
  43. type DroppedFiles = {
  44. files: DroppedFile[]
  45. targetFolderId: string
  46. }
  47. const FileTreeActionableContext = createContext<
  48. | {
  49. isDeleting: boolean
  50. isRenaming: boolean
  51. isCreatingFile: boolean
  52. isCreatingFolder: boolean
  53. isMoving: boolean
  54. inFlight: boolean
  55. actionedEntities: FileTreeEntity[] | null
  56. newFileCreateMode: any | null
  57. error: any | null
  58. canDelete: boolean
  59. canBulkDelete: boolean
  60. canRename: boolean
  61. canCreate: boolean
  62. parentFolderId: string
  63. selectedFileName: string | null | undefined
  64. isDuplicate: (parentFolderId: string, name: string) => boolean
  65. startRenaming: any
  66. finishRenaming: any
  67. startDeleting: any
  68. finishDeleting: any
  69. finishMoving: any
  70. startCreatingFile: any
  71. startCreatingFolder: any
  72. finishCreatingFolder: any
  73. startCreatingDocOrFile: any
  74. startUploadingDocOrFile: any
  75. finishCreatingDoc: (
  76. entity: Omit<NewDocEntity, 'endpoint'>
  77. ) => Promise<Doc | undefined>
  78. finishCreatingLinkedFile: (
  79. entity: Omit<NewLinkedFileEntity, 'endpoint'>
  80. ) => Promise<{ new_file_id: string } | undefined>
  81. cancel: () => void
  82. droppedFiles: { files: File[]; targetFolderId: string } | null
  83. setDroppedFiles: (value: DroppedFiles | null) => void
  84. downloadPath?: string
  85. canSetRootDocId: boolean
  86. setRootDocId: () => Promise<void>
  87. }
  88. | undefined
  89. >(undefined)
  90. /* eslint-disable no-unused-vars */
  91. enum ACTION_TYPES {
  92. START_RENAME = 'START_RENAME',
  93. START_DELETE = 'START_DELETE',
  94. DELETING = 'DELETING',
  95. START_CREATE_FILE = 'START_CREATE_FILE',
  96. START_CREATE_FOLDER = 'START_CREATE_FOLDER',
  97. CREATING_FILE = 'CREATING_FILE',
  98. CREATING_FOLDER = 'CREATING_FOLDER',
  99. MOVING = 'MOVING',
  100. CANCEL = 'CANCEL',
  101. CLEAR = 'CLEAR',
  102. ERROR = 'ERROR',
  103. }
  104. /* eslint-enable no-unused-vars */
  105. type State = {
  106. isDeleting: boolean
  107. isRenaming: boolean
  108. isCreatingFile: boolean
  109. isCreatingFolder: boolean
  110. isMoving: boolean
  111. inFlight: boolean
  112. actionedEntities: FileTreeEntity[] | null
  113. newFileCreateMode: any | null
  114. error: unknown | null
  115. }
  116. const defaultState: State = {
  117. isDeleting: false,
  118. isRenaming: false,
  119. isCreatingFile: false,
  120. isCreatingFolder: false,
  121. isMoving: false,
  122. inFlight: false,
  123. actionedEntities: null,
  124. newFileCreateMode: null,
  125. error: null,
  126. }
  127. function fileTreeActionableReadOnlyReducer(state: State) {
  128. return state
  129. }
  130. type Action =
  131. | {
  132. type: ACTION_TYPES.START_RENAME
  133. }
  134. | {
  135. type: ACTION_TYPES.START_DELETE
  136. actionedEntities: FileTreeEntity[] | null
  137. }
  138. | {
  139. type: ACTION_TYPES.START_CREATE_FILE
  140. newFileCreateMode: any | null
  141. }
  142. | {
  143. type: ACTION_TYPES.START_CREATE_FOLDER
  144. }
  145. | {
  146. type: ACTION_TYPES.CREATING_FILE
  147. }
  148. | {
  149. type: ACTION_TYPES.CREATING_FOLDER
  150. }
  151. | {
  152. type: ACTION_TYPES.DELETING
  153. }
  154. | {
  155. type: ACTION_TYPES.MOVING
  156. }
  157. | {
  158. type: ACTION_TYPES.CLEAR
  159. }
  160. | {
  161. type: ACTION_TYPES.CANCEL
  162. }
  163. | {
  164. type: ACTION_TYPES.ERROR
  165. error: unknown
  166. }
  167. function fileTreeActionableReducer(state: State, action: Action) {
  168. switch (action.type) {
  169. case ACTION_TYPES.START_RENAME:
  170. return { ...defaultState, isRenaming: true }
  171. case ACTION_TYPES.START_DELETE:
  172. return {
  173. ...defaultState,
  174. isDeleting: true,
  175. actionedEntities: action.actionedEntities,
  176. }
  177. case ACTION_TYPES.START_CREATE_FILE:
  178. return {
  179. ...defaultState,
  180. isCreatingFile: true,
  181. newFileCreateMode: action.newFileCreateMode,
  182. }
  183. case ACTION_TYPES.START_CREATE_FOLDER:
  184. return { ...defaultState, isCreatingFolder: true }
  185. case ACTION_TYPES.CREATING_FILE:
  186. return {
  187. ...defaultState,
  188. isCreatingFile: true,
  189. newFileCreateMode: state.newFileCreateMode,
  190. inFlight: true,
  191. }
  192. case ACTION_TYPES.CREATING_FOLDER:
  193. return { ...defaultState, isCreatingFolder: true, inFlight: true }
  194. case ACTION_TYPES.DELETING:
  195. // keep `actionedEntities` so the entities list remains displayed in the
  196. // delete modal
  197. return {
  198. ...defaultState,
  199. isDeleting: true,
  200. inFlight: true,
  201. actionedEntities: state.actionedEntities,
  202. }
  203. case ACTION_TYPES.MOVING:
  204. return {
  205. ...defaultState,
  206. isMoving: true,
  207. inFlight: true,
  208. }
  209. case ACTION_TYPES.CLEAR:
  210. return { ...defaultState }
  211. case ACTION_TYPES.CANCEL:
  212. if (state.inFlight) return state
  213. return { ...defaultState }
  214. case ACTION_TYPES.ERROR:
  215. return { ...state, inFlight: false, error: action.error }
  216. default:
  217. throw new Error(`Unknown user action type: ${(action as Action).type}`)
  218. }
  219. }
  220. export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
  221. children,
  222. }) => {
  223. const { projectId, project, updateProject } = useProjectContext()
  224. const { fileTreeReadOnly } = useFileTreeData()
  225. const { indexAllReferences } = useReferencesContext()
  226. const { write } = usePermissionsContext()
  227. const rootDocId = project?.rootDocId
  228. const [state, dispatch] = useReducer(
  229. fileTreeReadOnly
  230. ? fileTreeActionableReadOnlyReducer
  231. : fileTreeActionableReducer,
  232. defaultState
  233. )
  234. const { fileTreeData, dispatchRename, dispatchMove } = useFileTreeData()
  235. const { selectedEntityIds, isRootFolderSelected } = useFileTreeSelectable()
  236. const [droppedFiles, setDroppedFiles] = useState<DroppedFiles | null>(null)
  237. const startRenaming = useCallback(() => {
  238. dispatch({ type: ACTION_TYPES.START_RENAME })
  239. }, [])
  240. // update the entity with the new name immediately in the tree, but revert to
  241. // the old name if the sync fails
  242. const finishRenaming = useCallback(
  243. (newName: string) => {
  244. const selectedEntityId = Array.from(selectedEntityIds)[0]
  245. const found = findInTreeOrThrow(fileTreeData, selectedEntityId)
  246. const oldName = found.entity.name
  247. if (newName === oldName) {
  248. return dispatch({ type: ACTION_TYPES.CLEAR })
  249. }
  250. const error = validateRename(fileTreeData, found, newName)
  251. if (error) return dispatch({ type: ACTION_TYPES.ERROR, error })
  252. dispatch({ type: ACTION_TYPES.CLEAR })
  253. dispatchRename(selectedEntityId, newName)
  254. return syncRename(projectId, found.type, found.entity._id, newName).catch(
  255. error => {
  256. dispatchRename(selectedEntityId, oldName)
  257. // The state from this error action isn't used anywhere right now
  258. // but we need to handle the error for linting
  259. dispatch({ type: ACTION_TYPES.ERROR, error })
  260. }
  261. )
  262. },
  263. [dispatchRename, fileTreeData, projectId, selectedEntityIds]
  264. )
  265. const isDuplicate = useCallback(
  266. (parentFolderId: string, name: string) => {
  267. return !isNameUniqueInFolder(fileTreeData, parentFolderId, name)
  268. },
  269. [fileTreeData]
  270. )
  271. // init deletion flow (this will open the delete modal).
  272. // A copy of the selected entities is set as `actionedEntities` so it is kept
  273. // unchanged as the entities are deleted and the selection is updated
  274. const startDeleting = useCallback(() => {
  275. const actionedEntities = Array.from(selectedEntityIds).map(
  276. entityId => findInTreeOrThrow(fileTreeData, entityId).entity
  277. )
  278. dispatch({ type: ACTION_TYPES.START_DELETE, actionedEntities })
  279. }, [fileTreeData, selectedEntityIds])
  280. // deletes entities in series. Tree will be updated via the socket event
  281. const finishDeleting = useCallback(() => {
  282. dispatch({ type: ACTION_TYPES.DELETING })
  283. let shouldReindexReferences = false
  284. return (
  285. mapSeries(Array.from(selectedEntityIds), id => {
  286. const found = findInTreeOrThrow(fileTreeData, id)
  287. shouldReindexReferences =
  288. shouldReindexReferences || /\.bib$/.test(found.entity.name)
  289. return syncDelete(projectId, found.type, found.entity._id).catch(
  290. error => {
  291. // throw unless 404
  292. if (error.info.statusCode !== 404) {
  293. throw error
  294. }
  295. }
  296. )
  297. })
  298. // @ts-ignore (TODO: improve mapSeries types)
  299. .then(() => {
  300. if (shouldReindexReferences) {
  301. indexAllReferences(true)
  302. }
  303. dispatch({ type: ACTION_TYPES.CLEAR })
  304. })
  305. .catch((error: Error) => {
  306. // set an error and allow user to retry
  307. dispatch({ type: ACTION_TYPES.ERROR, error })
  308. })
  309. )
  310. }, [fileTreeData, projectId, selectedEntityIds, indexAllReferences])
  311. // moves entities. Tree is updated immediately and data are sync'd after.
  312. const finishMoving = useCallback(
  313. (toFolderId: string, draggedEntityIds: Set<string>) => {
  314. dispatch({ type: ACTION_TYPES.MOVING })
  315. // find entities and filter out no-ops and nested files
  316. const founds = Array.from(draggedEntityIds)
  317. .map(draggedEntityId =>
  318. findInTreeOrThrow(fileTreeData, draggedEntityId)
  319. )
  320. .filter(
  321. found =>
  322. found.parentFolderId !== toFolderId &&
  323. !draggedEntityIds.has(found.parentFolderId)
  324. )
  325. // make sure all entities can be moved, return early otherwise
  326. const isMoveToRoot = toFolderId === fileTreeData._id
  327. const validationError = founds
  328. .map(found =>
  329. validateMove(fileTreeData, toFolderId, found, isMoveToRoot)
  330. )
  331. .find(error => error)
  332. if (validationError) {
  333. return dispatch({ type: ACTION_TYPES.ERROR, error: validationError })
  334. }
  335. // keep track of old parent folder ids so we can revert entities if sync fails
  336. const oldParentFolderIds: Record<string, string> = {}
  337. let isMoveFailed = false
  338. // dispatch moves immediately
  339. founds.forEach(found => {
  340. oldParentFolderIds[found.entity._id] = found.parentFolderId
  341. dispatchMove(found.entity._id, toFolderId)
  342. })
  343. // sync dispatched moves after
  344. return (
  345. mapSeries(founds, async found => {
  346. try {
  347. await syncMove(projectId, found.type, found.entity._id, toFolderId)
  348. } catch (error) {
  349. isMoveFailed = true
  350. dispatchMove(found.entity._id, oldParentFolderIds[found.entity._id])
  351. dispatch({ type: ACTION_TYPES.ERROR, error })
  352. }
  353. })
  354. // @ts-ignore (TODO: improve mapSeries types)
  355. .then(() => {
  356. if (!isMoveFailed) {
  357. dispatch({ type: ACTION_TYPES.CLEAR })
  358. }
  359. })
  360. )
  361. },
  362. [dispatchMove, fileTreeData, projectId]
  363. )
  364. const startCreatingFolder = useCallback(() => {
  365. dispatch({ type: ACTION_TYPES.START_CREATE_FOLDER })
  366. }, [])
  367. const parentFolderId = useMemo(() => {
  368. return getSelectedParentFolderId(
  369. fileTreeData,
  370. selectedEntityIds,
  371. isRootFolderSelected
  372. )
  373. }, [fileTreeData, selectedEntityIds, isRootFolderSelected])
  374. // return the name of the selected file or doc if there is only one selected
  375. const selectedFileName = useMemo(() => {
  376. if (selectedEntityIds.size === 1) {
  377. const [selectedEntityId] = selectedEntityIds
  378. const selectedEntity = findInTree(fileTreeData, selectedEntityId)
  379. return selectedEntity?.entity?.name
  380. }
  381. return null
  382. }, [fileTreeData, selectedEntityIds])
  383. const finishCreatingEntity = useCallback(
  384. (entity: NewEntity) => {
  385. const error = validateCreate(fileTreeData, parentFolderId, entity)
  386. if (error) {
  387. return Promise.reject(error)
  388. }
  389. return syncCreateEntity(projectId, parentFolderId, entity)
  390. },
  391. [fileTreeData, parentFolderId, projectId]
  392. )
  393. const finishCreatingFolder = useCallback(
  394. (name: string) => {
  395. dispatch({ type: ACTION_TYPES.CREATING_FOLDER })
  396. return finishCreatingEntity({ endpoint: 'folder', name })
  397. .then(() => {
  398. dispatch({ type: ACTION_TYPES.CLEAR })
  399. })
  400. .catch(error => {
  401. dispatch({ type: ACTION_TYPES.ERROR, error })
  402. })
  403. },
  404. [finishCreatingEntity]
  405. )
  406. const startCreatingFile = useCallback((newFileCreateMode: any) => {
  407. dispatch({ type: ACTION_TYPES.START_CREATE_FILE, newFileCreateMode })
  408. }, [])
  409. const startCreatingDocOrFile = useCallback(() => {
  410. startCreatingFile('doc')
  411. }, [startCreatingFile])
  412. const startUploadingDocOrFile = useCallback(() => {
  413. startCreatingFile('upload')
  414. }, [startCreatingFile])
  415. type FinishCreatingDocOrFileReturn<T> = T extends NewDocEntity
  416. ? Promise<Doc | undefined>
  417. : T extends NewLinkedFileEntity
  418. ? Promise<{ new_file_id: string } | undefined>
  419. : never
  420. const finishCreatingDocOrFile = useCallback(
  421. <T extends NewDocEntity | NewLinkedFileEntity>(
  422. entity: T
  423. ): FinishCreatingDocOrFileReturn<T> => {
  424. dispatch({ type: ACTION_TYPES.CREATING_FILE })
  425. return finishCreatingEntity(entity)
  426. .then(docOrFile => {
  427. dispatch({ type: ACTION_TYPES.CLEAR })
  428. return docOrFile
  429. })
  430. .catch(error => {
  431. dispatch({ type: ACTION_TYPES.ERROR, error })
  432. }) as FinishCreatingDocOrFileReturn<T>
  433. },
  434. [finishCreatingEntity]
  435. )
  436. const finishCreatingDoc = useCallback(
  437. (entity: Omit<NewDocEntity, 'endpoint'>) => {
  438. return finishCreatingDocOrFile({ ...entity, endpoint: 'doc' })
  439. },
  440. [finishCreatingDocOrFile]
  441. )
  442. const finishCreatingLinkedFile = useCallback(
  443. (entity: Omit<NewLinkedFileEntity, 'endpoint'>) => {
  444. return finishCreatingDocOrFile({ ...entity, endpoint: 'linked_file' })
  445. },
  446. [finishCreatingDocOrFile]
  447. )
  448. const cancel = useCallback(() => {
  449. dispatch({ type: ACTION_TYPES.CANCEL })
  450. }, [])
  451. // listen for `file-tree.start-creating` events
  452. useEffect(() => {
  453. function handleEvent(event: Event) {
  454. dispatch({
  455. type: ACTION_TYPES.START_CREATE_FILE,
  456. newFileCreateMode: (event as CustomEvent<{ mode: string }>).detail.mode,
  457. })
  458. }
  459. window.addEventListener('file-tree.start-creating', handleEvent)
  460. return () => {
  461. window.removeEventListener('file-tree.start-creating', handleEvent)
  462. }
  463. }, [])
  464. // build the path for downloading a single file or doc
  465. const downloadPath = useMemo(() => {
  466. if (selectedEntityIds.size === 1) {
  467. const [selectedEntityId] = selectedEntityIds
  468. const selectedEntity = findInTree(fileTreeData, selectedEntityId)
  469. if (selectedEntity?.type === 'fileRef') {
  470. return `/project/${projectId}/blob/${selectedEntity.entity.hash}`
  471. }
  472. if (selectedEntity?.type === 'doc') {
  473. return `/project/${projectId}/doc/${selectedEntityId}/download`
  474. }
  475. }
  476. }, [fileTreeData, projectId, selectedEntityIds])
  477. const canSetRootDocId = useMemo(() => {
  478. // must have write permission on the project
  479. if (!write) {
  480. return false
  481. }
  482. // must be only one file selected
  483. if (!selectedFileName) {
  484. return false
  485. }
  486. // must not already be the root doc
  487. if (rootDocId && selectedEntityIds.has(rootDocId)) {
  488. return false
  489. }
  490. // must have a valid root doc extension
  491. return isValidTeXFile(selectedFileName)
  492. }, [rootDocId, selectedEntityIds, selectedFileName, write])
  493. const setRootDocId = useCallback(async () => {
  494. const [selectedEntityId] = selectedEntityIds
  495. await syncRootDocId(projectId, selectedEntityId)
  496. updateProject({ rootDocId: selectedEntityId })
  497. }, [projectId, selectedEntityIds, updateProject])
  498. const value = useMemo(
  499. () => ({
  500. canDelete: write && selectedEntityIds.size > 0 && !isRootFolderSelected,
  501. canBulkDelete:
  502. write && selectedEntityIds.size > 1 && !isRootFolderSelected,
  503. canRename: write && selectedEntityIds.size === 1 && !isRootFolderSelected,
  504. canCreate: write && selectedEntityIds.size < 2,
  505. ...state,
  506. parentFolderId,
  507. selectedFileName,
  508. isDuplicate,
  509. startRenaming,
  510. finishRenaming,
  511. startDeleting,
  512. finishDeleting,
  513. finishMoving,
  514. startCreatingFile,
  515. startCreatingFolder,
  516. finishCreatingFolder,
  517. startCreatingDocOrFile,
  518. startUploadingDocOrFile,
  519. finishCreatingDoc,
  520. finishCreatingLinkedFile,
  521. cancel,
  522. droppedFiles,
  523. setDroppedFiles,
  524. downloadPath,
  525. canSetRootDocId,
  526. setRootDocId,
  527. }),
  528. [
  529. cancel,
  530. downloadPath,
  531. droppedFiles,
  532. finishCreatingDoc,
  533. finishCreatingFolder,
  534. finishCreatingLinkedFile,
  535. finishDeleting,
  536. finishMoving,
  537. finishRenaming,
  538. isDuplicate,
  539. isRootFolderSelected,
  540. parentFolderId,
  541. selectedFileName,
  542. selectedEntityIds.size,
  543. startCreatingDocOrFile,
  544. startCreatingFile,
  545. startCreatingFolder,
  546. startDeleting,
  547. startRenaming,
  548. startUploadingDocOrFile,
  549. state,
  550. write,
  551. canSetRootDocId,
  552. setRootDocId,
  553. ]
  554. )
  555. return (
  556. <FileTreeActionableContext.Provider value={value}>
  557. {children}
  558. </FileTreeActionableContext.Provider>
  559. )
  560. }
  561. export function useFileTreeActionable() {
  562. const context = useContext(FileTreeActionableContext)
  563. if (!context) {
  564. throw new Error(
  565. 'useFileTreeActionable is only available inside FileTreeActionableProvider'
  566. )
  567. }
  568. return context
  569. }
  570. function getSelectedParentFolderId(
  571. fileTreeData: Folder,
  572. selectedEntityIds: Set<string>,
  573. isRootFolderSelected: boolean
  574. ) {
  575. if (isRootFolderSelected) {
  576. return fileTreeData._id
  577. }
  578. // we expect only one entity to be selected in that case, so we pick the first
  579. const selectedEntityId = Array.from(selectedEntityIds)[0]
  580. if (!selectedEntityId) {
  581. // in some cases no entities are selected. Return the root folder id then.
  582. return fileTreeData._id
  583. }
  584. const found = findInTree(fileTreeData, selectedEntityId)
  585. if (!found) {
  586. // if the entity isn't in the tree, return the root folder id.
  587. return fileTreeData._id
  588. }
  589. return found.type === 'folder' ? found.entity._id : found.parentFolderId
  590. }
  591. function validateCreate(
  592. fileTreeData: Folder,
  593. parentFolderId: string,
  594. entity: { name: string; endpoint: string }
  595. ) {
  596. if (!isCleanFilename(entity.name)) {
  597. return new InvalidFilenameError()
  598. }
  599. if (!isNameUniqueInFolder(fileTreeData, parentFolderId, entity.name)) {
  600. return new DuplicateFilenameError()
  601. }
  602. // check that the name of a file is allowed, if creating in the root folder
  603. const isMoveToRoot = parentFolderId === fileTreeData._id
  604. const isFolder = entity.endpoint === 'folder'
  605. if (isMoveToRoot && !isFolder && isBlockedFilename(entity.name)) {
  606. return new BlockedFilenameError()
  607. }
  608. }
  609. function validateRename(
  610. fileTreeData: Folder,
  611. found: { parentFolderId: string; path: string[]; type: string },
  612. newName: string
  613. ) {
  614. if (!isCleanFilename(newName)) {
  615. return new InvalidFilenameError()
  616. }
  617. if (!isNameUniqueInFolder(fileTreeData, found.parentFolderId, newName)) {
  618. return new DuplicateFilenameError()
  619. }
  620. const isTopLevel = found.path.length === 1
  621. const isFolder = found.type === 'folder'
  622. if (isTopLevel && !isFolder && isBlockedFilename(newName)) {
  623. return new BlockedFilenameError()
  624. }
  625. }
  626. function validateMove(
  627. fileTreeData: Folder,
  628. toFolderId: string,
  629. found: { entity: { name: string }; type: string },
  630. isMoveToRoot: boolean
  631. ) {
  632. if (!isNameUniqueInFolder(fileTreeData, toFolderId, found.entity.name)) {
  633. const error = new DuplicateFilenameMoveError()
  634. ;(error as DuplicateFilenameMoveError & { entityName: string }).entityName =
  635. found.entity.name
  636. return error
  637. }
  638. const isFolder = found.type === 'folder'
  639. if (isMoveToRoot && !isFolder && isBlockedFilename(found.entity.name)) {
  640. return new BlockedFilenameError()
  641. }
  642. }