Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add copy and "move and organise" features #1143

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
47 changes: 43 additions & 4 deletions src/components/modal/MoveToFolderModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
<script lang="ts">
import { defineComponent } from 'vue';

import { FilePickerType } from '@nextcloud/dialogs';
import { showInfo } from '@nextcloud/dialogs';

const NcProgressBar = () => import('@nextcloud/vue/dist/Components/NcProgressBar.js');
Expand Down Expand Up @@ -61,13 +60,49 @@ export default defineComponent({
},

async chooseFolderPath() {
enum Mode {
Move = 1,
Copy = 2,
Organise = 3,
}
let mode: Mode = Mode.Move as Mode;
let destination = await utils.chooseNcFolder(
this.t('memories', 'Choose a folder'),
this.config.folders_path,
FilePickerType.Move,
() => [
{
label: 'Move and organise',
callback: () => (mode = Mode.Organise),
},
{
label: 'Copy',
callback: () => (mode = Mode.Copy),
},
{
label: 'Move',
type: 'primary',
callback: () => (mode = Mode.Move),
},
],
);

let gen;
// Fails if the target exists, same behavior with Nextcloud files implementation.
const gen = dav.movePhotos(this.photos, destination, false);
switch (mode) {
case Mode.Organise: {
gen = dav.movePhotosByDate(this.photos, destination, false);
break;
}
case Mode.Copy: {
gen = dav.copyPhotos(this.photos, destination, false);
break;
}
case Mode.Move: {
gen = dav.movePhotos(this.photos, destination, false);
break;
}
}

this.show = true;

for await (const fids of gen) {
Expand All @@ -76,7 +111,11 @@ export default defineComponent({
}

const n = this.photosDone;
showInfo(this.n('memories', '{n} item moved to folder', '{n} items moved to folder', n, { n }));
if (mode === Mode.Copy) {
showInfo(this.n('memories', '{n} item copied to folder', '{n} items copied to folder', n, { n }));
} else {
showInfo(this.n('memories', '{n} item moved to folder', '{n} items moved to folder', n, { n }));
}
this.close();
},
},
Expand Down
179 changes: 177 additions & 2 deletions src/services/dav/base.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { showError } from '@nextcloud/dialogs';
import path from 'path';

import axios from '@nextcloud/axios';
import { showError } from '@nextcloud/dialogs';

import { getAlbumFileInfos } from './albums';
import client, { remotePath } from './client';

import * as nativex from '@native';
import { API } from '@services/API';
import { translate as t } from '@services/l10n';
import * as utils from '@services/utils';
import * as nativex from '@native';

import type { IFileInfo, IImageInfo, IPhoto } from '@typings';
import type { ResponseDataDetailed, SearchResult } from 'webdav';
Expand Down Expand Up @@ -285,6 +287,75 @@ export async function* deletePhotos(photos: IPhoto[], confirm: boolean = true) {
yield* runInParallel(calls, 10);
}

/**
* copy all files in a given list of Ids to given destination
*
* @param photos list of photos to copy
* @param destination to copy photos into
* @param overwrite behaviour if the target exists. `true` overwrites, `false` fails.
* @returns list of file ids that were copied
*/
export async function* copyPhotos(photos: IPhoto[], destination: string, overwrite: boolean) {
if (photos.length === 0) {
return;
}

// Set absolute target path
const prefixPath = `files/${utils.uid}`;
let targetPath = prefixPath + destination;
if (!targetPath.endsWith('/')) {
targetPath += '/';
}

// Also copy the stack files
photos = await extendWithStack(photos);
const fileIdsSet = new Set(photos.map((p) => p.fileid));

// Get files data
let fileInfos: IFileInfo[] = [];
try {
fileInfos = await getFiles(photos);
} catch (e) {
console.error('Failed to get file info for files to copy', photos, e);
showError(t('memories', 'Failed to copy files.'));
return;
}

// Copy each file
fileInfos = fileInfos.filter((f) => fileIdsSet.has(f.fileid));
const calls = fileInfos.map((fileInfo) => async () => {
try {
await client.copyFile(
fileInfo.originalFilename,
targetPath + fileInfo.basename,
// @ts-ignore - https://github.com/perry-mitchell/webdav-client/issues/329
{ headers: { Overwrite: overwrite ? 'T' : 'F' } },
);
return fileInfo.fileid;
} catch (error) {
console.error('Failed to copy', fileInfo, error);
if (error.response?.status === 412) {
// Precondition failed (only if `overwrite` flag set to false)
showError(
t('memories', 'Could not copy {fileName}, target exists.', {
fileName: fileInfo.filename,
}),
);
return 0;
}

showError(
t('memories', 'Failed to copy {fileName}.', {
fileName: fileInfo.filename,
}),
);
return 0;
}
});

yield* runInParallel(calls, 10);
}

/**
* Move all files in a given list of Ids to given destination
*
Expand Down Expand Up @@ -354,6 +425,110 @@ export async function* movePhotos(photos: IPhoto[], destination: string, overwri
yield* runInParallel(calls, 10);
}

/**
* Move multiple files in given lists of Ids to corresponding destinations in a year/month folder structure.
*
* @param photos list of photos to move
* @param destination to move photos into
* @param overwrite behaviour if the target exists. `true` overwrites, `false` fails.
* @returns list of file ids that were moved
*/
export async function* movePhotosByDate(photos: IPhoto[], destination: string, overwrite: boolean) {
if (photos.length === 0) {
return;
}

// Set absolute target path
const prefixPath = `files/${utils.uid}`;
destination = path.join(prefixPath, destination);
const datePaths: Map<string, Set<string>> = new Map(); // {'year': {'month1', 'month2'}}

photos = await extendWithStack(photos);
const fileIdsSet = new Set(photos.map((p) => p.fileid));

let fileInfos: IFileInfo[] = [];

try {
fileInfos = await getFiles(photos);
} catch (e) {
console.error('Failed to get file info for files to move', photos, e);
showError(t('memories', 'Failed to move files.'));
return;
}

const moveDirectives: Array<[string, IFileInfo]> = new Array();

photos.forEach((photo, i) => {
if (!fileIdsSet.has(fileInfos[i].fileid)) {
return;
}

const date = utils.dayIdToDate(photo.dayid);

const year = date.getFullYear().toString();
const month = String(date.getMonth() + 1).padStart(2, '0');

const months = datePaths.get(year) || new Set();
months.add(month);
datePaths.set(year, months);

const datePath = path.join(destination, `/${year}/${month}`);

moveDirectives.push([datePath, fileInfos[i]]);
});

async function createIfNotExist(directory: string, subDirectories: Iterable<string>) {
let existing = await client.getDirectoryContents(directory);
if ('data' in existing) {
existing = existing.data;
}
existing = existing.filter((f) => f.type === 'directory');
for (const subDirectory of subDirectories) {
if (!existing.some((f) => f.basename === subDirectory)) {
await client.createDirectory(path.join(directory, subDirectory));
}
}
}

await createIfNotExist(destination, datePaths.keys());
for (const [year, months] of datePaths) {
await createIfNotExist(path.join(destination, year), months);
}

// Move each file
const calls = moveDirectives.map(([targetPath, fileInfo]) => async () => {
try {
await client.moveFile(
fileInfo.originalFilename,
path.join(targetPath, fileInfo.basename),
// @ts-ignore - https://github.com/perry-mitchell/webdav-client/issues/329
{ headers: { Overwrite: overwrite ? 'T' : 'F' } },
);
return fileInfo.fileid;
} catch (error) {
console.error('Failed to move', fileInfo, error);
if (error.response?.status === 412) {
// Precondition failed (only if `overwrite` flag set to false)
showError(
t('memories', 'Could not move {fileName}, target exists.', {
fileName: fileInfo.filename,
}),
);
return 0;
}

showError(
t('memories', 'Failed to move {fileName}.', {
fileName: fileInfo.filename,
}),
);
return 0;
}
});

yield* runInParallel(calls, 10);
}

/**
* Fill the imageInfo attributes of the given photos
*
Expand Down
31 changes: 25 additions & 6 deletions src/services/utils/dialog.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { FilePickerType, getFilePickerBuilder } from '@nextcloud/dialogs';
import { showError } from '@nextcloud/dialogs';
import type { IFilePickerButton } from '@nextcloud/dialogs';
import { getFilePickerBuilder, showError } from '@nextcloud/dialogs';
import type { Node } from '@nextcloud/files';

import { translate as t, translatePlural as n } from '@services/l10n';
import { translatePlural as n, translate as t } from '@services/l10n';
import { bus } from './event-bus';
import { fragment } from './fragment';

// https://github.com/nextcloud/server/blob/4b7ec0a0c18d4e2007565dc28ee214814940161e/core/src/OC/dialogs.js
const oc_dialogs = (<any>OC).dialogs;

type IFilePickerButtonFactory = (
selectedNodes: Node[],
currentPath: string,
currentView: string,
) => IFilePickerButton[];

type ConfirmOptions = {
/** Title of dialog */
title?: string;
Expand Down Expand Up @@ -149,23 +156,35 @@ export async function prompt(opts: PromptOptions): Promise<string | null> {
);
}

function chooseButtonFactory(nodes: Node[]): IFilePickerButton[] {
const fileName = nodes?.[0]?.attributes?.displayName || nodes?.[0]?.basename;
let label = nodes.length === 1 ? t('memories', 'Choose {file}', { file: fileName }) : t('memories', 'Choose');
return [
{
callback: () => {},
type: 'primary',
label: label,
},
];
}

/**
* Choose a folder using the NC file picker
*
* @param title Title of the file picker
* @param initial Initial path
* @param type Type of the file picker
* @param buttonFactory Buttons factory
*
* @returns The path of the chosen folder
*/
export async function chooseNcFolder(
title: string,
initial: string = '/',
type: FilePickerType = FilePickerType.Choose,
buttonFactory: IFilePickerButtonFactory = chooseButtonFactory,
): Promise<string> {
const picker = getFilePickerBuilder(title)
.setMultiSelect(false)
.setType(type)
.setButtonFactory(buttonFactory)
.addMimeTypeFilter('httpd/unix-directory')
.allowDirectories()
.startAt(initial)
Expand Down