From df19a55b6e3b5154d857c0b406b2eefae96d1570 Mon Sep 17 00:00:00 2001 From: Remi van der Laan Date: Sun, 17 Apr 2022 12:04:40 +0200 Subject: [PATCH 1/2] WIP: Drag n drop files in the locations panel to move them, with a skip/overwrite dialog if already exists Still an issue present that overwritten files don't get detected as changed --- src/Messaging.ts | 34 ++++- src/entities/File.ts | 1 + .../LocationsPanel/LocationRecoveryDialog.tsx | 2 +- .../containers/Outliner/LocationsPanel/dnd.ts | 23 +++- .../Outliner/LocationsPanel/index.tsx | 2 +- .../Outliner/LocationsPanel/useFileDnD.ts | 122 +++++++++++++++++- src/frontend/containers/Settings/index.tsx | 6 +- src/frontend/stores/LocationStore.ts | 6 +- src/main.ts | 4 + 9 files changed, 180 insertions(+), 20 deletions(-) diff --git a/src/Messaging.ts b/src/Messaging.ts index d1947f905..a532938f3 100644 --- a/src/Messaging.ts +++ b/src/Messaging.ts @@ -1,10 +1,8 @@ -import { ipcRenderer, ipcMain, WebContents } from 'electron'; +import { BrowserWindow, ipcMain, ipcRenderer, WebContents } from 'electron'; import path from 'path'; - +import { IImportItem } from './clipper/server'; import { ID } from './entities/ID'; import { ITag } from './entities/Tag'; - -import { IImportItem } from './clipper/server'; import { ViewMethod } from './frontend/stores/UiStore'; /** @@ -32,6 +30,8 @@ const CLEAR_DATABASE = 'CLEAR_DATABASE'; const TOGGLE_DEV_TOOLS = 'TOGGLE_DEV_TOOLS'; const RELOAD = 'RELOAD'; const OPEN_DIALOG = 'OPEN_DIALOG'; +const MESSAGE_BOX = 'MESSAGE_BOX'; +const MESSAGE_BOX_SYNC = 'MESSAGE_BOX_SYNC'; const GET_PATH = 'GET_PATH'; const TRASH_FILE = 'TRASH_FILE'; const SET_FULL_SCREEN = 'SET_FULL_SCREEN'; @@ -133,10 +133,17 @@ export class RendererMessenger { static reload = (frontEndOnly?: boolean) => ipcRenderer.send(RELOAD, frontEndOnly); - static openDialog = ( + static showOpenDialog = ( options: Electron.OpenDialogOptions, ): Promise => ipcRenderer.invoke(OPEN_DIALOG, options); + static showMessageBox = ( + options: Electron.MessageBoxOptions, + ): Promise => ipcRenderer.invoke(MESSAGE_BOX, options); + + static showMessageBoxSync = (options: Electron.MessageBoxSyncOptions): Promise => + ipcRenderer.invoke(MESSAGE_BOX_SYNC, options); + static getPath = (name: SYSTEM_PATHS): Promise => ipcRenderer.invoke(GET_PATH, name); static trashFile = (absolutePath: string): Promise => @@ -246,7 +253,22 @@ export class MainMessenger { ipcMain.on(RELOAD, (_, frontEndOnly) => cb(frontEndOnly)); static onOpenDialog = (dialog: Electron.Dialog) => - ipcMain.handle(OPEN_DIALOG, (_, options) => dialog.showOpenDialog(options)); + ipcMain.handle(OPEN_DIALOG, (e, options) => { + const bw = BrowserWindow.fromWebContents(e.sender); + return bw ? dialog.showOpenDialog(bw, options) : dialog.showOpenDialog(options); + }); + + static onMessageBox = (dialog: Electron.Dialog) => + ipcMain.handle(MESSAGE_BOX, (e, options) => { + const bw = BrowserWindow.fromWebContents(e.sender); + return bw ? dialog.showMessageBox(bw, options) : dialog.showMessageBox(options); + }); + + static onMessageBoxSync = (dialog: Electron.Dialog) => + ipcMain.handle(MESSAGE_BOX_SYNC, (e, options) => { + const bw = BrowserWindow.fromWebContents(e.sender); + return bw ? dialog.showMessageBoxSync(bw, options) : dialog.showMessageBoxSync(options); + }); static onGetPath = (cb: (name: SYSTEM_PATHS) => string) => ipcMain.handle(GET_PATH, (_, name) => cb(name)); diff --git a/src/entities/File.ts b/src/entities/File.ts index 746d3f1d8..c15d704d6 100644 --- a/src/entities/File.ts +++ b/src/entities/File.ts @@ -235,6 +235,7 @@ export async function getMetaData(stats: FileStats, exifIO: ExifIO): Promise { }; const handleLocate = async () => { - const { filePaths: dirs } = await RendererMessenger.openDialog({ + const { filePaths: dirs } = await RendererMessenger.showOpenDialog({ properties: ['openDirectory'], defaultPath: location.path, // TODO: Maybe pick the parent dir of the original location by default? }); diff --git a/src/frontend/containers/Outliner/LocationsPanel/dnd.ts b/src/frontend/containers/Outliner/LocationsPanel/dnd.ts index 3ab1b9fac..f8ea3a2a9 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/dnd.ts +++ b/src/frontend/containers/Outliner/LocationsPanel/dnd.ts @@ -1,17 +1,32 @@ import fse from 'fs-extra'; import path from 'path'; -import { IMG_EXTENSIONS } from 'src/entities/File'; +import { ClientFile, IMG_EXTENSIONS } from 'src/entities/File'; import { ALLOWED_DROP_TYPES } from 'src/frontend/contexts/DropContext'; import { retainArray } from 'common/core'; import { timeoutPromise } from 'common/timeout'; import { IStoreFileMessage, RendererMessenger } from 'src/Messaging'; import { DnDAttribute } from 'src/frontend/contexts/TagDnDContext'; +import FileStore from 'src/frontend/stores/FileStore'; const ALLOWED_FILE_DROP_TYPES = IMG_EXTENSIONS.map((ext) => `image/${ext}`); export const isAcceptableType = (e: React.DragEvent) => e.dataTransfer.types.some((type) => ALLOWED_DROP_TYPES.includes(type)); +/** Returns the IDs of the files that match those in Allusion given dropData. Returns false if one or files has no matches */ +export const findDroppedFileMatches = ( + dropData: (File | string)[], + fs: FileStore, +): ClientFile[] | false => { + const matches = dropData.map( + (file) => + typeof file !== 'string' && + file.path && + fs.fileList.find((f) => f.absolutePath === file.path), + ); + return matches.every((m): m is ClientFile => m instanceof ClientFile) ? matches : false; +}; + /** * Executed callback function while dragging over a target. * @@ -42,9 +57,7 @@ export function handleDragLeave(event: React.DragEvent) { } } -export async function storeDroppedImage(e: React.DragEvent, directory: string) { - const dropData = await getDropData(e); - +export async function storeDroppedImage(dropData: (string | File)[], directory: string) { for (const dataItem of dropData) { let fileData: IStoreFileMessage | undefined; @@ -122,7 +135,7 @@ function getFilenameFromUrl(url: string, fallback: string) { return index !== -1 ? pathname.substring(index + 1) : pathname; } -async function getDropData(e: React.DragEvent): Promise> { +export async function getDropData(e: React.DragEvent): Promise> { // Using a set to filter out duplicates. For some reason, dropping URLs duplicates them 3 times (for me) const dropItems = new Set(); diff --git a/src/frontend/containers/Outliner/LocationsPanel/index.tsx b/src/frontend/containers/Outliner/LocationsPanel/index.tsx index af2ed0e4b..7e06e933f 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/index.tsx +++ b/src/frontend/containers/Outliner/LocationsPanel/index.tsx @@ -511,7 +511,7 @@ const LocationsPanel = observer((props: Partial) => { const handleChooseWatchedDir = useCallback(async () => { let path: string; try { - const { filePaths } = await RendererMessenger.openDialog({ + const { filePaths } = await RendererMessenger.showOpenDialog({ properties: ['openDirectory'], }); // multi-selection is disabled which means there can be at most 1 folder diff --git a/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts b/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts index dbf56dcfe..aa3a4046b 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts +++ b/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts @@ -1,17 +1,117 @@ -import { useState, useCallback } from 'react'; +import { humanFileSize } from 'common/fmt'; +import fse from 'fs-extra'; +import path from 'path'; +import { useCallback, useState } from 'react'; +import { ClientFile, IFile } from 'src/entities/File'; +import { ClientLocation } from 'src/entities/Location'; import { AppToaster } from 'src/frontend/components/Toaster'; +import { useStore } from 'src/frontend/contexts/StoreContext'; import { DnDAttribute } from 'src/frontend/contexts/TagDnDContext'; +import FileStore from 'src/frontend/stores/FileStore'; +import { RendererMessenger } from 'src/Messaging'; import { IExpansionState } from '../../types'; -import { onDragOver, isAcceptableType, storeDroppedImage, handleDragLeave } from './dnd'; +import { + findDroppedFileMatches, + getDropData, + handleDragLeave, + isAcceptableType, + onDragOver, + storeDroppedImage, +} from './dnd'; export const HOVER_TIME_TO_EXPAND = 600; +/** + * Either moves or downloads a dropped file into the target directory + * @param fileStore + * @param matches + * @param dir + */ +const handleMove = async ( + fileStore: FileStore, + matches: ClientFile[], + loc: ClientLocation, + dir: string, +) => { + let isReplaceAllActive = false; + + // If it's a file being dropped that's already in Allusion, move it + for (const file of matches) { + const src = path.normalize(file.absolutePath); + const dst = path.normalize(path.join(dir, file.name)); + if (src !== dst) { + const alreadyExists = await fse.pathExists(dst); + + if (alreadyExists && !isReplaceAllActive) { + const srcStats = await fse.stat(src); + const dstStats = await fse.stat(dst); + + // if the file is already in the target location, prompt the user to confirm the move + // TODO: could also add option to rename with a number suffix? + const res = await RendererMessenger.showMessageBox({ + type: 'question', + title: 'Replace or skip file?', + message: `"${file.name}" already exists in this folder. Replace or skip?`, + detail: `From "${path.dirname(file.absolutePath)}" (${humanFileSize( + srcStats.size, + )}) \nTo "${dir}" (${humanFileSize(dstStats.size)})`, + buttons: ['&Replace', '&Skip', '&Cancel'], + normalizeAccessKeys: true, + defaultId: 0, + cancelId: 2, + checkboxLabel: matches.length > 1 ? 'Apply to all' : undefined, + }); + + if (res.response === 0 && res.checkboxChecked) { + isReplaceAllActive = true; // replace all + } else if ((res.response === 1 && res.checkboxChecked) || res.response === 2) { + break; // skip all + } else if (res.response === 1) { + continue; // skip this file + } + } + + // When replacing an existing file, no change is detected: only the original file is removed + if (alreadyExists) { + // const newData: IFile = { + // ...file.serialize(), + // absolutePath: dst, + // relativePath: dst.replace(loc.path, ''), + // }; + + // console.log('Moving file on disk from', src, 'to', dst); + // // Move the file on disk. After a while the file watcher will detect it and mark this file as unlinked + // await fse.move(src, dst, { overwrite: true }); + + // console.log('Delete file in db', file.serialize()); + // // Remove the original file from the database, so it doesn't linger around as an unlinked file + // await fileStore.deleteFiles([file]); + + // console.log('Update file in db', newData); + // // Update the path of the moved file in the database + // fileStore.replaceMovedFile(file, newData); + + await fse.remove(dst); + const dstFile = fileStore.fileList.find((f) => f.absolutePath === dst); + if (dstFile) { + await fileStore.deleteFiles([dstFile]); + } + await new Promise((res) => setTimeout(res, 1000)); + } else { + // Else, the file watcher process will detect the changes and update the File entity accordingly + } + await fse.move(src, dst, { overwrite: true }); + } + } +}; + export const useFileDropHandling = ( expansionId: string, fullPath: string, expansion: IExpansionState, setExpansion: (s: IExpansionState) => void, ) => { + const { fileStore, locationStore } = useStore(); // Don't expand immediately, only after hovering over it for a second or so const [expandTimeoutId, setExpandTimeoutId] = useState(); const expandDelayed = useCallback(() => { @@ -47,7 +147,23 @@ export const useFileDropHandling = ( if (isAcceptableType(event)) { event.dataTransfer.dropEffect = 'none'; try { - await storeDroppedImage(event, fullPath); + const dropData = await getDropData(event); + + // if this is a local file (it has matches to the files in the DB), + // it should be moved instead of copied + const matches = findDroppedFileMatches(dropData, fileStore); + if (matches) { + const loc = locationStore.locationList.find((l) => fullPath.startsWith(l.path)); + if (!loc) { + throw new Error('Location not found for path ' + fullPath); + } + await handleMove(fileStore, matches, loc, fullPath); + setTimeout(() => fileStore.refetch(), 500); + } else { + // Otherwise it's an external file (e.g. from the web or a folder not set up as a Location in Allusion) + // -> download it and "copy" it to the target folder + await storeDroppedImage(dropData, fullPath); + } } catch (e) { console.error(e); AppToaster.show({ diff --git a/src/frontend/containers/Settings/index.tsx b/src/frontend/containers/Settings/index.tsx index 67b76fa55..27b40f927 100644 --- a/src/frontend/containers/Settings/index.tsx +++ b/src/frontend/containers/Settings/index.tsx @@ -168,7 +168,7 @@ const ImportExport = observer(() => { }, []); const handleChooseImportDir = async () => { - const { filePaths } = await RendererMessenger.openDialog({ + const { filePaths } = await RendererMessenger.showOpenDialog({ properties: ['openFile'], filters: [{ extensions: ['json'], name: 'JSON' }], defaultPath: backupDir, @@ -449,7 +449,7 @@ const BackgroundProcesses = observer(() => { const importDirectory = uiStore.importDirectory; const browseImportDirectory = async () => { - const { filePaths: dirs } = await RendererMessenger.openDialog({ + const { filePaths: dirs } = await RendererMessenger.showOpenDialog({ properties: ['openDirectory'], defaultPath: importDirectory, }); @@ -617,7 +617,7 @@ const Advanced = observer(() => { }; const browseThumbnailDirectory = async () => { - const { filePaths: dirs } = await RendererMessenger.openDialog({ + const { filePaths: dirs } = await RendererMessenger.showOpenDialog({ properties: ['openDirectory'], defaultPath: thumbnailDirectory, }); diff --git a/src/frontend/stores/LocationStore.ts b/src/frontend/stores/LocationStore.ts index f6c73599d..01f763ba5 100644 --- a/src/frontend/stores/LocationStore.ts +++ b/src/frontend/stores/LocationStore.ts @@ -551,7 +551,11 @@ export type FileStats = { ino: string; }; -async function pathToIFile(stats: FileStats, loc: ClientLocation, exifIO: ExifIO): Promise { +export async function pathToIFile( + stats: FileStats, + loc: ClientLocation, + exifIO: ExifIO, +): Promise { const now = new Date(); return { absolutePath: stats.absolutePath, diff --git a/src/main.ts b/src/main.ts index f4d3e8858..15bb44014 100644 --- a/src/main.ts +++ b/src/main.ts @@ -683,6 +683,10 @@ MainMessenger.onReload((frontEndOnly) => MainMessenger.onOpenDialog(dialog); +MainMessenger.onMessageBox(dialog); + +MainMessenger.onMessageBoxSync(dialog); + MainMessenger.onGetPath((path) => app.getPath(path)); MainMessenger.onTrashFile((absolutePath) => shell.trashItem(absolutePath)); From 02e9b9ea0a48b0268ca1b81979ba17e42f424e16 Mon Sep 17 00:00:00 2001 From: Remi van der Laan Date: Sat, 30 Apr 2022 15:10:22 +0200 Subject: [PATCH 2/2] Got the replace function working properly, had to set a timeout for a UI quirk workaround --- .../LocationsPanel/LocationCreationDialog.tsx | 69 +++++++++---------- .../containers/Outliner/LocationsPanel/dnd.ts | 24 +++---- .../Outliner/LocationsPanel/useFileDnD.ts | 36 ++++------ src/frontend/stores/FileStore.ts | 15 ++-- 4 files changed, 69 insertions(+), 75 deletions(-) diff --git a/src/frontend/containers/Outliner/LocationsPanel/LocationCreationDialog.tsx b/src/frontend/containers/Outliner/LocationsPanel/LocationCreationDialog.tsx index 491cc0cc0..1bee64ef4 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/LocationCreationDialog.tsx +++ b/src/frontend/containers/Outliner/LocationsPanel/LocationCreationDialog.tsx @@ -1,4 +1,5 @@ import { when } from 'mobx'; +import { observer } from 'mobx-react-lite'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ClientLocation, ClientSubLocation } from 'src/entities/Location'; import { useStore } from 'src/frontend/contexts/StoreContext'; @@ -47,50 +48,46 @@ const LocationLabel = (nodeData: any, treeData: any) => ( ); -const SubLocation = ({ - nodeData, - treeData, -}: { - nodeData: ClientSubLocation; - treeData: ITreeData; -}) => { - const { expansion, setExpansion } = treeData; - const subLocation = nodeData; +const SubLocation = observer( + ({ nodeData, treeData }: { nodeData: ClientSubLocation; treeData: ITreeData }) => { + const { expansion, setExpansion } = treeData; + const subLocation = nodeData; - const toggleExclusion = () => { - subLocation.toggleExcluded(); - // Need to update expansion to force a rerender of the tree - setExpansion({ ...expansion, [subLocation.path]: false }); - }; + const toggleExclusion = () => { + subLocation.toggleExcluded(); + // Need to update expansion to force a rerender of the tree + setExpansion({ ...expansion, [subLocation.path]: false }); + }; - return ( -
- - - {subLocation.name} - -
- ); -}; + return ( +
+ + + {subLocation.name} + +
+ ); + }, +); -const Location = ({ nodeData }: { nodeData: ClientLocation; treeData: ITreeData }) => { +const Location = observer(({ nodeData }: { nodeData: ClientLocation; treeData: ITreeData }) => { return (
{nodeData.name}
); -}; +}); // eslint-disable-next-line @typescript-eslint/no-empty-function const emptyFunction = () => {}; diff --git a/src/frontend/containers/Outliner/LocationsPanel/dnd.ts b/src/frontend/containers/Outliner/LocationsPanel/dnd.ts index f8ea3a2a9..78e76c2dd 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/dnd.ts +++ b/src/frontend/containers/Outliner/LocationsPanel/dnd.ts @@ -7,6 +7,7 @@ import { timeoutPromise } from 'common/timeout'; import { IStoreFileMessage, RendererMessenger } from 'src/Messaging'; import { DnDAttribute } from 'src/frontend/contexts/TagDnDContext'; import FileStore from 'src/frontend/stores/FileStore'; +import { action } from 'mobx'; const ALLOWED_FILE_DROP_TYPES = IMG_EXTENSIONS.map((ext) => `image/${ext}`); @@ -14,18 +15,17 @@ export const isAcceptableType = (e: React.DragEvent) => e.dataTransfer.types.some((type) => ALLOWED_DROP_TYPES.includes(type)); /** Returns the IDs of the files that match those in Allusion given dropData. Returns false if one or files has no matches */ -export const findDroppedFileMatches = ( - dropData: (File | string)[], - fs: FileStore, -): ClientFile[] | false => { - const matches = dropData.map( - (file) => - typeof file !== 'string' && - file.path && - fs.fileList.find((f) => f.absolutePath === file.path), - ); - return matches.every((m): m is ClientFile => m instanceof ClientFile) ? matches : false; -}; +export const findDroppedFileMatches = action( + (dropData: (File | string)[], fs: FileStore): ClientFile[] | false => { + const matches = dropData.map( + (file) => + typeof file !== 'string' && + file.path && + fs.fileList.find((f) => f.absolutePath === file.path), + ); + return matches.every((m): m is ClientFile => m instanceof ClientFile) ? matches : false; + }, +); /** * Executed callback function while dragging over a target. diff --git a/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts b/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts index aa3a4046b..b9406e521 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts +++ b/src/frontend/containers/Outliner/LocationsPanel/useFileDnD.ts @@ -2,7 +2,7 @@ import { humanFileSize } from 'common/fmt'; import fse from 'fs-extra'; import path from 'path'; import { useCallback, useState } from 'react'; -import { ClientFile, IFile } from 'src/entities/File'; +import { ClientFile } from 'src/entities/File'; import { ClientLocation } from 'src/entities/Location'; import { AppToaster } from 'src/frontend/components/Toaster'; import { useStore } from 'src/frontend/contexts/StoreContext'; @@ -54,7 +54,9 @@ const handleMove = async ( message: `"${file.name}" already exists in this folder. Replace or skip?`, detail: `From "${path.dirname(file.absolutePath)}" (${humanFileSize( srcStats.size, - )}) \nTo "${dir}" (${humanFileSize(dstStats.size)})`, + )}) \nTo "${dir}" (${humanFileSize( + dstStats.size, + )})\nNote: The tags on the replaced image will be lost.`, buttons: ['&Replace', '&Skip', '&Cancel'], normalizeAccessKeys: true, defaultId: 0, @@ -71,31 +73,23 @@ const handleMove = async ( } } - // When replacing an existing file, no change is detected: only the original file is removed + // When replacing an existing file, no change is detected when moving the file + // The target file needs to be removed from disk and the DB first if (alreadyExists) { - // const newData: IFile = { - // ...file.serialize(), - // absolutePath: dst, - // relativePath: dst.replace(loc.path, ''), - // }; - - // console.log('Moving file on disk from', src, 'to', dst); - // // Move the file on disk. After a while the file watcher will detect it and mark this file as unlinked - // await fse.move(src, dst, { overwrite: true }); - - // console.log('Delete file in db', file.serialize()); - // // Remove the original file from the database, so it doesn't linger around as an unlinked file - // await fileStore.deleteFiles([file]); - - // console.log('Update file in db', newData); - // // Update the path of the moved file in the database - // fileStore.replaceMovedFile(file, newData); - + // - Remove the target file from disk await fse.remove(dst); + + // - Remove the target file from the store + // TODO: This removes the target file and its tags. Could merge them, but that's a bit more work const dstFile = fileStore.fileList.find((f) => f.absolutePath === dst); if (dstFile) { await fileStore.deleteFiles([dstFile]); } + + // - Move the source file to the target path + // Now the DB and internal state have been prepared to be able to detect the moving of the file + // Will be done with the move operation below + // We need to wait a second for the UI to update, otherwise it will cause render issues for some reason (old and new are rendered simultaneously) await new Promise((res) => setTimeout(res, 1000)); } else { // Else, the file watcher process will detect the changes and update the File entity accordingly diff --git a/src/frontend/stores/FileStore.ts b/src/frontend/stores/FileStore.ts index 880895426..bde565381 100644 --- a/src/frontend/stores/FileStore.ts +++ b/src/frontend/stores/FileStore.ts @@ -276,6 +276,7 @@ class FileStore { } } + /** Removes a file from the internal state of this store and the DB. Does not remove from disk. */ @action async deleteFiles(files: ClientFile[]): Promise { if (files.length === 0) { return; @@ -288,10 +289,12 @@ class FileStore { // Remove files from stores for (const file of files) { + file.dispose(); this.rootStore.uiStore.deselectFile(file); this.removeThumbnail(file.absolutePath); } - this.refetch(); + this.fileListLastModified = new Date(); + return this.refetch(); } catch (err) { console.error('Could not remove files', err); } @@ -312,15 +315,15 @@ class FileStore { } } - @action.bound refetch() { + @action.bound async refetch() { if (this.showsAllContent) { - this.fetchAllFiles(); + return this.fetchAllFiles(); } else if (this.showsUntaggedContent) { - this.fetchUntaggedFiles(); + return this.fetchUntaggedFiles(); } else if (this.showsQueryContent) { - this.fetchFilesByQuery(); + return this.fetchFilesByQuery(); } else if (this.showsMissingContent) { - this.fetchMissingFiles(); + return this.fetchMissingFiles(); } }