Skip to content

Commit

Permalink
feat: cascading folder restore
Browse files Browse the repository at this point in the history
  • Loading branch information
kulmann committed Oct 26, 2022
1 parent ceaa124 commit 27b90dc
Show file tree
Hide file tree
Showing 2 changed files with 72 additions and 77 deletions.
139 changes: 67 additions & 72 deletions packages/web-app-files/src/mixins/actions/restore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { mapActions, mapGetters, mapMutations, mapState } from 'vuex'
import PQueue from 'p-queue'
import { dirname } from 'path'
import { isLocationTrashActive } from '../../router'

Expand Down Expand Up @@ -56,48 +55,48 @@ export default {
...mapMutations('runtime/spaces', ['UPDATE_SPACE_FIELD']),
...mapMutations(['SET_QUOTA']),

async collectRestoreConflicts(resources) {
const parentFolders = {}
const conflicts = []
const resolvedResources = []
const missingFolderStructure = []
for (const resource of resources) {
async $_restore_collectConflicts(sortedResources: Resource[]) {
const existingResourcesCache = {}
const conflicts: Resource[] = []
const resolvedResources: Resource[] = []
const missingFolderPaths: string[] = []
for (const resource of sortedResources) {
const parentPath = dirname(resource.path)

// check if parent folder has already been requested
let parentResources = []
if (parentPath in parentFolders) {
parentResources = parentFolders[parentPath]
let existingResources: Resource[] = []
if (parentPath in existingResourcesCache) {
existingResources = existingResourcesCache[parentPath]
} else {
try {
parentResources = await this.$clientService.webdav.listFiles(this.space, {
existingResources = await this.$clientService.webdav.listFiles(this.space, {
path: parentPath
})
existingResources = existingResources.slice(1)
} catch (error) {
missingFolderStructure.push(resource)
missingFolderPaths.push(parentPath)
}
const resourceParentPath = dirname(resource.path)
parentResources = parentResources.filter((e) => dirname(e.path) === resourceParentPath)
parentFolders[parentPath] = parentResources
existingResourcesCache[parentPath] = existingResources
}
// Check for naming conflict in parent folder and between resources batch
const hasConflict =
parentResources.some((e) => e.name === resource.name) ||
resources.filter((e) => e.id !== resource.id).some((e) => e.path === resource.path)
if (!hasConflict) {
resolvedResources.push(resource)
} else {
existingResources.some((r) => r.name === resource.name) ||
resolvedResources
.filter((r) => r.id !== resource.id)
.some((r) => r.path === resource.path)
if (hasConflict) {
conflicts.push(resource)
} else {
resolvedResources.push(resource)
}
}
return {
parentFolders,
existingResourcesByPath: existingResourcesCache,
conflicts,
resolvedResources,
missingFolderStructure
missingFolderPaths: missingFolderPaths.filter((path) => !(path in existingResourcesCache))
}
},
async collectRestoreResolveStrategies(conflicts) {
async $_restore_collectResolveStrategies(conflicts: Resource[]) {
let count = 0
const resolvedConflicts = []
const allConflictsCount = conflicts.length
Expand Down Expand Up @@ -139,63 +138,58 @@ export default {
}
return resolvedConflicts
},
async restoreFolderStructure(resource) {
async $_restore_createFolderStructure(path: string, existingPaths: string[]) {
const { webdav } = clientService
const createdFolderPaths = []
const directory = dirname(resource.path)

const folders = directory.split('/').filter(Boolean)
const pathSegments = path.split('/').filter(Boolean)
let parentPath = ''
for (const subFolder of folders) {
for (const subFolder of pathSegments) {
const folderPath = urlJoin(parentPath, subFolder)
if (createdFolderPaths.includes(folderPath)) {
parentPath += `/${subFolder}`
createdFolderPaths.push(parentPath)
if (existingPaths.includes(folderPath)) {
parentPath = urlJoin(parentPath, subFolder)
continue
}

try {
await webdav.createFolder(this.space, { path: folderPath })
} catch (error) {
console.error(error)
}
} catch (ignored) {}

existingPaths.push(folderPath)
parentPath = folderPath
}

parentPath = urlJoin(parentPath, subFolder)
createdFolderPaths.push(parentPath)
return {
existingPaths
}
},
async restoreResources(resources, filesToOverwrite, missingFolderStructure) {
async $_restore_restoreResources(resources: Resource[], missingFolderPaths: string[]) {
const restoredResources = []
const failedResources = []
const restorePromises = []
const restoreQueue = new PQueue({ concurrency: 4 })
// resources need to be sorted by path ASC to recover the parents first in case of deep folder structure
const sortedResources = resources.sort((a,b) => a.path.length - b.path.length);

sortedResources.forEach(async (resource) => {
let createdFolderPaths = []
for (const resource of resources) {
const parentPath = dirname(resource.path)
if(missingFolderStructure.includes(resource) && !sortedResources.some(r => r.path.includes(parentPath))) {
await this.restoreFolderStructure(resource)
if (missingFolderPaths.includes(parentPath)) {
const { existingPaths } = await this.$_restore_createFolderStructure(
parentPath,
createdFolderPaths
)
createdFolderPaths = existingPaths
}
const overwrite = filesToOverwrite.includes(resource)

restorePromises.push(
restoreQueue.add(async () => {
try {
await this.$clientService.webdav.restoreFile(this.space, resource, resource, {
overwrite
})
restoredResources.push(resource)
} catch (e) {
console.error(e)
failedResources.push(resource)
}
try {
await this.$clientService.webdav.restoreFile(this.space, resource, resource, {
overwrite: true
})
)
})
await Promise.all(restorePromises)
restoredResources.push(resource)
} catch (e) {
console.error(e)
failedResources.push(resource)
}
}

// success handler (for partial and full success)
if (restoredResources.length > 0) {
if (restoredResources.length) {
this.removeFilesFromTrashbin(restoredResources)
let translated
const translateParams: any = {}
Expand All @@ -212,7 +206,7 @@ export default {
}

// failure handler (for partial and full failure)
if (failedResources.length > 0) {
if (failedResources.length) {
let translated
const translateParams: any = {}
if (failedResources.length === 1) {
Expand All @@ -228,7 +222,7 @@ export default {
})
}

// Load quota
// Reload quota
if (this.capabilities?.spaces?.enabled) {
const accessToken = this.$store.getters['runtime/auth/accessToken']
const graphClient = clientService.graphAuthenticated(this.configuration.server, accessToken)
Expand All @@ -244,40 +238,41 @@ export default {
}
},

async $_restore_trigger({ resources }) {
async $_restore_trigger({ resources }: { resources: Resource[] }) {
// resources need to be sorted by path ASC to recover the parents first in case of deep nested folder structure
const sortedResources = resources.sort((a, b) => a.path.length - b.path.length)

// collect and request existing files in associated parent folders of each resource
const { parentFolders, conflicts, resolvedResources, missingFolderStructure } = await this.collectRestoreConflicts(
resources
)
const { existingResourcesByPath, conflicts, resolvedResources, missingFolderPaths } =
await this.$_restore_collectConflicts(sortedResources)

// iterate through conflicts and collect resolve strategies
const resolvedConflicts = await this.collectRestoreResolveStrategies(conflicts)
const resolvedConflicts = await this.$_restore_collectResolveStrategies(conflicts)

// iterate through conflicts and behave according to strategy
const filesToOverwrite = resolvedConflicts
.filter((e) => e.strategy === ResolveStrategy.REPLACE)
.map((e) => e.resource)
resolvedResources.push(...filesToOverwrite)
const filesToKeepBoth = resolvedConflicts

.filter((e) => e.strategy === ResolveStrategy.KEEP_BOTH)
.map((e) => e.resource)

for (let resource of filesToKeepBoth) {
resource = { ...resource }
const parentPath = dirname(resource.path)
const parentResources = parentFolders[parentPath] || []
const existingResources = existingResourcesByPath[parentPath] || []
const extension = extractExtensionFromFile(resource)
const resolvedName = resolveFileNameDuplicate(resource.name, extension, [
...parentResources,
...existingResources,
...resolvedConflicts.map((e) => e.resource),
...resolvedResources
])
resource.name = resolvedName
resource.path = urlJoin(parentPath, resolvedName)
resolvedResources.push(resource)
}
this.restoreResources(resolvedResources, filesToOverwrite, missingFolderStructure)
return this.$_restore_restoreResources(resolvedResources, missingFolderPaths)
}
}
}
10 changes: 5 additions & 5 deletions packages/web-app-files/tests/unit/mixins/actions/restore.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('restore', () => {
const wrapper = getWrapper()
const showMessageStub = jest.spyOn(wrapper.vm, 'showMessage')
const removeFilesFromTrashbinStub = jest.spyOn(wrapper.vm, 'removeFilesFromTrashbin')
await wrapper.vm.restoreResources([{ id: '1' }], [])
await wrapper.vm.$_restore_restoreResources([{ id: '1', path: '/1' }], [])

expect(showMessageStub).toHaveBeenCalledTimes(1)
expect(removeFilesFromTrashbinStub).toHaveBeenCalledTimes(1)
Expand All @@ -63,14 +63,14 @@ describe('restore', () => {
const wrapper = getWrapper({ resolveClearTrashBin: false })
const showMessageStub = jest.spyOn(wrapper.vm, 'showMessage')
const removeFilesFromTrashbinStub = jest.spyOn(wrapper.vm, 'removeFilesFromTrashbin')
await wrapper.vm.restoreResources([{ id: '1' }], [])
await wrapper.vm.$_restore_restoreResources([{ id: '1', path: '/1' }], [])

expect(showMessageStub).toHaveBeenCalledTimes(1)
expect(removeFilesFromTrashbinStub).toHaveBeenCalledTimes(0)
})
it('should request parent folder on collecting restore conflicts', async () => {
const wrapper = getWrapper()
await wrapper.vm.collectRestoreConflicts([{ id: '1', path: '1', name: '1' }])
await wrapper.vm.$_restore_collectConflicts([{ id: '1', path: '1', name: '1' }])

expect(wrapper.vm.$clientService.webdav.listFiles).toHaveBeenCalledWith(expect.anything(), {
path: '.'
Expand All @@ -80,14 +80,14 @@ describe('restore', () => {
const wrapper = getWrapper()
const resourceOne = { id: '1', path: '1', name: '1' }
const resourceTwo = { id: '2', path: '1', name: '1' }
const { conflicts } = await wrapper.vm.collectRestoreConflicts([resourceOne, resourceTwo])
const { conflicts } = await wrapper.vm.$_restore_collectConflicts([resourceOne, resourceTwo])

expect(conflicts).toContain(resourceTwo)
})
it('should add files without conflict to resolved resources', async () => {
const wrapper = getWrapper()
const resource = { id: '1', path: '1', name: '1' }
const { resolvedResources } = await wrapper.vm.collectRestoreConflicts([resource])
const { resolvedResources } = await wrapper.vm.$_restore_collectConflicts([resource])

expect(resolvedResources).toContain(resource)
})
Expand Down

0 comments on commit 27b90dc

Please sign in to comment.