Merge remote-tracking branch 'origin/main' into mobile_save_a_copy

This commit is contained in:
Neeraj Gupta 2024-04-19 14:57:19 +05:30
commit 564ca77a8b
125 changed files with 2733 additions and 2804 deletions

View file

@ -24,9 +24,10 @@ import { attachFSWatchIPCHandlers, attachIPCHandlers } from "./main/ipc";
import log, { initLogging } from "./main/log";
import { createApplicationMenu, createTrayContextMenu } from "./main/menu";
import { setupAutoUpdater } from "./main/services/app-update";
import autoLauncher from "./main/services/autoLauncher";
import { initWatcher } from "./main/services/chokidar";
import autoLauncher from "./main/services/auto-launcher";
import { createWatcher } from "./main/services/watch";
import { userPreferences } from "./main/stores/user-preferences";
import { migrateLegacyWatchStoreIfNeeded } from "./main/stores/watch";
import { registerStreamProtocol } from "./main/stream";
import { isDev } from "./main/util";
@ -196,8 +197,6 @@ const createMainWindow = async () => {
window.maximize();
}
window.loadURL(rendererURL);
// Open the DevTools automatically when running in dev mode
if (isDev) window.webContents.openDevTools();
@ -296,6 +295,21 @@ const deleteLegacyDiskCacheDirIfExists = async () => {
}
};
/**
* Older versions of our app used to keep a keys.json. It is not needed anymore,
* remove it if it exists.
*
* This code was added March 2024, and can be removed after some time once most
* people have upgraded to newer versions.
*/
const deleteLegacyKeysStoreIfExists = async () => {
const keysStore = path.join(app.getPath("userData"), "keys.json");
if (existsSync(keysStore)) {
log.info(`Removing legacy keys store at ${keysStore}`);
await fs.rm(keysStore);
}
};
const main = () => {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
@ -311,6 +325,7 @@ const main = () => {
setupRendererServer();
registerPrivilegedSchemes();
increaseDiskCache();
migrateLegacyWatchStoreIfNeeded();
app.on("second-instance", () => {
// Someone tried to run a second instance, we should focus our window.
@ -325,19 +340,26 @@ const main = () => {
//
// Note that some Electron APIs can only be used after this event occurs.
app.on("ready", async () => {
// Create window and prepare for renderer
mainWindow = await createMainWindow();
Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
setupTrayItem(mainWindow);
attachIPCHandlers();
attachFSWatchIPCHandlers(initWatcher(mainWindow));
attachFSWatchIPCHandlers(createWatcher(mainWindow));
registerStreamProtocol();
if (!isDev) setupAutoUpdater(mainWindow);
handleDownloads(mainWindow);
handleExternalLinks(mainWindow);
addAllowOriginHeader(mainWindow);
// Start loading the renderer
mainWindow.loadURL(rendererURL);
// Continue on with the rest of the startup sequence
Menu.setApplicationMenu(await createApplicationMenu(mainWindow));
setupTrayItem(mainWindow);
if (!isDev) setupAutoUpdater(mainWindow);
try {
deleteLegacyDiskCacheDirIfExists();
deleteLegacyKeysStoreIfExists();
} catch (e) {
// Log but otherwise ignore errors during non-critical startup
// actions.

View file

@ -22,10 +22,8 @@ export const fsReadTextFile = async (filePath: string) =>
export const fsWriteFile = (path: string, contents: string) =>
fs.writeFile(path, contents);
/* TODO: Audit below this */
export const isFolder = async (dirPath: string) => {
export const fsIsDir = async (dirPath: string) => {
if (!existsSync(dirPath)) return false;
const stats = await fs.stat(dirPath);
return stats.isDirectory();
const stat = await fs.stat(dirPath);
return stat.isDirectory();
};

View file

@ -10,7 +10,12 @@
import type { FSWatcher } from "chokidar";
import { ipcMain } from "electron/main";
import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc";
import type {
CollectionMapping,
ElectronFile,
FolderWatch,
PendingUploads,
} from "../types/ipc";
import {
selectDirectory,
showUploadDirsDialog,
@ -19,13 +24,13 @@ import {
} from "./dialogs";
import {
fsExists,
fsIsDir,
fsMkdirIfNeeded,
fsReadTextFile,
fsRename,
fsRm,
fsRmdir,
fsWriteFile,
isFolder,
} from "./fs";
import { logToDisk } from "./log";
import {
@ -49,16 +54,17 @@ import {
} from "./services/store";
import {
getElectronFilesFromGoogleZip,
getPendingUploads,
setToUploadCollection,
setToUploadFiles,
pendingUploads,
setPendingUploadCollection,
setPendingUploadFiles,
} from "./services/upload";
import {
addWatchMapping,
getWatchMappings,
removeWatchMapping,
updateWatchMappingIgnoredFiles,
updateWatchMappingSyncedFiles,
watchAdd,
watchFindFiles,
watchGet,
watchRemove,
watchUpdateIgnoredFiles,
watchUpdateSyncedFiles,
} from "./services/watch";
import { openDirectory, openLogDirectory } from "./util";
@ -132,6 +138,8 @@ export const attachIPCHandlers = () => {
fsWriteFile(path, contents),
);
ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath));
// - Conversion
ipcMain.handle("convertToJPEG", (_, fileData, filename) =>
@ -183,28 +191,26 @@ export const attachIPCHandlers = () => {
ipcMain.handle("showUploadZipDialog", () => showUploadZipDialog());
// - FS Legacy
ipcMain.handle("isFolder", (_, dirPath: string) => isFolder(dirPath));
// - Upload
ipcMain.handle("getPendingUploads", () => getPendingUploads());
ipcMain.handle("pendingUploads", () => pendingUploads());
ipcMain.handle("setPendingUploadCollection", (_, collectionName: string) =>
setPendingUploadCollection(collectionName),
);
ipcMain.handle(
"setToUploadFiles",
(_, type: FILE_PATH_TYPE, filePaths: string[]) =>
setToUploadFiles(type, filePaths),
"setPendingUploadFiles",
(_, type: PendingUploads["type"], filePaths: string[]) =>
setPendingUploadFiles(type, filePaths),
);
// -
ipcMain.handle("getElectronFilesFromGoogleZip", (_, filePath: string) =>
getElectronFilesFromGoogleZip(filePath),
);
ipcMain.handle("setToUploadCollection", (_, collectionName: string) =>
setToUploadCollection(collectionName),
);
ipcMain.handle("getDirFiles", (_, dirPath: string) => getDirFiles(dirPath));
};
@ -213,42 +219,36 @@ export const attachIPCHandlers = () => {
* watch folder functionality.
*
* It gets passed a {@link FSWatcher} instance which it can then forward to the
* actual handlers.
* actual handlers if they need access to it to do their thing.
*/
export const attachFSWatchIPCHandlers = (watcher: FSWatcher) => {
// - Watch
ipcMain.handle(
"addWatchMapping",
(
_,
collectionName: string,
folderPath: string,
uploadStrategy: number,
) =>
addWatchMapping(
watcher,
collectionName,
folderPath,
uploadStrategy,
),
);
ipcMain.handle("removeWatchMapping", (_, folderPath: string) =>
removeWatchMapping(watcher, folderPath),
);
ipcMain.handle("getWatchMappings", () => getWatchMappings());
ipcMain.handle("watchGet", () => watchGet(watcher));
ipcMain.handle(
"updateWatchMappingSyncedFiles",
(_, folderPath: string, files: WatchMapping["syncedFiles"]) =>
updateWatchMappingSyncedFiles(folderPath, files),
"watchAdd",
(_, folderPath: string, collectionMapping: CollectionMapping) =>
watchAdd(watcher, folderPath, collectionMapping),
);
ipcMain.handle("watchRemove", (_, folderPath: string) =>
watchRemove(watcher, folderPath),
);
ipcMain.handle(
"updateWatchMappingIgnoredFiles",
(_, folderPath: string, files: WatchMapping["ignoredFiles"]) =>
updateWatchMappingIgnoredFiles(folderPath, files),
"watchUpdateSyncedFiles",
(_, syncedFiles: FolderWatch["syncedFiles"], folderPath: string) =>
watchUpdateSyncedFiles(syncedFiles, folderPath),
);
ipcMain.handle(
"watchUpdateIgnoredFiles",
(_, ignoredFiles: FolderWatch["ignoredFiles"], folderPath: string) =>
watchUpdateIgnoredFiles(ignoredFiles, folderPath),
);
ipcMain.handle("watchFindFiles", (_, folderPath: string) =>
watchFindFiles(folderPath),
);
};

View file

@ -7,7 +7,7 @@ import {
} from "electron";
import { allowWindowClose } from "../main";
import { forceCheckForAppUpdates } from "./services/app-update";
import autoLauncher from "./services/autoLauncher";
import autoLauncher from "./services/auto-launcher";
import { userPreferences } from "./stores/user-preferences";
import { openLogDirectory } from "./util";

View file

@ -1,19 +0,0 @@
export function isPlatform(platform: "mac" | "windows" | "linux") {
return getPlatform() === platform;
}
export function getPlatform(): "mac" | "windows" | "linux" {
switch (process.platform) {
case "aix":
case "freebsd":
case "linux":
case "openbsd":
case "android":
return "linux";
case "darwin":
case "sunos":
return "mac";
case "win32":
return "windows";
}
}

View file

@ -1,9 +1,9 @@
import { compareVersions } from "compare-versions";
import { app, BrowserWindow } from "electron";
import { default as electronLog } from "electron-log";
import { autoUpdater } from "electron-updater";
import { app, BrowserWindow } from "electron/main";
import { allowWindowClose } from "../../main";
import { AppUpdateInfo } from "../../types/ipc";
import { AppUpdate } from "../../types/ipc";
import log from "../log";
import { userPreferences } from "../stores/user-preferences";
@ -52,8 +52,8 @@ const checkForUpdatesAndNotify = async (mainWindow: BrowserWindow) => {
return;
}
const showUpdateDialog = (updateInfo: AppUpdateInfo) =>
mainWindow.webContents.send("appUpdateAvailable", updateInfo);
const showUpdateDialog = (update: AppUpdate) =>
mainWindow.webContents.send("appUpdateAvailable", update);
log.debug(() => "Attempting auto update");
autoUpdater.downloadUpdate();

View file

@ -0,0 +1,51 @@
import AutoLaunch from "auto-launch";
import { app } from "electron/main";
class AutoLauncher {
/**
* This property will be set and used on Linux and Windows. On macOS,
* there's a separate API
*/
private autoLaunch?: AutoLaunch;
constructor() {
if (process.platform != "darwin") {
this.autoLaunch = new AutoLaunch({
name: "ente",
isHidden: true,
});
}
}
async isEnabled() {
const autoLaunch = this.autoLaunch;
if (autoLaunch) {
return await autoLaunch.isEnabled();
} else {
return app.getLoginItemSettings().openAtLogin;
}
}
async toggleAutoLaunch() {
const isEnabled = await this.isEnabled();
const autoLaunch = this.autoLaunch;
if (autoLaunch) {
if (isEnabled) await autoLaunch.disable();
else await autoLaunch.enable();
} else {
if (isEnabled) app.setLoginItemSettings({ openAtLogin: false });
else app.setLoginItemSettings({ openAtLogin: true });
}
}
async wasAutoLaunched() {
if (this.autoLaunch) {
return app.commandLine.hasSwitch("hidden");
} else {
// TODO(MR): This apparently doesn't work anymore.
return app.getLoginItemSettings().wasOpenedAtLogin;
}
}
}
export default new AutoLauncher();

View file

@ -1,41 +0,0 @@
import { AutoLauncherClient } from "../../types/main";
import { isPlatform } from "../platform";
import linuxAndWinAutoLauncher from "./autoLauncherClients/linuxAndWinAutoLauncher";
import macAutoLauncher from "./autoLauncherClients/macAutoLauncher";
class AutoLauncher {
private client: AutoLauncherClient;
async init() {
if (isPlatform("linux") || isPlatform("windows")) {
this.client = linuxAndWinAutoLauncher;
} else {
this.client = macAutoLauncher;
}
// migrate old auto launch settings for windows from mac auto launcher to linux and windows auto launcher
if (isPlatform("windows") && (await macAutoLauncher.isEnabled())) {
await macAutoLauncher.toggleAutoLaunch();
await linuxAndWinAutoLauncher.toggleAutoLaunch();
}
}
async isEnabled() {
if (!this.client) {
await this.init();
}
return await this.client.isEnabled();
}
async toggleAutoLaunch() {
if (!this.client) {
await this.init();
}
await this.client.toggleAutoLaunch();
}
async wasAutoLaunched() {
if (!this.client) {
await this.init();
}
return this.client.wasAutoLaunched();
}
}
export default new AutoLauncher();

View file

@ -1,39 +0,0 @@
import AutoLaunch from "auto-launch";
import { app } from "electron";
import { AutoLauncherClient } from "../../../types/main";
const LAUNCHED_AS_HIDDEN_FLAG = "hidden";
class LinuxAndWinAutoLauncher implements AutoLauncherClient {
private instance: AutoLaunch;
constructor() {
const autoLauncher = new AutoLaunch({
name: "ente",
isHidden: true,
});
this.instance = autoLauncher;
}
async isEnabled() {
return await this.instance.isEnabled();
}
async toggleAutoLaunch() {
if (await this.isEnabled()) {
await this.disableAutoLaunch();
} else {
await this.enableAutoLaunch();
}
}
async wasAutoLaunched() {
return app.commandLine.hasSwitch(LAUNCHED_AS_HIDDEN_FLAG);
}
private async disableAutoLaunch() {
await this.instance.disable();
}
private async enableAutoLaunch() {
await this.instance.enable();
}
}
export default new LinuxAndWinAutoLauncher();

View file

@ -1,28 +0,0 @@
import { app } from "electron";
import { AutoLauncherClient } from "../../../types/main";
class MacAutoLauncher implements AutoLauncherClient {
async isEnabled() {
return app.getLoginItemSettings().openAtLogin;
}
async toggleAutoLaunch() {
if (await this.isEnabled()) {
this.disableAutoLaunch();
} else {
this.enableAutoLaunch();
}
}
async wasAutoLaunched() {
return app.getLoginItemSettings().wasOpenedAtLogin;
}
private disableAutoLaunch() {
app.setLoginItemSettings({ openAtLogin: false });
}
private enableAutoLaunch() {
app.setLoginItemSettings({ openAtLogin: true });
}
}
export default new MacAutoLauncher();

View file

@ -1,45 +0,0 @@
import chokidar from "chokidar";
import { BrowserWindow } from "electron";
import path from "path";
import log from "../log";
import { getElectronFile } from "./fs";
import { getWatchMappings } from "./watch";
/**
* Convert a file system {@link filePath} that uses the local system specific
* path separators into a path that uses POSIX file separators.
*/
const normalizeToPOSIX = (filePath: string) =>
filePath.split(path.sep).join(path.posix.sep);
export function initWatcher(mainWindow: BrowserWindow) {
const mappings = getWatchMappings();
const folderPaths = mappings.map((mapping) => {
return mapping.folderPath;
});
const watcher = chokidar.watch(folderPaths, {
awaitWriteFinish: true,
});
watcher
.on("add", async (path) => {
mainWindow.webContents.send(
"watch-add",
await getElectronFile(normalizeToPOSIX(path)),
);
})
.on("unlink", (path) => {
mainWindow.webContents.send("watch-unlink", normalizeToPOSIX(path));
})
.on("unlinkDir", (path) => {
mainWindow.webContents.send(
"watch-unlink-dir",
normalizeToPOSIX(path),
);
})
.on("error", (error) => {
log.error("Error while watching files", error);
});
return watcher;
}

View file

@ -91,19 +91,6 @@ export async function getElectronFile(filePath: string): Promise<ElectronFile> {
};
}
export const getValidPaths = (paths: string[]) => {
if (!paths) {
return [] as string[];
}
return paths.filter(async (path) => {
try {
await fs.stat(path).then((stat) => stat.isFile());
} catch (e) {
return false;
}
});
};
export const getZipFileStream = async (
zip: StreamZip.StreamZipAsync,
filePath: string,

View file

@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import path from "path";
import { CustomErrors, ElectronFile } from "../../types/ipc";
import log from "../log";
import { isPlatform } from "../platform";
import { writeStream } from "../stream";
import { generateTempFilePath } from "../temp";
import { execAsync, isDev } from "../util";
@ -67,19 +66,15 @@ const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
OUTPUT_PATH_PLACEHOLDER,
];
function getImageMagickStaticPath() {
return isDev
? "resources/image-magick"
: path.join(process.resourcesPath, "image-magick");
}
const imageMagickStaticPath = () =>
path.join(isDev ? "build" : process.resourcesPath, "image-magick");
export async function convertToJPEG(
fileData: Uint8Array,
filename: string,
): Promise<Uint8Array> {
if (isPlatform("windows")) {
if (process.platform == "win32")
throw Error(CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED);
}
const convertedFileData = await convertToJPEG_(fileData, filename);
return convertedFileData;
}
@ -126,7 +121,7 @@ function constructConvertCommand(
tempOutputFilePath: string,
) {
let convertCmd: string[];
if (isPlatform("mac")) {
if (process.platform == "darwin") {
convertCmd = SIPS_HEIC_CONVERT_COMMAND_TEMPLATE.map((cmdPart) => {
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return tempInputFilePath;
@ -136,11 +131,11 @@ function constructConvertCommand(
}
return cmdPart;
});
} else if (isPlatform("linux")) {
} else if (process.platform == "linux") {
convertCmd = IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE.map(
(cmdPart) => {
if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) {
return getImageMagickStaticPath();
return imageMagickStaticPath();
}
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return tempInputFilePath;
@ -165,11 +160,10 @@ export async function generateImageThumbnail(
let inputFilePath = null;
let createdTempInputFile = null;
try {
if (isPlatform("windows")) {
if (process.platform == "win32")
throw Error(
CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED,
);
}
if (!existsSync(inputFile.path)) {
const tempFilePath = await generateTempFilePath(inputFile.name);
await writeStream(tempFilePath, await inputFile.stream());
@ -240,7 +234,7 @@ function constructThumbnailGenerationCommand(
quality: number,
) {
let thumbnailGenerationCmd: string[];
if (isPlatform("mac")) {
if (process.platform == "darwin") {
thumbnailGenerationCmd = SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map(
(cmdPart) => {
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
@ -258,11 +252,11 @@ function constructThumbnailGenerationCommand(
return cmdPart;
},
);
} else if (isPlatform("linux")) {
} else if (process.platform == "linux") {
thumbnailGenerationCmd =
IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE.map((cmdPart) => {
if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) {
return getImageMagickStaticPath();
return imageMagickStaticPath();
}
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return inputFilePath;

View file

@ -1,12 +1,15 @@
import { safeStorage } from "electron/main";
import { keysStore } from "../stores/keys.store";
import { safeStorageStore } from "../stores/safeStorage.store";
import { uploadStatusStore } from "../stores/upload.store";
import { watchStore } from "../stores/watch.store";
import { safeStorageStore } from "../stores/safe-storage";
import { uploadStatusStore } from "../stores/upload-status";
import { watchStore } from "../stores/watch";
/**
* Clear all stores except user preferences.
*
* This is useful to reset state when the user logs out.
*/
export const clearStores = () => {
uploadStatusStore.clear();
keysStore.clear();
safeStorageStore.clear();
watchStore.clear();
};

View file

@ -1,19 +1,23 @@
import StreamZip from "node-stream-zip";
import { existsSync } from "original-fs";
import path from "path";
import { ElectronFile, FILE_PATH_TYPE } from "../../types/ipc";
import { FILE_PATH_KEYS } from "../../types/main";
import { uploadStatusStore } from "../stores/upload.store";
import { getElectronFile, getValidPaths, getZipFileStream } from "./fs";
import { ElectronFile, type PendingUploads } from "../../types/ipc";
import {
uploadStatusStore,
type UploadStatusStore,
} from "../stores/upload-status";
import { getElectronFile, getZipFileStream } from "./fs";
export const getPendingUploads = async () => {
const filePaths = getSavedFilePaths(FILE_PATH_TYPE.FILES);
const zipPaths = getSavedFilePaths(FILE_PATH_TYPE.ZIPS);
export const pendingUploads = async () => {
const collectionName = uploadStatusStore.get("collectionName");
const filePaths = validSavedPaths("files");
const zipPaths = validSavedPaths("zips");
let files: ElectronFile[] = [];
let type: FILE_PATH_TYPE;
let type: PendingUploads["type"];
if (zipPaths.length) {
type = FILE_PATH_TYPE.ZIPS;
type = "zips";
for (const zipPath of zipPaths) {
files = [
...files,
@ -23,9 +27,10 @@ export const getPendingUploads = async () => {
const pendingFilePaths = new Set(filePaths);
files = files.filter((file) => pendingFilePaths.has(file.path));
} else if (filePaths.length) {
type = FILE_PATH_TYPE.FILES;
type = "files";
files = await Promise.all(filePaths.map(getElectronFile));
}
return {
files,
collectionName,
@ -33,16 +38,56 @@ export const getPendingUploads = async () => {
};
};
export const getSavedFilePaths = (type: FILE_PATH_TYPE) => {
const paths =
getValidPaths(
uploadStatusStore.get(FILE_PATH_KEYS[type]) as string[],
) ?? [];
setToUploadFiles(type, paths);
export const validSavedPaths = (type: PendingUploads["type"]) => {
const key = storeKey(type);
const savedPaths = (uploadStatusStore.get(key) as string[]) ?? [];
const paths = savedPaths.filter((p) => existsSync(p));
uploadStatusStore.set(key, paths);
return paths;
};
export const setPendingUploadCollection = (collectionName: string) => {
if (collectionName) uploadStatusStore.set("collectionName", collectionName);
else uploadStatusStore.delete("collectionName");
};
export const setPendingUploadFiles = (
type: PendingUploads["type"],
filePaths: string[],
) => {
const key = storeKey(type);
if (filePaths) uploadStatusStore.set(key, filePaths);
else uploadStatusStore.delete(key);
};
const storeKey = (type: PendingUploads["type"]): keyof UploadStatusStore => {
switch (type) {
case "zips":
return "zipPaths";
case "files":
return "filePaths";
}
};
export const getElectronFilesFromGoogleZip = async (filePath: string) => {
const zip = new StreamZip.async({
file: filePath,
});
const zipName = path.basename(filePath, ".zip");
const entries = await zip.entries();
const files: ElectronFile[] = [];
for (const entry of Object.values(entries)) {
const basename = path.basename(entry.name);
if (entry.isFile && basename.length > 0 && basename[0] !== ".") {
files.push(await getZipEntryAsElectronFile(zipName, zip, entry));
}
}
return files;
};
export async function getZipEntryAsElectronFile(
zipName: string,
zip: StreamZip.StreamZipAsync,
@ -69,39 +114,3 @@ export async function getZipEntryAsElectronFile(
},
};
}
export const setToUploadFiles = (type: FILE_PATH_TYPE, filePaths: string[]) => {
const key = FILE_PATH_KEYS[type];
if (filePaths) {
uploadStatusStore.set(key, filePaths);
} else {
uploadStatusStore.delete(key);
}
};
export const setToUploadCollection = (collectionName: string) => {
if (collectionName) {
uploadStatusStore.set("collectionName", collectionName);
} else {
uploadStatusStore.delete("collectionName");
}
};
export const getElectronFilesFromGoogleZip = async (filePath: string) => {
const zip = new StreamZip.async({
file: filePath,
});
const zipName = path.basename(filePath, ".zip");
const entries = await zip.entries();
const files: ElectronFile[] = [];
for (const entry of Object.values(entries)) {
const basename = path.basename(entry.name);
if (entry.isFile && basename.length > 0 && basename[0] !== ".") {
files.push(await getZipEntryAsElectronFile(zipName, zip, entry));
}
}
return files;
};

View file

@ -1,101 +1,159 @@
import type { FSWatcher } from "chokidar";
import ElectronLog from "electron-log";
import { WatchMapping, WatchStoreType } from "../../types/ipc";
import { watchStore } from "../stores/watch.store";
import chokidar, { type FSWatcher } from "chokidar";
import { BrowserWindow } from "electron/main";
import fs from "node:fs/promises";
import path from "node:path";
import { FolderWatch, type CollectionMapping } from "../../types/ipc";
import { fsIsDir } from "../fs";
import log from "../log";
import { watchStore } from "../stores/watch";
export const addWatchMapping = async (
watcher: FSWatcher,
rootFolderName: string,
folderPath: string,
uploadStrategy: number,
) => {
ElectronLog.log(`Adding watch mapping: ${folderPath}`);
const watchMappings = getWatchMappings();
if (isMappingPresent(watchMappings, folderPath)) {
throw new Error(`Watch mapping already exists`);
/**
* Create and return a new file system watcher.
*
* Internally this uses the watcher from the chokidar package.
*
* @param mainWindow The window handle is used to notify the renderer process of
* pertinent file system events.
*/
export const createWatcher = (mainWindow: BrowserWindow) => {
const send = (eventName: string) => (path: string) =>
mainWindow.webContents.send(eventName, ...eventData(path));
const folderPaths = folderWatches().map((watch) => watch.folderPath);
const watcher = chokidar.watch(folderPaths, {
awaitWriteFinish: true,
});
watcher
.on("add", send("watchAddFile"))
.on("unlink", send("watchRemoveFile"))
.on("unlinkDir", send("watchRemoveDir"))
.on("error", (error) => log.error("Error while watching files", error));
return watcher;
};
const eventData = (path: string): [string, FolderWatch] => {
path = posixPath(path);
const watch = folderWatches().find((watch) =>
path.startsWith(watch.folderPath + "/"),
);
if (!watch) throw new Error(`No folder watch was found for path ${path}`);
return [path, watch];
};
/**
* Convert a file system {@link filePath} that uses the local system specific
* path separators into a path that uses POSIX file separators.
*/
const posixPath = (filePath: string) =>
filePath.split(path.sep).join(path.posix.sep);
export const watchGet = (watcher: FSWatcher) => {
const [valid, deleted] = folderWatches().reduce(
([valid, deleted], watch) => {
(fsIsDir(watch.folderPath) ? valid : deleted).push(watch);
return [valid, deleted];
},
[[], []],
);
if (deleted.length) {
for (const watch of deleted) watchRemove(watcher, watch.folderPath);
setFolderWatches(valid);
}
return valid;
};
watcher.add(folderPath);
const folderWatches = (): FolderWatch[] => watchStore.get("mappings") ?? [];
watchMappings.push({
rootFolderName,
uploadStrategy,
const setFolderWatches = (watches: FolderWatch[]) =>
watchStore.set("mappings", watches);
export const watchAdd = async (
watcher: FSWatcher,
folderPath: string,
collectionMapping: CollectionMapping,
) => {
const watches = folderWatches();
if (!fsIsDir(folderPath))
throw new Error(
`Attempting to add a folder watch for a folder path ${folderPath} that is not an existing directory`,
);
if (watches.find((watch) => watch.folderPath == folderPath))
throw new Error(
`A folder watch with the given folder path ${folderPath} already exists`,
);
watches.push({
folderPath,
collectionMapping,
syncedFiles: [],
ignoredFiles: [],
});
setWatchMappings(watchMappings);
setFolderWatches(watches);
watcher.add(folderPath);
return watches;
};
function isMappingPresent(watchMappings: WatchMapping[], folderPath: string) {
const watchMapping = watchMappings?.find(
(mapping) => mapping.folderPath === folderPath,
);
return !!watchMapping;
}
export const watchRemove = async (watcher: FSWatcher, folderPath: string) => {
const watches = folderWatches();
const filtered = watches.filter((watch) => watch.folderPath != folderPath);
if (watches.length == filtered.length)
throw new Error(
`Attempting to remove a non-existing folder watch for folder path ${folderPath}`,
);
setFolderWatches(filtered);
watcher.unwatch(folderPath);
return filtered;
};
export const removeWatchMapping = async (
watcher: FSWatcher,
export const watchUpdateSyncedFiles = (
syncedFiles: FolderWatch["syncedFiles"],
folderPath: string,
) => {
let watchMappings = getWatchMappings();
const watchMapping = watchMappings.find(
(mapping) => mapping.folderPath === folderPath,
setFolderWatches(
folderWatches().map((watch) => {
if (watch.folderPath == folderPath) {
watch.syncedFiles = syncedFiles;
}
return watch;
}),
);
if (!watchMapping) {
throw new Error(`Watch mapping does not exist`);
}
watcher.unwatch(watchMapping.folderPath);
watchMappings = watchMappings.filter(
(mapping) => mapping.folderPath !== watchMapping.folderPath,
);
setWatchMappings(watchMappings);
};
export function updateWatchMappingSyncedFiles(
export const watchUpdateIgnoredFiles = (
ignoredFiles: FolderWatch["ignoredFiles"],
folderPath: string,
files: WatchMapping["syncedFiles"],
): void {
const watchMappings = getWatchMappings();
const watchMapping = watchMappings.find(
(mapping) => mapping.folderPath === folderPath,
) => {
setFolderWatches(
folderWatches().map((watch) => {
if (watch.folderPath == folderPath) {
watch.ignoredFiles = ignoredFiles;
}
return watch;
}),
);
};
if (!watchMapping) {
throw Error(`Watch mapping not found`);
export const watchFindFiles = async (dirPath: string) => {
const items = await fs.readdir(dirPath, { withFileTypes: true });
let paths: string[] = [];
for (const item of items) {
const itemPath = path.posix.join(dirPath, item.name);
if (item.isFile()) {
paths.push(itemPath);
} else if (item.isDirectory()) {
paths = [...paths, ...(await watchFindFiles(itemPath))];
}
}
watchMapping.syncedFiles = files;
setWatchMappings(watchMappings);
}
export function updateWatchMappingIgnoredFiles(
folderPath: string,
files: WatchMapping["ignoredFiles"],
): void {
const watchMappings = getWatchMappings();
const watchMapping = watchMappings.find(
(mapping) => mapping.folderPath === folderPath,
);
if (!watchMapping) {
throw Error(`Watch mapping not found`);
}
watchMapping.ignoredFiles = files;
setWatchMappings(watchMappings);
}
export function getWatchMappings() {
const mappings = watchStore.get("mappings") ?? [];
return mappings;
}
function setWatchMappings(watchMappings: WatchStoreType["mappings"]) {
watchStore.set("mappings", watchMappings);
}
return paths;
};

View file

@ -1,18 +0,0 @@
import Store, { Schema } from "electron-store";
import type { KeysStoreType } from "../../types/main";
const keysStoreSchema: Schema<KeysStoreType> = {
AnonymizeUserID: {
type: "object",
properties: {
id: {
type: "string",
},
},
},
};
export const keysStore = new Store({
name: "keys",
schema: keysStoreSchema,
});

View file

@ -1,7 +1,10 @@
import Store, { Schema } from "electron-store";
import type { SafeStorageStoreType } from "../../types/main";
const safeStorageSchema: Schema<SafeStorageStoreType> = {
interface SafeStorageStore {
encryptionKey: string;
}
const safeStorageSchema: Schema<SafeStorageStore> = {
encryptionKey: {
type: "string",
},

View file

@ -1,7 +1,12 @@
import Store, { Schema } from "electron-store";
import type { UploadStoreType } from "../../types/main";
const uploadStoreSchema: Schema<UploadStoreType> = {
export interface UploadStatusStore {
filePaths: string[];
zipPaths: string[];
collectionName: string;
}
const uploadStatusSchema: Schema<UploadStatusStore> = {
filePaths: {
type: "array",
items: {
@ -21,5 +26,5 @@ const uploadStoreSchema: Schema<UploadStoreType> = {
export const uploadStatusStore = new Store({
name: "upload-status",
schema: uploadStoreSchema,
schema: uploadStatusSchema,
});

View file

@ -1,12 +1,12 @@
import Store, { Schema } from "electron-store";
interface UserPreferencesSchema {
interface UserPreferences {
hideDockIcon: boolean;
skipAppVersion?: string;
muteUpdateNotificationVersion?: string;
}
const userPreferencesSchema: Schema<UserPreferencesSchema> = {
const userPreferencesSchema: Schema<UserPreferences> = {
hideDockIcon: {
type: "boolean",
},

View file

@ -1,47 +0,0 @@
import Store, { Schema } from "electron-store";
import { WatchStoreType } from "../../types/ipc";
const watchStoreSchema: Schema<WatchStoreType> = {
mappings: {
type: "array",
items: {
type: "object",
properties: {
rootFolderName: {
type: "string",
},
uploadStrategy: {
type: "number",
},
folderPath: {
type: "string",
},
syncedFiles: {
type: "array",
items: {
type: "object",
properties: {
path: {
type: "string",
},
id: {
type: "number",
},
},
},
},
ignoredFiles: {
type: "array",
items: {
type: "string",
},
},
},
},
},
};
export const watchStore = new Store({
name: "watch-status",
schema: watchStoreSchema,
});

View file

@ -0,0 +1,73 @@
import Store, { Schema } from "electron-store";
import { type FolderWatch } from "../../types/ipc";
import log from "../log";
interface WatchStore {
mappings: FolderWatchWithLegacyFields[];
}
type FolderWatchWithLegacyFields = FolderWatch & {
/** @deprecated Only retained for migration, do not use in other code */
rootFolderName?: string;
/** @deprecated Only retained for migration, do not use in other code */
uploadStrategy?: number;
};
const watchStoreSchema: Schema<WatchStore> = {
mappings: {
type: "array",
items: {
type: "object",
properties: {
rootFolderName: { type: "string" },
collectionMapping: { type: "string" },
uploadStrategy: { type: "number" },
folderPath: { type: "string" },
syncedFiles: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
uploadedFileID: { type: "number" },
collectionID: { type: "number" },
},
},
},
ignoredFiles: {
type: "array",
items: { type: "string" },
},
},
},
},
};
export const watchStore = new Store({
name: "watch-status",
schema: watchStoreSchema,
});
/**
* Previous versions of the store used to store an integer to indicate the
* collection mapping, migrate these to the new schema if we encounter them.
*/
export const migrateLegacyWatchStoreIfNeeded = () => {
let needsUpdate = false;
const watches = watchStore.get("mappings")?.map((watch) => {
let collectionMapping = watch.collectionMapping;
if (!collectionMapping) {
collectionMapping = watch.uploadStrategy == 1 ? "parent" : "root";
needsUpdate = true;
}
if (watch.rootFolderName) {
delete watch.rootFolderName;
needsUpdate = true;
}
return { ...watch, collectionMapping };
});
if (needsUpdate) {
watchStore.set("mappings", watches);
log.info("Migrated legacy watch store data to new schema");
}
};

View file

@ -56,6 +56,13 @@ export const openDirectory = async (dirPath: string) => {
if (res) throw new Error(`Failed to open directory ${dirPath}: res`);
};
/**
* Open the app's log directory in the system's folder viewer.
*
* @see {@link openDirectory}
*/
export const openLogDirectory = () => openDirectory(logDirectoryPath());
/**
* Return the path where the logs for the app are saved.
*
@ -72,10 +79,3 @@ export const openDirectory = async (dirPath: string) => {
*
*/
const logDirectoryPath = () => app.getPath("logs");
/**
* Open the app's log directory in the system's folder viewer.
*
* @see {@link openDirectory}
*/
export const openLogDirectory = () => openDirectory(logDirectoryPath());

View file

@ -40,12 +40,13 @@
import { contextBridge, ipcRenderer } from "electron/renderer";
// While we can't import other code, we can import types since they're just
// needed when compiling and will not be needed / looked around for at runtime.
// needed when compiling and will not be needed or looked around for at runtime.
import type {
AppUpdateInfo,
AppUpdate,
CollectionMapping,
ElectronFile,
FILE_PATH_TYPE,
WatchMapping,
FolderWatch,
PendingUploads,
} from "./types/ipc";
// - General
@ -77,12 +78,12 @@ const onMainWindowFocus = (cb?: () => void) => {
// - App update
const onAppUpdateAvailable = (
cb?: ((updateInfo: AppUpdateInfo) => void) | undefined,
cb?: ((update: AppUpdate) => void) | undefined,
) => {
ipcRenderer.removeAllListeners("appUpdateAvailable");
if (cb) {
ipcRenderer.on("appUpdateAvailable", (_, updateInfo: AppUpdateInfo) =>
cb(updateInfo),
ipcRenderer.on("appUpdateAvailable", (_, update: AppUpdate) =>
cb(update),
);
}
};
@ -118,6 +119,9 @@ const fsReadTextFile = (path: string): Promise<string> =>
const fsWriteFile = (path: string, contents: string): Promise<void> =>
ipcRenderer.invoke("fsWriteFile", path, contents);
const fsIsDir = (dirPath: string): Promise<boolean> =>
ipcRenderer.invoke("fsIsDir", dirPath);
// - AUDIT below this
// - Conversion
@ -188,82 +192,78 @@ const showUploadZipDialog = (): Promise<{
// - Watch
const registerWatcherFunctions = (
addFile: (file: ElectronFile) => Promise<void>,
removeFile: (path: string) => Promise<void>,
removeFolder: (folderPath: string) => Promise<void>,
) => {
ipcRenderer.removeAllListeners("watch-add");
ipcRenderer.removeAllListeners("watch-unlink");
ipcRenderer.removeAllListeners("watch-unlink-dir");
ipcRenderer.on("watch-add", (_, file: ElectronFile) => addFile(file));
ipcRenderer.on("watch-unlink", (_, filePath: string) =>
removeFile(filePath),
);
ipcRenderer.on("watch-unlink-dir", (_, folderPath: string) =>
removeFolder(folderPath),
const watchGet = (): Promise<FolderWatch[]> => ipcRenderer.invoke("watchGet");
const watchAdd = (
folderPath: string,
collectionMapping: CollectionMapping,
): Promise<FolderWatch[]> =>
ipcRenderer.invoke("watchAdd", folderPath, collectionMapping);
const watchRemove = (folderPath: string): Promise<FolderWatch[]> =>
ipcRenderer.invoke("watchRemove", folderPath);
const watchUpdateSyncedFiles = (
syncedFiles: FolderWatch["syncedFiles"],
folderPath: string,
): Promise<void> =>
ipcRenderer.invoke("watchUpdateSyncedFiles", syncedFiles, folderPath);
const watchUpdateIgnoredFiles = (
ignoredFiles: FolderWatch["ignoredFiles"],
folderPath: string,
): Promise<void> =>
ipcRenderer.invoke("watchUpdateIgnoredFiles", ignoredFiles, folderPath);
const watchOnAddFile = (f: (path: string, watch: FolderWatch) => void) => {
ipcRenderer.removeAllListeners("watchAddFile");
ipcRenderer.on("watchAddFile", (_, path: string, watch: FolderWatch) =>
f(path, watch),
);
};
const addWatchMapping = (
collectionName: string,
folderPath: string,
uploadStrategy: number,
): Promise<void> =>
ipcRenderer.invoke(
"addWatchMapping",
collectionName,
folderPath,
uploadStrategy,
const watchOnRemoveFile = (f: (path: string, watch: FolderWatch) => void) => {
ipcRenderer.removeAllListeners("watchRemoveFile");
ipcRenderer.on("watchRemoveFile", (_, path: string, watch: FolderWatch) =>
f(path, watch),
);
};
const removeWatchMapping = (folderPath: string): Promise<void> =>
ipcRenderer.invoke("removeWatchMapping", folderPath);
const watchOnRemoveDir = (f: (path: string, watch: FolderWatch) => void) => {
ipcRenderer.removeAllListeners("watchRemoveDir");
ipcRenderer.on("watchRemoveDir", (_, path: string, watch: FolderWatch) =>
f(path, watch),
);
};
const getWatchMappings = (): Promise<WatchMapping[]> =>
ipcRenderer.invoke("getWatchMappings");
const updateWatchMappingSyncedFiles = (
folderPath: string,
files: WatchMapping["syncedFiles"],
): Promise<void> =>
ipcRenderer.invoke("updateWatchMappingSyncedFiles", folderPath, files);
const updateWatchMappingIgnoredFiles = (
folderPath: string,
files: WatchMapping["ignoredFiles"],
): Promise<void> =>
ipcRenderer.invoke("updateWatchMappingIgnoredFiles", folderPath, files);
// - FS Legacy
const isFolder = (dirPath: string): Promise<boolean> =>
ipcRenderer.invoke("isFolder", dirPath);
const watchFindFiles = (folderPath: string): Promise<string[]> =>
ipcRenderer.invoke("watchFindFiles", folderPath);
// - Upload
const getPendingUploads = (): Promise<{
files: ElectronFile[];
collectionName: string;
type: string;
}> => ipcRenderer.invoke("getPendingUploads");
const pendingUploads = (): Promise<PendingUploads | undefined> =>
ipcRenderer.invoke("pendingUploads");
const setToUploadFiles = (
type: FILE_PATH_TYPE,
const setPendingUploadCollection = (collectionName: string): Promise<void> =>
ipcRenderer.invoke("setPendingUploadCollection", collectionName);
const setPendingUploadFiles = (
type: PendingUploads["type"],
filePaths: string[],
): Promise<void> => ipcRenderer.invoke("setToUploadFiles", type, filePaths);
): Promise<void> =>
ipcRenderer.invoke("setPendingUploadFiles", type, filePaths);
// -
const getElectronFilesFromGoogleZip = (
filePath: string,
): Promise<ElectronFile[]> =>
ipcRenderer.invoke("getElectronFilesFromGoogleZip", filePath);
const setToUploadCollection = (collectionName: string): Promise<void> =>
ipcRenderer.invoke("setToUploadCollection", collectionName);
const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
ipcRenderer.invoke("getDirFiles", dirPath);
//
// These objects exposed here will become available to the JS code in our
// renderer (the web/ code) as `window.ElectronAPIs.*`
//
@ -295,10 +295,13 @@ const getDirFiles = (dirPath: string): Promise<ElectronFile[]> =>
// https://www.electronjs.org/docs/latest/api/context-bridge#methods
//
// The copy itself is relatively fast, but the problem with transfering large
// amounts of data is potentially running out of memory during the copy. For an
// alternative, see [Note: IPC streams].
// amounts of data is potentially running out of memory during the copy.
//
// For an alternative, see [Note: IPC streams].
//
contextBridge.exposeInMainWorld("electron", {
// - General
appVersion,
logToDisk,
openDirectory,
@ -309,12 +312,14 @@ contextBridge.exposeInMainWorld("electron", {
onMainWindowFocus,
// - App update
onAppUpdateAvailable,
updateAndRestart,
updateOnNextRestart,
skipAppUpdate,
// - FS
fs: {
exists: fsExists,
rename: fsRename,
@ -323,42 +328,51 @@ contextBridge.exposeInMainWorld("electron", {
rm: fsRm,
readTextFile: fsReadTextFile,
writeFile: fsWriteFile,
isDir: fsIsDir,
},
// - Conversion
convertToJPEG,
generateImageThumbnail,
runFFmpegCmd,
// - ML
clipImageEmbedding,
clipTextEmbedding,
detectFaces,
faceEmbedding,
// - File selection
selectDirectory,
showUploadFilesDialog,
showUploadDirsDialog,
showUploadZipDialog,
// - Watch
registerWatcherFunctions,
addWatchMapping,
removeWatchMapping,
getWatchMappings,
updateWatchMappingSyncedFiles,
updateWatchMappingIgnoredFiles,
// - FS legacy
// TODO: Move these into fs + document + rename if needed
isFolder,
watch: {
get: watchGet,
add: watchAdd,
remove: watchRemove,
onAddFile: watchOnAddFile,
onRemoveFile: watchOnRemoveFile,
onRemoveDir: watchOnRemoveDir,
findFiles: watchFindFiles,
updateSyncedFiles: watchUpdateSyncedFiles,
updateIgnoredFiles: watchUpdateIgnoredFiles,
},
// - Upload
getPendingUploads,
setToUploadFiles,
pendingUploads,
setPendingUploadCollection,
setPendingUploadFiles,
// -
getElectronFilesFromGoogleZip,
setToUploadCollection,
getDirFiles,
});

View file

@ -5,6 +5,32 @@
* See [Note: types.ts <-> preload.ts <-> ipc.ts]
*/
export interface AppUpdate {
autoUpdatable: boolean;
version: string;
}
export interface FolderWatch {
collectionMapping: CollectionMapping;
folderPath: string;
syncedFiles: FolderWatchSyncedFile[];
ignoredFiles: string[];
}
export type CollectionMapping = "root" | "parent";
export interface FolderWatchSyncedFile {
path: string;
uploadedFileID: number;
collectionID: number;
}
export interface PendingUploads {
collectionName: string;
type: "files" | "zips";
files: ElectronFile[];
}
/**
* Errors that have special semantics on the web side.
*
@ -51,32 +77,3 @@ export interface ElectronFile {
blob: () => Promise<Blob>;
arrayBuffer: () => Promise<Uint8Array>;
}
interface WatchMappingSyncedFile {
path: string;
uploadedFileID: number;
collectionID: number;
}
export interface WatchMapping {
rootFolderName: string;
uploadStrategy: number;
folderPath: string;
syncedFiles: WatchMappingSyncedFile[];
ignoredFiles: string[];
}
export interface WatchStoreType {
mappings: WatchMapping[];
}
export enum FILE_PATH_TYPE {
/* eslint-disable no-unused-vars */
FILES = "files",
ZIPS = "zips",
}
export interface AppUpdateInfo {
autoUpdatable: boolean;
version: string;
}

View file

@ -1,31 +0,0 @@
import { FILE_PATH_TYPE } from "./ipc";
export interface AutoLauncherClient {
isEnabled: () => Promise<boolean>;
toggleAutoLaunch: () => Promise<void>;
wasAutoLaunched: () => Promise<boolean>;
}
export interface UploadStoreType {
filePaths: string[];
zipPaths: string[];
collectionName: string;
}
export interface KeysStoreType {
AnonymizeUserID: {
id: string;
};
}
/* eslint-disable no-unused-vars */
export const FILE_PATH_KEYS: {
[k in FILE_PATH_TYPE]: keyof UploadStoreType;
} = {
[FILE_PATH_TYPE.ZIPS]: "zipPaths",
[FILE_PATH_TYPE.FILES]: "filePaths",
};
export interface SafeStorageStoreType {
encryptionKey: string;
}

View file

@ -110,10 +110,10 @@ or "dog playing at the beach".
Check the sections within the upload progress bar for "Failed Uploads," "Ignored
Uploads," and "Unsuccessful Uploads."
## How do i keep NAS and Ente photos synced?
## How do I keep NAS and Ente photos synced?
Please try using our CLI to pull data into your NAS
https://github.com/ente-io/ente/tree/main/cli#readme .
https://github.com/ente-io/ente/tree/main/cli#readme.
## Is there a way to view all albums on the map view?

View file

@ -478,11 +478,10 @@ class FilesDB {
}
Future<EnteFile?> getFile(int generatedID) async {
final db = await instance.database;
final results = await db.query(
filesTable,
where: '$columnGeneratedID = ?',
whereArgs: [generatedID],
final db = await instance.ffiDB;
final results = db.select(
'SELECT * FROM $filesTable WHERE $columnGeneratedID = ?',
[generatedID],
);
if (results.isEmpty) {
return null;
@ -491,11 +490,10 @@ class FilesDB {
}
Future<EnteFile?> getUploadedFile(int uploadedID, int collectionID) async {
final db = await instance.database;
final results = await db.query(
filesTable,
where: '$columnUploadedFileID = ? AND $columnCollectionID = ?',
whereArgs: [
final db = await instance.ffiDB;
final results = db.select(
'SELECT * FROM $filesTable WHERE $columnUploadedFileID = ? AND $columnCollectionID = ?',
[
uploadedID,
collectionID,
],
@ -506,29 +504,12 @@ class FilesDB {
return convertToFiles(results)[0];
}
Future<EnteFile?> getAnyUploadedFile(int uploadedID) async {
final db = await instance.database;
final results = await db.query(
filesTable,
where: '$columnUploadedFileID = ?',
whereArgs: [
uploadedID,
],
);
if (results.isEmpty) {
return null;
}
return convertToFiles(results)[0];
}
Future<Set<int>> getUploadedFileIDs(int collectionID) async {
final db = await instance.database;
final results = await db.query(
filesTable,
columns: [columnUploadedFileID],
where:
'$columnCollectionID = ? AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)',
whereArgs: [
final db = await instance.ffiDB;
final results = db.select(
'SELECT $columnUploadedFileID FROM $filesTable'
' WHERE $columnCollectionID = ? AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)',
[
collectionID,
],
);
@ -564,12 +545,10 @@ class FilesDB {
}
Future<BackedUpFileIDs> getBackedUpIDs() async {
final db = await instance.database;
final results = await db.query(
filesTable,
columns: [columnLocalID, columnUploadedFileID, columnFileSize],
where:
'$columnLocalID IS NOT NULL AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)',
final db = await instance.sqliteAsyncDB;
final results = await db.getAll(
'SELECT $columnLocalID, $columnUploadedFileID, $columnFileSize FROM $filesTable'
' WHERE $columnLocalID IS NOT NULL AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)',
);
final Set<String> localIDs = <String>{};
final Set<int> uploadedIDs = <int>{};
@ -705,13 +684,12 @@ class FilesDB {
}
Future<List<EnteFile>> getAllFilesCollection(int collectionID) async {
final db = await instance.database;
final db = await instance.sqliteAsyncDB;
const String whereClause = '$columnCollectionID = ?';
final List<Object> whereArgs = [collectionID];
final results = await db.query(
filesTable,
where: whereClause,
whereArgs: whereArgs,
final results = await db.getAll(
'SELECT * FROM $filesTable WHERE $whereClause',
whereArgs,
);
final files = convertToFiles(results);
return files;
@ -721,14 +699,13 @@ class FilesDB {
int collectionID,
int addedTime,
) async {
final db = await instance.database;
final db = await instance.sqliteAsyncDB;
const String whereClause =
'$columnCollectionID = ? AND $columnAddedTime > ?';
final List<Object> whereArgs = [collectionID, addedTime];
final results = await db.query(
filesTable,
where: whereClause,
whereArgs: whereArgs,
final results = await db.getAll(
'SELECT * FROM $filesTable WHERE $whereClause',
whereArgs,
);
final files = convertToFiles(results);
return files;
@ -750,20 +727,22 @@ class FilesDB {
inParam += "'" + id.toString() + "',";
}
inParam = inParam.substring(0, inParam.length - 1);
final db = await instance.database;
final db = await instance.sqliteAsyncDB;
final order = (asc ?? false ? 'ASC' : 'DESC');
final String whereClause =
'$columnCollectionID IN ($inParam) AND $columnCreationTime >= ? AND '
'$columnCreationTime <= ? AND $columnOwnerID = ?';
final List<Object> whereArgs = [startTime, endTime, userID];
final results = await db.query(
filesTable,
where: whereClause,
whereArgs: whereArgs,
orderBy:
'$columnCreationTime ' + order + ', $columnModificationTime ' + order,
limit: limit,
String query = 'SELECT * FROM $filesTable WHERE $whereClause ORDER BY '
'$columnCreationTime $order, $columnModificationTime $order';
if (limit != null) {
query += ' LIMIT ?';
whereArgs.add(limit);
}
final results = await db.getAll(
query,
whereArgs,
);
final files = convertToFiles(results);
final dedupeResult =
@ -781,7 +760,7 @@ class FilesDB {
if (durations.isEmpty) {
return <EnteFile>[];
}
final db = await instance.database;
final db = await instance.sqliteAsyncDB;
String whereClause = "( ";
for (int index = 0; index < durations.length; index++) {
whereClause += "($columnCreationTime >= " +
@ -796,11 +775,12 @@ class FilesDB {
}
}
whereClause += ")";
final results = await db.query(
filesTable,
where: whereClause,
orderBy: '$columnCreationTime ' + order,
final query =
'SELECT * FROM $filesTable WHERE $whereClause ORDER BY $columnCreationTime $order';
final results = await db.getAll(
query,
);
final files = convertToFiles(results);
return applyDBFilters(
files,

View file

@ -55,6 +55,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Modify your query, or try searching for"),
"moveToHiddenAlbum":
MessageLookupByLibrary.simpleMessage("Move to hidden album"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"selectALocation":
MessageLookupByLibrary.simpleMessage("Select a location"),
"selectALocationFirst":

View file

@ -1213,6 +1213,7 @@ class MessageLookup extends MessageLookupByLibrary {
"scanThisBarcodeWithnyourAuthenticatorApp":
MessageLookupByLibrary.simpleMessage(
"Scanne diesen Code mit \ndeiner Authentifizierungs-App"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"searchAlbumsEmptySection":
MessageLookupByLibrary.simpleMessage("Alben"),
"searchByAlbumNameHint":

View file

@ -1175,6 +1175,7 @@ class MessageLookup extends MessageLookupByLibrary {
"scanThisBarcodeWithnyourAuthenticatorApp":
MessageLookupByLibrary.simpleMessage(
"Scan this barcode with\nyour authenticator app"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"searchAlbumsEmptySection":
MessageLookupByLibrary.simpleMessage("Albums"),
"searchByAlbumNameHint":

View file

@ -1044,6 +1044,7 @@ class MessageLookup extends MessageLookupByLibrary {
"scanThisBarcodeWithnyourAuthenticatorApp":
MessageLookupByLibrary.simpleMessage(
"Escanea este código QR con tu aplicación de autenticación"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"searchByAlbumNameHint":
MessageLookupByLibrary.simpleMessage("Nombre del álbum"),
"searchByExamples": MessageLookupByLibrary.simpleMessage(

View file

@ -1182,6 +1182,7 @@ class MessageLookup extends MessageLookupByLibrary {
"scanThisBarcodeWithnyourAuthenticatorApp":
MessageLookupByLibrary.simpleMessage(
"Scannez ce code-barres avec\nvotre application d\'authentification"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"searchAlbumsEmptySection":
MessageLookupByLibrary.simpleMessage("Albums"),
"searchByAlbumNameHint":

View file

@ -1137,6 +1137,7 @@ class MessageLookup extends MessageLookupByLibrary {
"scanThisBarcodeWithnyourAuthenticatorApp":
MessageLookupByLibrary.simpleMessage(
"Scansione questo codice QR\ncon la tua app di autenticazione"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"searchByAlbumNameHint":
MessageLookupByLibrary.simpleMessage("Nome album"),
"searchByExamples": MessageLookupByLibrary.simpleMessage(

View file

@ -55,6 +55,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Modify your query, or try searching for"),
"moveToHiddenAlbum":
MessageLookupByLibrary.simpleMessage("Move to hidden album"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"selectALocation":
MessageLookupByLibrary.simpleMessage("Select a location"),
"selectALocationFirst":

View file

@ -21,7 +21,7 @@ class MessageLookup extends MessageLookupByLibrary {
String get localeName => 'nl';
static String m0(count) =>
"${Intl.plural(count, zero: 'Add collaborator', one: 'Add collaborator', other: 'Add collaborators')}";
"${Intl.plural(count, zero: 'Voeg samenwerker toe', one: 'Voeg samenwerker toe', other: 'Voeg samenwerkers toe')}";
static String m2(count) =>
"${Intl.plural(count, one: 'Bestand toevoegen', other: 'Bestanden toevoegen')}";
@ -30,7 +30,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Jouw ${storageAmount} add-on is geldig tot ${endDate}";
static String m1(count) =>
"${Intl.plural(count, zero: 'Add viewer', one: 'Add viewer', other: 'Add viewers')}";
"${Intl.plural(count, one: 'Voeg kijker toe', other: 'Voeg kijkers toe')}";
static String m4(emailOrName) => "Toegevoegd door ${emailOrName}";
@ -64,6 +64,8 @@ class MessageLookup extends MessageLookupByLibrary {
static String m13(provider) =>
"Neem contact met ons op via support@ente.io om uw ${provider} abonnement te beheren.";
static String m69(endpoint) => "Verbonden met ${endpoint}";
static String m14(count) =>
"${Intl.plural(count, one: 'Verwijder ${count} bestand', other: 'Verwijder ${count} bestanden')}";
@ -85,7 +87,7 @@ class MessageLookup extends MessageLookupByLibrary {
static String m20(newEmail) => "E-mailadres gewijzigd naar ${newEmail}";
static String m21(email) =>
"${email} heeft geen ente account.\n\nStuur ze een uitnodiging om foto\'s te delen.";
"${email} heeft geen Ente account.\n\nStuur ze een uitnodiging om foto\'s te delen.";
static String m22(count, formattedNumber) =>
"${Intl.plural(count, one: '1 bestand', other: '${formattedNumber} bestanden')} in dit album zijn veilig geback-upt";
@ -102,7 +104,7 @@ class MessageLookup extends MessageLookupByLibrary {
static String m26(endDate) => "Gratis proefversie geldig tot ${endDate}";
static String m27(count) =>
"U heeft nog steeds toegang tot ${Intl.plural(count, one: 'het', other: 'ze')} op ente zolang u een actief abonnement heeft";
"Je hebt nog steeds toegang tot ${Intl.plural(count, one: 'het', other: 'ze')} op Ente zolang je een actief abonnement hebt";
static String m28(sizeInMBorGB) => "Maak ${sizeInMBorGB} vrij";
@ -164,7 +166,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Hey, kunt u bevestigen dat dit uw ente.io verificatie-ID is: ${verificationID}";
static String m50(referralCode, referralStorageInGB) =>
"ente verwijzingscode: ${referralCode} \n\nPas het toe bij Instellingen → Algemeen → Verwijzingen om ${referralStorageInGB} GB gratis te krijgen nadat je je hebt aangemeld voor een betaald abonnement\n\nhttps://ente.io";
"Ente verwijzingscode: ${referralCode} \n\nPas het toe bij Instellingen → Algemeen → Verwijzingen om ${referralStorageInGB} GB gratis te krijgen nadat je je hebt aangemeld voor een betaald abonnement\n\nhttps://ente.io";
static String m51(numberOfPeople) =>
"${Intl.plural(numberOfPeople, zero: 'Deel met specifieke mensen', one: 'Gedeeld met 1 persoon', other: 'Gedeeld met ${numberOfPeople} mensen')}";
@ -175,10 +177,10 @@ class MessageLookup extends MessageLookupByLibrary {
"Deze ${fileType} zal worden verwijderd van jouw apparaat.";
static String m54(fileType) =>
"Deze ${fileType} staat zowel in ente als op jouw apparaat.";
"Deze ${fileType} staat zowel in Ente als op jouw apparaat.";
static String m55(fileType) =>
"Deze ${fileType} zal worden verwijderd uit ente.";
"Deze ${fileType} zal worden verwijderd uit Ente.";
static String m56(storageAmountInGB) => "${storageAmountInGB} GB";
@ -187,7 +189,7 @@ class MessageLookup extends MessageLookupByLibrary {
"${usedAmount} ${usedStorageUnit} van ${totalAmount} ${totalStorageUnit} gebruikt";
static String m58(id) =>
"Uw ${id} is al aan een ander ente account gekoppeld.\nAls u uw ${id} wilt gebruiken met dit account, neem dan contact op met onze klantenservice";
"Jouw ${id} is al aan een ander Ente account gekoppeld.\nAls je jouw ${id} wilt gebruiken met dit account, neem dan contact op met onze klantenservice";
static String m59(endDate) => "Uw abonnement loopt af op ${endDate}";
@ -218,7 +220,7 @@ class MessageLookup extends MessageLookupByLibrary {
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"aNewVersionOfEnteIsAvailable": MessageLookupByLibrary.simpleMessage(
"Er is een nieuwe versie van ente beschikbaar."),
"Er is een nieuwe versie van Ente beschikbaar."),
"about": MessageLookupByLibrary.simpleMessage("Over"),
"account": MessageLookupByLibrary.simpleMessage("Account"),
"accountWelcomeBack":
@ -249,7 +251,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Voeg geselecteerde toe"),
"addToAlbum":
MessageLookupByLibrary.simpleMessage("Toevoegen aan album"),
"addToEnte": MessageLookupByLibrary.simpleMessage("Toevoegen aan ente"),
"addToEnte": MessageLookupByLibrary.simpleMessage("Toevoegen aan Ente"),
"addToHiddenAlbum": MessageLookupByLibrary.simpleMessage(
"Toevoegen aan verborgen album"),
"addViewer": MessageLookupByLibrary.simpleMessage("Voeg kijker toe"),
@ -421,6 +423,8 @@ class MessageLookup extends MessageLookupByLibrary {
"claimedStorageSoFar": m10,
"cleanUncategorized":
MessageLookupByLibrary.simpleMessage("Ongecategoriseerd opschonen"),
"cleanUncategorizedDescription": MessageLookupByLibrary.simpleMessage(
"Verwijder alle bestanden van Ongecategoriseerd die aanwezig zijn in andere albums"),
"clearCaches": MessageLookupByLibrary.simpleMessage("Cache legen"),
"clearIndexes": MessageLookupByLibrary.simpleMessage("Index wissen"),
"click": MessageLookupByLibrary.simpleMessage("• Click"),
@ -438,7 +442,7 @@ class MessageLookup extends MessageLookupByLibrary {
"codeUsedByYou":
MessageLookupByLibrary.simpleMessage("Code gebruikt door jou"),
"collabLinkSectionDescription": MessageLookupByLibrary.simpleMessage(
"Maak een link waarmee mensen foto\'s in jouw gedeelde album kunnen toevoegen en bekijken zonder dat ze daarvoor een ente app of account nodig hebben. Handig voor het verzamelen van foto\'s van evenementen."),
"Maak een link waarmee mensen foto\'s in jouw gedeelde album kunnen toevoegen en bekijken zonder dat ze daarvoor een Ente app of account nodig hebben. Handig voor het verzamelen van foto\'s van evenementen."),
"collaborativeLink":
MessageLookupByLibrary.simpleMessage("Gezamenlijke link"),
"collaborativeLinkCreatedFor": m11,
@ -501,7 +505,7 @@ class MessageLookup extends MessageLookupByLibrary {
"createAlbumActionHint": MessageLookupByLibrary.simpleMessage(
"Lang indrukken om foto\'s te selecteren en klik + om een album te maken"),
"createCollaborativeLink":
MessageLookupByLibrary.simpleMessage("Create collaborative link"),
MessageLookupByLibrary.simpleMessage("Maak een gezamenlijke link"),
"createCollage": MessageLookupByLibrary.simpleMessage("Creëer collage"),
"createNewAccount":
MessageLookupByLibrary.simpleMessage("Nieuw account aanmaken"),
@ -516,6 +520,7 @@ class MessageLookup extends MessageLookupByLibrary {
"currentUsageIs":
MessageLookupByLibrary.simpleMessage("Huidig gebruik is "),
"custom": MessageLookupByLibrary.simpleMessage("Aangepast"),
"customEndpoint": m69,
"darkTheme": MessageLookupByLibrary.simpleMessage("Donker"),
"dayToday": MessageLookupByLibrary.simpleMessage("Vandaag"),
"dayYesterday": MessageLookupByLibrary.simpleMessage("Gisteren"),
@ -538,7 +543,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Hiermee worden alle lege albums verwijderd. Dit is handig wanneer je rommel in je albumlijst wilt verminderen."),
"deleteAll": MessageLookupByLibrary.simpleMessage("Alles Verwijderen"),
"deleteConfirmDialogBody": MessageLookupByLibrary.simpleMessage(
"Dit account is gekoppeld aan andere ente apps, als je er gebruik van maakt.\\n\\nJe geüploade gegevens worden in alle ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle ente diensten."),
"Dit account is gekoppeld aan andere Ente apps, als je er gebruik van maakt. Je geüploade gegevens worden in alle Ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle Ente diensten."),
"deleteEmailRequest": MessageLookupByLibrary.simpleMessage(
"Stuur een e-mail naar <warning>account-deletion@ente.io</warning> vanaf het door jou geregistreerde e-mailadres."),
"deleteEmptyAlbums":
@ -550,7 +555,7 @@ class MessageLookup extends MessageLookupByLibrary {
"deleteFromDevice":
MessageLookupByLibrary.simpleMessage("Verwijder van apparaat"),
"deleteFromEnte":
MessageLookupByLibrary.simpleMessage("Verwijder van ente"),
MessageLookupByLibrary.simpleMessage("Verwijder van Ente"),
"deleteItemCount": m14,
"deleteLocation":
MessageLookupByLibrary.simpleMessage("Verwijder locatie"),
@ -571,7 +576,7 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Gedeeld album verwijderen?"),
"deleteSharedAlbumDialogBody": MessageLookupByLibrary.simpleMessage(
"Het album wordt verwijderd voor iedereen\n\nJe verliest de toegang tot gedeelde foto\'s in dit album die eigendom zijn van anderen"),
"descriptions": MessageLookupByLibrary.simpleMessage("Descriptions"),
"descriptions": MessageLookupByLibrary.simpleMessage("Beschrijvingen"),
"deselectAll":
MessageLookupByLibrary.simpleMessage("Alles deselecteren"),
"designedToOutlive": MessageLookupByLibrary.simpleMessage(
@ -579,12 +584,16 @@ class MessageLookup extends MessageLookupByLibrary {
"details": MessageLookupByLibrary.simpleMessage("Details"),
"devAccountChanged": MessageLookupByLibrary.simpleMessage(
"Het ontwikkelaarsaccount dat we gebruiken om te publiceren in de App Store is veranderd. Daarom moet je opnieuw inloggen.\n\nOnze excuses voor het ongemak, helaas was dit onvermijdelijk."),
"developerSettings":
MessageLookupByLibrary.simpleMessage("Ontwikkelaarsinstellingen"),
"developerSettingsWarning": MessageLookupByLibrary.simpleMessage(
"Weet je zeker dat je de ontwikkelaarsinstellingen wilt wijzigen?"),
"deviceCodeHint":
MessageLookupByLibrary.simpleMessage("Voer de code in"),
"deviceFilesAutoUploading": MessageLookupByLibrary.simpleMessage(
"Bestanden toegevoegd aan dit album van dit apparaat zullen automatisch geüpload worden naar ente."),
"Bestanden toegevoegd aan dit album van dit apparaat zullen automatisch geüpload worden naar Ente."),
"deviceLockExplanation": MessageLookupByLibrary.simpleMessage(
"Schakel de schermvergrendeling van het apparaat uit wanneer ente op de voorgrond is en er een back-up aan de gang is. Dit is normaal gesproken niet nodig, maar kan grote uploads en initiële imports van grote mappen sneller laten verlopen."),
"Schakel de schermvergrendeling van het apparaat uit wanneer Ente op de voorgrond is en er een back-up aan de gang is. Dit is normaal gesproken niet nodig, maar kan grote uploads en initiële imports van grote mappen sneller laten verlopen."),
"deviceNotFound":
MessageLookupByLibrary.simpleMessage("Apparaat niet gevonden"),
"didYouKnow": MessageLookupByLibrary.simpleMessage("Wist u dat?"),
@ -648,15 +657,17 @@ class MessageLookup extends MessageLookupByLibrary {
"encryption": MessageLookupByLibrary.simpleMessage("Encryptie"),
"encryptionKeys":
MessageLookupByLibrary.simpleMessage("Encryptiesleutels"),
"endpointUpdatedMessage": MessageLookupByLibrary.simpleMessage(
"Eindpunt met succes bijgewerkt"),
"endtoendEncryptedByDefault": MessageLookupByLibrary.simpleMessage(
"Standaard end-to-end versleuteld"),
"enteCanEncryptAndPreserveFilesOnlyIfYouGrant":
MessageLookupByLibrary.simpleMessage(
"ente kan bestanden alleen versleutelen en bewaren als u toegang tot ze geeft"),
"Ente kan bestanden alleen versleutelen en bewaren als u toegang tot ze geeft"),
"entePhotosPerm": MessageLookupByLibrary.simpleMessage(
"ente <i>heeft toestemming nodig om</i> je foto\'s te bewaren"),
"Ente <i>heeft toestemming nodig om</i> je foto\'s te bewaren"),
"enteSubscriptionPitch": MessageLookupByLibrary.simpleMessage(
"ente bewaart uw herinneringen, zodat ze altijd beschikbaar voor u zijn, zelfs als u uw apparaat verliest."),
"Ente bewaart uw herinneringen, zodat ze altijd beschikbaar voor u zijn, zelfs als u uw apparaat verliest."),
"enteSubscriptionShareWithFamily": MessageLookupByLibrary.simpleMessage(
"Je familie kan ook aan je abonnement worden toegevoegd."),
"enterAlbumName":
@ -716,7 +727,7 @@ class MessageLookup extends MessageLookupByLibrary {
"failedToVerifyPaymentStatus": MessageLookupByLibrary.simpleMessage(
"Betalingsstatus verifiëren mislukt"),
"familyPlanOverview": MessageLookupByLibrary.simpleMessage(
"Voeg 5 gezinsleden toe aan uw bestaande abonnement zonder extra te betalen.\n\nElk lid krijgt zijn eigen privé ruimte en kan elkaars bestanden niet zien, tenzij ze zijn gedeeld.\n\nFamilieplannen zijn beschikbaar voor klanten die een betaald ente abonnement hebben.\n\nAbonneer u nu om aan de slag te gaan!"),
"Voeg 5 gezinsleden toe aan je bestaande abonnement zonder extra te betalen.\n\nElk lid krijgt zijn eigen privé ruimte en kan elkaars bestanden niet zien tenzij ze zijn gedeeld.\n\nFamilieplannen zijn beschikbaar voor klanten die een betaald Ente abonnement hebben.\n\nAbonneer nu om aan de slag te gaan!"),
"familyPlanPortalTitle":
MessageLookupByLibrary.simpleMessage("Familie"),
"familyPlans":
@ -777,6 +788,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Wij gebruiken geen tracking. Het zou helpen als je ons vertelt waar je ons gevonden hebt!"),
"hearUsWhereTitle": MessageLookupByLibrary.simpleMessage(
"Hoe hoorde je over Ente? (optioneel)"),
"help": MessageLookupByLibrary.simpleMessage("Hulp"),
"hidden": MessageLookupByLibrary.simpleMessage("Verborgen"),
"hide": MessageLookupByLibrary.simpleMessage("Verbergen"),
"hiding": MessageLookupByLibrary.simpleMessage("Verbergen..."),
@ -792,7 +804,7 @@ class MessageLookup extends MessageLookupByLibrary {
"iOSOkButton": MessageLookupByLibrary.simpleMessage("Oké"),
"ignoreUpdate": MessageLookupByLibrary.simpleMessage("Negeren"),
"ignoredFolderUploadReason": MessageLookupByLibrary.simpleMessage(
"Sommige bestanden in dit album worden genegeerd voor de upload omdat ze eerder van ente zijn verwijderd."),
"Sommige bestanden in dit album worden genegeerd voor uploaden omdat ze eerder van Ente zijn verwijderd."),
"importing": MessageLookupByLibrary.simpleMessage("Importeren...."),
"incorrectCode": MessageLookupByLibrary.simpleMessage("Onjuiste code"),
"incorrectPasswordTitle":
@ -811,16 +823,20 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Installeer handmatig"),
"invalidEmailAddress":
MessageLookupByLibrary.simpleMessage("Ongeldig e-mailadres"),
"invalidEndpoint":
MessageLookupByLibrary.simpleMessage("Ongeldig eindpunt"),
"invalidEndpointMessage": MessageLookupByLibrary.simpleMessage(
"Sorry, het eindpunt dat je hebt ingevoerd is ongeldig. Voer een geldig eindpunt in en probeer het opnieuw."),
"invalidKey": MessageLookupByLibrary.simpleMessage("Ongeldige sleutel"),
"invalidRecoveryKey": MessageLookupByLibrary.simpleMessage(
"De herstelsleutel die je hebt ingevoerd is niet geldig. Zorg ervoor dat deze 24 woorden bevat en controleer de spelling van elk van deze woorden.\n\nAls je een oudere herstelcode hebt ingevoerd, zorg ervoor dat deze 64 tekens lang is, en controleer ze allemaal."),
"invite": MessageLookupByLibrary.simpleMessage("Uitnodigen"),
"inviteToEnte":
MessageLookupByLibrary.simpleMessage("Uitnodigen voor ente"),
MessageLookupByLibrary.simpleMessage("Uitnodigen voor Ente"),
"inviteYourFriends":
MessageLookupByLibrary.simpleMessage("Vrienden uitnodigen"),
"inviteYourFriendsToEnte": MessageLookupByLibrary.simpleMessage(
"Vrienden uitnodigen voor ente"),
"Vrienden uitnodigen voor Ente"),
"itLooksLikeSomethingWentWrongPleaseRetryAfterSome":
MessageLookupByLibrary.simpleMessage(
"Het lijkt erop dat er iets fout is gegaan. Probeer het later opnieuw. Als de fout zich blijft voordoen, neem dan contact op met ons supportteam."),
@ -830,7 +846,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Bestanden tonen het aantal resterende dagen voordat ze permanent worden verwijderd"),
"itemsWillBeRemovedFromAlbum": MessageLookupByLibrary.simpleMessage(
"Geselecteerde items zullen worden verwijderd uit dit album"),
"joinDiscord": MessageLookupByLibrary.simpleMessage("Join Discord"),
"joinDiscord": MessageLookupByLibrary.simpleMessage("Join de Discord"),
"keepPhotos": MessageLookupByLibrary.simpleMessage("Foto\'s behouden"),
"kiloMeterUnit": MessageLookupByLibrary.simpleMessage("km"),
"kindlyHelpUsWithThisInformation": MessageLookupByLibrary.simpleMessage(
@ -888,7 +904,7 @@ class MessageLookup extends MessageLookupByLibrary {
"locationName": MessageLookupByLibrary.simpleMessage("Locatie naam"),
"locationTagFeatureDescription": MessageLookupByLibrary.simpleMessage(
"Een locatie tag groept alle foto\'s die binnen een bepaalde straal van een foto zijn genomen"),
"locations": MessageLookupByLibrary.simpleMessage("Locations"),
"locations": MessageLookupByLibrary.simpleMessage("Locaties"),
"lockButtonLabel": MessageLookupByLibrary.simpleMessage("Vergrendel"),
"lockScreenEnablePreSteps": MessageLookupByLibrary.simpleMessage(
"Om vergrendelscherm in te schakelen, moet u een toegangscode of schermvergrendeling instellen in uw systeeminstellingen."),
@ -902,7 +918,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Dit zal logboeken verzenden om ons te helpen uw probleem op te lossen. Houd er rekening mee dat bestandsnamen zullen worden meegenomen om problemen met specifieke bestanden bij te houden."),
"longPressAnEmailToVerifyEndToEndEncryption":
MessageLookupByLibrary.simpleMessage(
"Long press an email to verify end to end encryption."),
"Druk lang op een e-mail om de versleuteling te verifiëren."),
"longpressOnAnItemToViewInFullscreen": MessageLookupByLibrary.simpleMessage(
"Houd een bestand lang ingedrukt om te bekijken op volledig scherm"),
"lostDevice":
@ -953,7 +969,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Kan geen verbinding maken met Ente, controleer uw netwerkinstellingen en neem contact op met ondersteuning als de fout zich blijft voordoen."),
"never": MessageLookupByLibrary.simpleMessage("Nooit"),
"newAlbum": MessageLookupByLibrary.simpleMessage("Nieuw album"),
"newToEnte": MessageLookupByLibrary.simpleMessage("Nieuw bij ente"),
"newToEnte": MessageLookupByLibrary.simpleMessage("Nieuw bij Ente"),
"newest": MessageLookupByLibrary.simpleMessage("Nieuwste"),
"no": MessageLookupByLibrary.simpleMessage("Nee"),
"noAlbumsSharedByYouYet": MessageLookupByLibrary.simpleMessage(
@ -1007,6 +1023,9 @@ class MessageLookup extends MessageLookupByLibrary {
"orPickAnExistingOne":
MessageLookupByLibrary.simpleMessage("Of kies een bestaande"),
"pair": MessageLookupByLibrary.simpleMessage("Koppelen"),
"passkey": MessageLookupByLibrary.simpleMessage("Passkey"),
"passkeyAuthTitle":
MessageLookupByLibrary.simpleMessage("Passkey verificatie"),
"password": MessageLookupByLibrary.simpleMessage("Wachtwoord"),
"passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage(
"Wachtwoord succesvol aangepast"),
@ -1018,6 +1037,8 @@ class MessageLookup extends MessageLookupByLibrary {
MessageLookupByLibrary.simpleMessage("Betaalgegevens"),
"paymentFailed":
MessageLookupByLibrary.simpleMessage("Betaling mislukt"),
"paymentFailedMessage": MessageLookupByLibrary.simpleMessage(
"Helaas is je betaling mislukt. Neem contact op met support zodat we je kunnen helpen!"),
"paymentFailedTalkToProvider": m37,
"pendingItems":
MessageLookupByLibrary.simpleMessage("Bestanden in behandeling"),
@ -1206,6 +1227,7 @@ class MessageLookup extends MessageLookupByLibrary {
"scanThisBarcodeWithnyourAuthenticatorApp":
MessageLookupByLibrary.simpleMessage(
"Scan deze barcode met\nje authenticator app"),
"search": MessageLookupByLibrary.simpleMessage("Zoeken"),
"searchAlbumsEmptySection":
MessageLookupByLibrary.simpleMessage("Albums"),
"searchByAlbumNameHint":
@ -1253,7 +1275,7 @@ class MessageLookup extends MessageLookupByLibrary {
"selectYourPlan":
MessageLookupByLibrary.simpleMessage("Kies uw abonnement"),
"selectedFilesAreNotOnEnte": MessageLookupByLibrary.simpleMessage(
"Geselecteerde bestanden staan niet op ente"),
"Geselecteerde bestanden staan niet op Ente"),
"selectedFoldersWillBeEncryptedAndBackedUp":
MessageLookupByLibrary.simpleMessage(
"Geselecteerde mappen worden versleuteld en geback-upt"),
@ -1267,6 +1289,8 @@ class MessageLookup extends MessageLookupByLibrary {
"sendInvite":
MessageLookupByLibrary.simpleMessage("Stuur een uitnodiging"),
"sendLink": MessageLookupByLibrary.simpleMessage("Stuur link"),
"serverEndpoint":
MessageLookupByLibrary.simpleMessage("Server eindpunt"),
"sessionExpired":
MessageLookupByLibrary.simpleMessage("Sessie verlopen"),
"setAPassword":
@ -1290,15 +1314,15 @@ class MessageLookup extends MessageLookupByLibrary {
"Deel alleen met de mensen die u wilt"),
"shareTextConfirmOthersVerificationID": m49,
"shareTextRecommendUsingEnte": MessageLookupByLibrary.simpleMessage(
"Download ente zodat we gemakkelijk foto\'s en video\'s van originele kwaliteit kunnen delen\n\nhttps://ente.io"),
"Download Ente zodat we gemakkelijk foto\'s en video\'s in originele kwaliteit kunnen delen\n\nhttps://ente.io"),
"shareTextReferralCode": m50,
"shareWithNonenteUsers": MessageLookupByLibrary.simpleMessage(
"Delen met niet-ente gebruikers"),
"Delen met niet-Ente gebruikers"),
"shareWithPeopleSectionTitle": m51,
"shareYourFirstAlbum":
MessageLookupByLibrary.simpleMessage("Deel jouw eerste album"),
"sharedAlbumSectionDescription": MessageLookupByLibrary.simpleMessage(
"Maak gedeelde en collaboratieve albums met andere ente gebruikers, inclusief gebruikers met gratis abonnementen."),
"Maak gedeelde en collaboratieve albums met andere Ente gebruikers, inclusief gebruikers met gratis abonnementen."),
"sharedByMe": MessageLookupByLibrary.simpleMessage("Gedeeld door mij"),
"sharedByYou": MessageLookupByLibrary.simpleMessage("Gedeeld door jou"),
"sharedPhotoNotifications":
@ -1328,7 +1352,7 @@ class MessageLookup extends MessageLookupByLibrary {
"skip": MessageLookupByLibrary.simpleMessage("Overslaan"),
"social": MessageLookupByLibrary.simpleMessage("Sociale media"),
"someItemsAreInBothEnteAndYourDevice": MessageLookupByLibrary.simpleMessage(
"Sommige bestanden bevinden zich in zowel ente als op uw apparaat."),
"Sommige bestanden bevinden zich zowel in Ente als op jouw apparaat."),
"someOfTheFilesYouAreTryingToDeleteAre":
MessageLookupByLibrary.simpleMessage(
"Sommige bestanden die u probeert te verwijderen zijn alleen beschikbaar op uw apparaat en kunnen niet hersteld worden als deze verwijderd worden"),
@ -1494,9 +1518,8 @@ class MessageLookup extends MessageLookupByLibrary {
"Tot 50% korting, tot 4 december."),
"usableReferralStorageInfo": MessageLookupByLibrary.simpleMessage(
"Bruikbare opslag is beperkt door je huidige abonnement. Buitensporige geclaimde opslag zal automatisch bruikbaar worden wanneer je je abonnement upgrade."),
"usePublicLinksForPeopleNotOnEnte":
MessageLookupByLibrary.simpleMessage(
"Gebruik publieke links voor mensen die niet op ente zitten"),
"usePublicLinksForPeopleNotOnEnte": MessageLookupByLibrary.simpleMessage(
"Gebruik publieke links voor mensen die geen Ente account hebben"),
"useRecoveryKey":
MessageLookupByLibrary.simpleMessage("Herstelcode gebruiken"),
"useSelectedPhoto":
@ -1512,6 +1535,8 @@ class MessageLookup extends MessageLookupByLibrary {
"verifyEmail": MessageLookupByLibrary.simpleMessage("Bevestig e-mail"),
"verifyEmailID": m65,
"verifyIDLabel": MessageLookupByLibrary.simpleMessage("Verifiëren"),
"verifyPasskey":
MessageLookupByLibrary.simpleMessage("Bevestig passkey"),
"verifyPassword":
MessageLookupByLibrary.simpleMessage("Bevestig wachtwoord"),
"verifying": MessageLookupByLibrary.simpleMessage("Verifiëren..."),
@ -1532,6 +1557,8 @@ class MessageLookup extends MessageLookupByLibrary {
"viewer": MessageLookupByLibrary.simpleMessage("Kijker"),
"visitWebToManage": MessageLookupByLibrary.simpleMessage(
"Bezoek alstublieft web.ente.io om uw abonnement te beheren"),
"waitingForVerification":
MessageLookupByLibrary.simpleMessage("Wachten op verificatie..."),
"waitingForWifi":
MessageLookupByLibrary.simpleMessage("Wachten op WiFi..."),
"weAreOpenSource":

View file

@ -77,6 +77,7 @@ class MessageLookup extends MessageLookupByLibrary {
"Modify your query, or try searching for"),
"moveToHiddenAlbum":
MessageLookupByLibrary.simpleMessage("Move to hidden album"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"selectALocation":
MessageLookupByLibrary.simpleMessage("Select a location"),
"selectALocationFirst":

View file

@ -171,6 +171,7 @@ class MessageLookup extends MessageLookupByLibrary {
"resetPasswordTitle":
MessageLookupByLibrary.simpleMessage("Zresetuj hasło"),
"saveKey": MessageLookupByLibrary.simpleMessage("Zapisz klucz"),
"search": MessageLookupByLibrary.simpleMessage("Search"),
"selectALocation":
MessageLookupByLibrary.simpleMessage("Select a location"),
"selectALocationFirst":

View file

@ -1217,6 +1217,7 @@ class MessageLookup extends MessageLookupByLibrary {
"scanThisBarcodeWithnyourAuthenticatorApp":
MessageLookupByLibrary.simpleMessage(
"Escaneie este código de barras com\nseu aplicativo autenticador"),
"search": MessageLookupByLibrary.simpleMessage("Pesquisar"),
"searchAlbumsEmptySection":
MessageLookupByLibrary.simpleMessage("Álbuns"),
"searchByAlbumNameHint":

View file

@ -988,6 +988,7 @@ class MessageLookup extends MessageLookupByLibrary {
"scanCode": MessageLookupByLibrary.simpleMessage("扫描二维码/条码"),
"scanThisBarcodeWithnyourAuthenticatorApp":
MessageLookupByLibrary.simpleMessage("用您的身份验证器应用\n扫描此条码"),
"search": MessageLookupByLibrary.simpleMessage("搜索"),
"searchAlbumsEmptySection": MessageLookupByLibrary.simpleMessage("相册"),
"searchByAlbumNameHint": MessageLookupByLibrary.simpleMessage("相册名称"),
"searchByExamples": MessageLookupByLibrary.simpleMessage(

View file

@ -8553,6 +8553,16 @@ class S {
args: [],
);
}
/// `Search`
String get search {
return Intl.message(
'Search',
name: 'search',
desc: '',
args: [],
);
}
}
class AppLocalizationDelegate extends LocalizationsDelegate<S> {

View file

@ -17,5 +17,6 @@
"addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"createCollaborativeLink": "Create collaborative link"
"createCollaborativeLink": "Create collaborative link",
"search": "Search"
}

View file

@ -1203,5 +1203,6 @@
"addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"createCollaborativeLink": "Create collaborative link"
"createCollaborativeLink": "Create collaborative link",
"search": "Search"
}

View file

@ -1211,5 +1211,6 @@
"invalidEndpointMessage": "Sorry, the endpoint you entered is invalid. Please enter a valid endpoint and try again.",
"endpointUpdatedMessage": "Endpoint updated successfully",
"customEndpoint": "Connected to {endpoint}",
"createCollaborativeLink": "Create collaborative link"
"createCollaborativeLink": "Create collaborative link",
"search": "Search"
}

View file

@ -979,5 +979,6 @@
"addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"createCollaborativeLink": "Create collaborative link"
"createCollaborativeLink": "Create collaborative link",
"search": "Search"
}

View file

@ -1160,5 +1160,6 @@
"addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"createCollaborativeLink": "Create collaborative link"
"createCollaborativeLink": "Create collaborative link",
"search": "Search"
}

View file

@ -1122,5 +1122,6 @@
"addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"createCollaborativeLink": "Create collaborative link"
"createCollaborativeLink": "Create collaborative link",
"search": "Search"
}

View file

@ -17,5 +17,6 @@
"addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"createCollaborativeLink": "Create collaborative link"
"createCollaborativeLink": "Create collaborative link",
"search": "Search"
}

View file

@ -23,7 +23,7 @@
"sendEmail": "E-mail versturen",
"deleteRequestSLAText": "Je verzoek wordt binnen 72 uur verwerkt.",
"deleteEmailRequest": "Stuur een e-mail naar <warning>account-deletion@ente.io</warning> vanaf het door jou geregistreerde e-mailadres.",
"entePhotosPerm": "ente <i>heeft toestemming nodig om</i> je foto's te bewaren",
"entePhotosPerm": "Ente <i>heeft toestemming nodig om</i> je foto's te bewaren",
"ok": "Oké",
"createAccount": "Account aanmaken",
"createNewAccount": "Nieuw account aanmaken",
@ -225,17 +225,17 @@
},
"description": "Number of participants in an album, including the album owner."
},
"collabLinkSectionDescription": "Maak een link waarmee mensen foto's in jouw gedeelde album kunnen toevoegen en bekijken zonder dat ze daarvoor een ente app of account nodig hebben. Handig voor het verzamelen van foto's van evenementen.",
"collabLinkSectionDescription": "Maak een link waarmee mensen foto's in jouw gedeelde album kunnen toevoegen en bekijken zonder dat ze daarvoor een Ente app of account nodig hebben. Handig voor het verzamelen van foto's van evenementen.",
"collectPhotos": "Foto's verzamelen",
"collaborativeLink": "Gezamenlijke link",
"shareWithNonenteUsers": "Delen met niet-ente gebruikers",
"shareWithNonenteUsers": "Delen met niet-Ente gebruikers",
"createPublicLink": "Maak publieke link",
"sendLink": "Stuur link",
"copyLink": "Kopieer link",
"linkHasExpired": "Link is vervallen",
"publicLinkEnabled": "Publieke link ingeschakeld",
"shareALink": "Deel een link",
"sharedAlbumSectionDescription": "Maak gedeelde en collaboratieve albums met andere ente gebruikers, inclusief gebruikers met gratis abonnementen.",
"sharedAlbumSectionDescription": "Maak gedeelde en collaboratieve albums met andere Ente gebruikers, inclusief gebruikers met gratis abonnementen.",
"shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {Deel met specifieke mensen} =1 {Gedeeld met 1 persoon} other {Gedeeld met {numberOfPeople} mensen}}",
"@shareWithPeopleSectionTitle": {
"placeholders": {
@ -259,12 +259,12 @@
},
"verificationId": "Verificatie ID",
"verifyEmailID": "Verifieer {email}",
"emailNoEnteAccount": "{email} heeft geen ente account.\n\nStuur ze een uitnodiging om foto's te delen.",
"emailNoEnteAccount": "{email} heeft geen Ente account.\n\nStuur ze een uitnodiging om foto's te delen.",
"shareMyVerificationID": "Hier is mijn verificatie-ID: {verificationID} voor ente.io.",
"shareTextConfirmOthersVerificationID": "Hey, kunt u bevestigen dat dit uw ente.io verificatie-ID is: {verificationID}",
"somethingWentWrong": "Er ging iets mis",
"sendInvite": "Stuur een uitnodiging",
"shareTextRecommendUsingEnte": "Download ente zodat we gemakkelijk foto's en video's van originele kwaliteit kunnen delen\n\nhttps://ente.io",
"shareTextRecommendUsingEnte": "Download Ente zodat we gemakkelijk foto's en video's in originele kwaliteit kunnen delen\n\nhttps://ente.io",
"done": "Voltooid",
"applyCodeTitle": "Code toepassen",
"enterCodeDescription": "Voer de code van de vriend in om gratis opslag voor jullie beiden te claimen",
@ -281,7 +281,7 @@
"claimMore": "Claim meer!",
"theyAlsoGetXGb": "Zij krijgen ook {storageAmountInGB} GB",
"freeStorageOnReferralSuccess": "{storageAmountInGB} GB telkens als iemand zich aanmeldt voor een betaald abonnement en je code toepast",
"shareTextReferralCode": "ente verwijzingscode: {referralCode} \n\nPas het toe bij Instellingen → Algemeen → Verwijzingen om {referralStorageInGB} GB gratis te krijgen nadat je je hebt aangemeld voor een betaald abonnement\n\nhttps://ente.io",
"shareTextReferralCode": "Ente verwijzingscode: {referralCode} \n\nPas het toe bij Instellingen → Algemeen → Verwijzingen om {referralStorageInGB} GB gratis te krijgen nadat je je hebt aangemeld voor een betaald abonnement\n\nhttps://ente.io",
"claimFreeStorage": "Claim gratis opslag",
"inviteYourFriends": "Vrienden uitnodigen",
"failedToFetchReferralDetails": "Kan geen verwijzingsgegevens ophalen. Probeer het later nog eens.",
@ -304,6 +304,7 @@
}
},
"faq": "Veelgestelde vragen",
"help": "Hulp",
"oopsSomethingWentWrong": "Oeps, er is iets misgegaan",
"peopleUsingYourCode": "Mensen die jouw code gebruiken",
"eligible": "gerechtigd",
@ -333,7 +334,7 @@
"removeParticipantBody": "{userEmail} zal worden verwijderd uit dit gedeelde album\n\nAlle door hen toegevoegde foto's worden ook uit het album verwijderd",
"keepPhotos": "Foto's behouden",
"deletePhotos": "Foto's verwijderen",
"inviteToEnte": "Uitnodigen voor ente",
"inviteToEnte": "Uitnodigen voor Ente",
"removePublicLink": "Verwijder publieke link",
"disableLinkMessage": "Dit verwijdert de openbare link voor toegang tot \"{albumName}\".",
"sharing": "Delen...",
@ -349,10 +350,10 @@
"videoSmallCase": "video",
"photoSmallCase": "foto",
"singleFileDeleteHighlight": "Het wordt uit alle albums verwijderd.",
"singleFileInBothLocalAndRemote": "Deze {fileType} staat zowel in ente als op jouw apparaat.",
"singleFileInRemoteOnly": "Deze {fileType} zal worden verwijderd uit ente.",
"singleFileInBothLocalAndRemote": "Deze {fileType} staat zowel in Ente als op jouw apparaat.",
"singleFileInRemoteOnly": "Deze {fileType} zal worden verwijderd uit Ente.",
"singleFileDeleteFromDevice": "Deze {fileType} zal worden verwijderd van jouw apparaat.",
"deleteFromEnte": "Verwijder van ente",
"deleteFromEnte": "Verwijder van Ente",
"yesDelete": "Ja, verwijderen",
"movedToTrash": "Naar prullenbak verplaatst",
"deleteFromDevice": "Verwijder van apparaat",
@ -444,7 +445,7 @@
"backupOverMobileData": "Back-up maken via mobiele data",
"backupVideos": "Back-up video's",
"disableAutoLock": "Automatisch vergrendelen uitschakelen",
"deviceLockExplanation": "Schakel de schermvergrendeling van het apparaat uit wanneer ente op de voorgrond is en er een back-up aan de gang is. Dit is normaal gesproken niet nodig, maar kan grote uploads en initiële imports van grote mappen sneller laten verlopen.",
"deviceLockExplanation": "Schakel de schermvergrendeling van het apparaat uit wanneer Ente op de voorgrond is en er een back-up aan de gang is. Dit is normaal gesproken niet nodig, maar kan grote uploads en initiële imports van grote mappen sneller laten verlopen.",
"about": "Over",
"weAreOpenSource": "We zijn open source!",
"privacy": "Privacy",
@ -464,7 +465,7 @@
"authToInitiateAccountDeletion": "Gelieve te verifiëren om het verwijderen van je account te starten",
"areYouSureYouWantToLogout": "Weet je zeker dat je wilt uitloggen?",
"yesLogout": "Ja, log uit",
"aNewVersionOfEnteIsAvailable": "Er is een nieuwe versie van ente beschikbaar.",
"aNewVersionOfEnteIsAvailable": "Er is een nieuwe versie van Ente beschikbaar.",
"update": "Update",
"installManually": "Installeer handmatig",
"criticalUpdateAvailable": "Belangrijke update beschikbaar",
@ -553,11 +554,11 @@
"systemTheme": "Systeem",
"freeTrial": "Gratis proefversie",
"selectYourPlan": "Kies uw abonnement",
"enteSubscriptionPitch": "ente bewaart uw herinneringen, zodat ze altijd beschikbaar voor u zijn, zelfs als u uw apparaat verliest.",
"enteSubscriptionPitch": "Ente bewaart uw herinneringen, zodat ze altijd beschikbaar voor u zijn, zelfs als u uw apparaat verliest.",
"enteSubscriptionShareWithFamily": "Je familie kan ook aan je abonnement worden toegevoegd.",
"currentUsageIs": "Huidig gebruik is ",
"@currentUsageIs": {
"description": "This text is followed by storage usaged",
"description": "This text is followed by storage usage",
"examples": {
"0": "Current usage is 1.2 GB"
},
@ -619,7 +620,7 @@
"appleId": "Apple ID",
"playstoreSubscription": "PlayStore abonnement",
"appstoreSubscription": "PlayStore abonnement",
"subAlreadyLinkedErrMessage": "Uw {id} is al aan een ander ente account gekoppeld.\nAls u uw {id} wilt gebruiken met dit account, neem dan contact op met onze klantenservice",
"subAlreadyLinkedErrMessage": "Jouw {id} is al aan een ander Ente account gekoppeld.\nAls je jouw {id} wilt gebruiken met dit account, neem dan contact op met onze klantenservice",
"visitWebToManage": "Bezoek alstublieft web.ente.io om uw abonnement te beheren",
"couldNotUpdateSubscription": "Kon abonnement niet wijzigen",
"pleaseContactSupportAndWeWillBeHappyToHelp": "Neem alstublieft contact op met support@ente.io en we helpen u graag!",
@ -640,7 +641,7 @@
"thankYou": "Bedankt",
"failedToVerifyPaymentStatus": "Betalingsstatus verifiëren mislukt",
"pleaseWaitForSometimeBeforeRetrying": "Gelieve even te wachten voordat u opnieuw probeert",
"paymentFailedWithReason": "Helaas is uw betaling mislukt vanwege {reason}",
"paymentFailedMessage": "Helaas is je betaling mislukt. Neem contact op met support zodat we je kunnen helpen!",
"youAreOnAFamilyPlan": "U bent onderdeel van een familie abonnement!",
"contactFamilyAdmin": "Neem contact op met <green>{familyAdminEmail}</green> om uw abonnement te beheren",
"leaveFamily": "Familie abonnement verlaten",
@ -664,7 +665,7 @@
"everywhere": "overal",
"androidIosWebDesktop": "Android, iOS, Web, Desktop",
"mobileWebDesktop": "Mobiel, Web, Desktop",
"newToEnte": "Nieuw bij ente",
"newToEnte": "Nieuw bij Ente",
"pleaseLoginAgain": "Log opnieuw in",
"devAccountChanged": "Het ontwikkelaarsaccount dat we gebruiken om te publiceren in de App Store is veranderd. Daarom moet je opnieuw inloggen.\n\nOnze excuses voor het ongemak, helaas was dit onvermijdelijk.",
"yourSubscriptionHasExpired": "Uw abonnement is verlopen",
@ -677,12 +678,12 @@
},
"backupFailed": "Back-up mislukt",
"couldNotBackUpTryLater": "We konden uw gegevens niet back-uppen.\nWe zullen het later opnieuw proberen.",
"enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "ente kan bestanden alleen versleutelen en bewaren als u toegang tot ze geeft",
"enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "Ente kan bestanden alleen versleutelen en bewaren als u toegang tot ze geeft",
"pleaseGrantPermissions": "Geef alstublieft toestemming",
"grantPermission": "Toestemming verlenen",
"privateSharing": "Privé delen",
"shareOnlyWithThePeopleYouWant": "Deel alleen met de mensen die u wilt",
"usePublicLinksForPeopleNotOnEnte": "Gebruik publieke links voor mensen die niet op ente zitten",
"usePublicLinksForPeopleNotOnEnte": "Gebruik publieke links voor mensen die geen Ente account hebben",
"allowPeopleToAddPhotos": "Mensen toestaan foto's toe te voegen",
"shareAnAlbumNow": "Deel nu een album",
"collectEventPhotos": "Foto's van gebeurtenissen verzamelen",
@ -694,7 +695,7 @@
},
"onDevice": "Op het apparaat",
"@onEnte": {
"description": "The text displayed above albums backed up to ente",
"description": "The text displayed above albums backed up to Ente",
"type": "text"
},
"onEnte": "Op <branding>ente</branding>",
@ -740,7 +741,7 @@
"saveCollage": "Sla collage op",
"collageSaved": "Collage opgeslagen in gallerij",
"collageLayout": "Layout",
"addToEnte": "Toevoegen aan ente",
"addToEnte": "Toevoegen aan Ente",
"addToAlbum": "Toevoegen aan album",
"delete": "Verwijderen",
"hide": "Verbergen",
@ -805,9 +806,9 @@
"photosAddedByYouWillBeRemovedFromTheAlbum": "Foto's toegevoegd door u zullen worden verwijderd uit het album",
"youveNoFilesInThisAlbumThatCanBeDeleted": "Je hebt geen bestanden in dit album die verwijderd kunnen worden",
"youDontHaveAnyArchivedItems": "U heeft geen gearchiveerde bestanden.",
"ignoredFolderUploadReason": "Sommige bestanden in dit album worden genegeerd voor de upload omdat ze eerder van ente zijn verwijderd.",
"ignoredFolderUploadReason": "Sommige bestanden in dit album worden genegeerd voor uploaden omdat ze eerder van Ente zijn verwijderd.",
"resetIgnoredFiles": "Reset genegeerde bestanden",
"deviceFilesAutoUploading": "Bestanden toegevoegd aan dit album van dit apparaat zullen automatisch geüpload worden naar ente.",
"deviceFilesAutoUploading": "Bestanden toegevoegd aan dit album van dit apparaat zullen automatisch geüpload worden naar Ente.",
"turnOnBackupForAutoUpload": "Schakel back-up in om bestanden die toegevoegd zijn aan deze map op dit apparaat automatisch te uploaden.",
"noHiddenPhotosOrVideos": "Geen verborgen foto's of video's",
"toHideAPhotoOrVideo": "Om een foto of video te verbergen",
@ -885,7 +886,7 @@
"@freeUpSpaceSaving": {
"description": "Text to tell user how much space they can free up by deleting items from the device"
},
"freeUpAccessPostDelete": "U heeft nog steeds toegang tot {count, plural, one {het} other {ze}} op ente zolang u een actief abonnement heeft",
"freeUpAccessPostDelete": "Je hebt nog steeds toegang tot {count, plural, one {het} other {ze}} op Ente zolang je een actief abonnement hebt",
"@freeUpAccessPostDelete": {
"placeholders": {
"count": {
@ -936,7 +937,7 @@
"renameFile": "Bestandsnaam wijzigen",
"enterFileName": "Geef bestandsnaam op",
"filesDeleted": "Bestanden verwijderd",
"selectedFilesAreNotOnEnte": "Geselecteerde bestanden staan niet op ente",
"selectedFilesAreNotOnEnte": "Geselecteerde bestanden staan niet op Ente",
"thisActionCannotBeUndone": "Deze actie kan niet ongedaan gemaakt worden",
"emptyTrash": "Prullenbak leegmaken?",
"permDeleteWarning": "Alle bestanden in de prullenbak zullen permanent worden verwijderd\n\nDeze actie kan niet ongedaan worden gemaakt",
@ -945,7 +946,7 @@
"permanentlyDeleteFromDevice": "Permanent verwijderen van apparaat?",
"someOfTheFilesYouAreTryingToDeleteAre": "Sommige bestanden die u probeert te verwijderen zijn alleen beschikbaar op uw apparaat en kunnen niet hersteld worden als deze verwijderd worden",
"theyWillBeDeletedFromAllAlbums": "Ze zullen uit alle albums worden verwijderd.",
"someItemsAreInBothEnteAndYourDevice": "Sommige bestanden bevinden zich in zowel ente als op uw apparaat.",
"someItemsAreInBothEnteAndYourDevice": "Sommige bestanden bevinden zich zowel in Ente als op jouw apparaat.",
"selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Geselecteerde bestanden worden verwijderd uit alle albums en verplaatst naar de prullenbak.",
"theseItemsWillBeDeletedFromYourDevice": "Deze bestanden zullen worden verwijderd van uw apparaat.",
"itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Het lijkt erop dat er iets fout is gegaan. Probeer het later opnieuw. Als de fout zich blijft voordoen, neem dan contact op met ons supportteam.",
@ -1051,7 +1052,7 @@
},
"setRadius": "Radius instellen",
"familyPlanPortalTitle": "Familie",
"familyPlanOverview": "Voeg 5 gezinsleden toe aan uw bestaande abonnement zonder extra te betalen.\n\nElk lid krijgt zijn eigen privé ruimte en kan elkaars bestanden niet zien, tenzij ze zijn gedeeld.\n\nFamilieplannen zijn beschikbaar voor klanten die een betaald ente abonnement hebben.\n\nAbonneer u nu om aan de slag te gaan!",
"familyPlanOverview": "Voeg 5 gezinsleden toe aan je bestaande abonnement zonder extra te betalen.\n\nElk lid krijgt zijn eigen privé ruimte en kan elkaars bestanden niet zien tenzij ze zijn gedeeld.\n\nFamilieplannen zijn beschikbaar voor klanten die een betaald Ente abonnement hebben.\n\nAbonneer nu om aan de slag te gaan!",
"androidBiometricHint": "Identiteit verifiëren",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
@ -1129,7 +1130,7 @@
"noAlbumsSharedByYouYet": "Nog geen albums gedeeld door jou",
"sharedWithYou": "Gedeeld met jou",
"sharedByYou": "Gedeeld door jou",
"inviteYourFriendsToEnte": "Vrienden uitnodigen voor ente",
"inviteYourFriendsToEnte": "Vrienden uitnodigen voor Ente",
"failedToDownloadVideo": "Downloaden van video mislukt",
"hiding": "Verbergen...",
"unhiding": "Zichtbaar maken...",
@ -1139,7 +1140,7 @@
"addToHiddenAlbum": "Toevoegen aan verborgen album",
"moveToHiddenAlbum": "Verplaatsen naar verborgen album",
"fileTypes": "Bestandstype",
"deleteConfirmDialogBody": "Dit account is gekoppeld aan andere ente apps, als je er gebruik van maakt.\\n\\nJe geüploade gegevens worden in alle ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle ente diensten.",
"deleteConfirmDialogBody": "Dit account is gekoppeld aan andere Ente apps, als je er gebruik van maakt. Je geüploade gegevens worden in alle Ente apps gepland voor verwijdering, en je account wordt permanent verwijderd voor alle Ente diensten.",
"hearUsWhereTitle": "Hoe hoorde je over Ente? (optioneel)",
"hearUsExplanation": "Wij gebruiken geen tracking. Het zou helpen als je ons vertelt waar je ons gevonden hebt!",
"viewAddOnButton": "Add-ons bekijken",
@ -1187,16 +1188,29 @@
"changeLocationOfSelectedItems": "Locatie van geselecteerde items wijzigen?",
"editsToLocationWillOnlyBeSeenWithinEnte": "Bewerkte locatie wordt alleen gezien binnen Ente",
"cleanUncategorized": "Ongecategoriseerd opschonen",
"cleanUncategorizedDescription": "Verwijder alle bestanden van Ongecategoriseerd die aanwezig zijn in andere albums",
"waitingForVerification": "Wachten op verificatie...",
"passkey": "Passkey",
"passkeyAuthTitle": "Passkey verificatie",
"verifyPasskey": "Bevestig passkey",
"playOnTv": "Album afspelen op TV",
"pair": "Koppelen",
"deviceNotFound": "Apparaat niet gevonden",
"castInstruction": "Bezoek cast.ente.io op het apparaat dat u wilt koppelen.\n\nVoer de code hieronder in om het album op uw TV af te spelen.",
"deviceCodeHint": "Voer de code in",
"joinDiscord": "Join Discord",
"locations": "Locations",
"descriptions": "Descriptions",
"addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"createCollaborativeLink": "Create collaborative link"
"joinDiscord": "Join de Discord",
"locations": "Locaties",
"descriptions": "Beschrijvingen",
"addViewers": "{count, plural, one {Voeg kijker toe} other {Voeg kijkers toe}}",
"addCollaborators": "{count, plural, zero {Voeg samenwerker toe} one {Voeg samenwerker toe} other {Voeg samenwerkers toe}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Druk lang op een e-mail om de versleuteling te verifiëren.",
"developerSettingsWarning": "Weet je zeker dat je de ontwikkelaarsinstellingen wilt wijzigen?",
"developerSettings": "Ontwikkelaarsinstellingen",
"serverEndpoint": "Server eindpunt",
"invalidEndpoint": "Ongeldig eindpunt",
"invalidEndpointMessage": "Sorry, het eindpunt dat je hebt ingevoerd is ongeldig. Voer een geldig eindpunt in en probeer het opnieuw.",
"endpointUpdatedMessage": "Eindpunt met succes bijgewerkt",
"customEndpoint": "Verbonden met {endpoint}",
"createCollaborativeLink": "Maak een gezamenlijke link",
"search": "Zoeken"
}

View file

@ -31,5 +31,6 @@
"addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"createCollaborativeLink": "Create collaborative link"
"createCollaborativeLink": "Create collaborative link",
"search": "Search"
}

View file

@ -118,5 +118,6 @@
"addViewers": "{count, plural, zero {Add viewer} one {Add viewer} other {Add viewers}}",
"addCollaborators": "{count, plural, zero {Add collaborator} one {Add collaborator} other {Add collaborators}}",
"longPressAnEmailToVerifyEndToEndEncryption": "Long press an email to verify end to end encryption.",
"createCollaborativeLink": "Create collaborative link"
"createCollaborativeLink": "Create collaborative link",
"search": "Search"
}

View file

@ -1211,5 +1211,6 @@
"invalidEndpointMessage": "Desculpe, o endpoint que você inseriu é inválido. Por favor, insira um endpoint válido e tente novamente.",
"endpointUpdatedMessage": "Endpoint atualizado com sucesso",
"customEndpoint": "Conectado a {endpoint}",
"createCollaborativeLink": "Criar link colaborativo"
"createCollaborativeLink": "Criar link colaborativo",
"search": "Pesquisar"
}

View file

@ -1211,5 +1211,6 @@
"invalidEndpointMessage": "抱歉,您输入的端点无效。请输入有效的端点,然后重试。",
"endpointUpdatedMessage": "端点更新成功",
"customEndpoint": "已连接至 {endpoint}",
"createCollaborativeLink": "创建协作链接"
"createCollaborativeLink": "创建协作链接",
"search": "搜索"
}

View file

@ -59,9 +59,9 @@ class _RecoveryPageState extends State<RecoveryPage> {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (BuildContext context) {
return WillPopScope(
onWillPop: () async => false,
child: const PasswordEntryPage(
return const PopScope(
canPop: false,
child: PasswordEntryPage(
mode: PasswordEntryMode.reset,
),
);

View file

@ -27,8 +27,8 @@ class LinearProgressDialogState extends State<LinearProgressDialog> {
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async => false,
return PopScope(
canPop: false,
child: AlertDialog(
title: Text(
widget.message,

View file

@ -155,8 +155,8 @@ class ProgressDialog {
barrierColor: _barrierColor,
builder: (BuildContext context) {
_dismissingContext = context;
return WillPopScope(
onWillPop: () async => _barrierDismissible,
return PopScope(
canPop: _barrierDismissible,
child: Dialog(
backgroundColor: _backgroundColor,
insetAnimationCurve: _insetAnimCurve,

View file

@ -52,8 +52,15 @@ class _PaymentWebPageState extends State<PaymentWebPage> {
if (initPaymentUrl == null) {
return const EnteLoadingWidget();
}
return WillPopScope(
onWillPop: (() async => _buildPageExitWidget(context)),
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
if (didPop) return;
final shouldPop = await _buildPageExitWidget(context);
if (shouldPop) {
Navigator.of(context).pop();
}
},
child: Scaffold(
appBar: AppBar(
title: Text(S.of(context).subscription),

View file

@ -83,8 +83,8 @@ class _AppUpdateDialogState extends State<AppUpdateDialog> {
);
final shouldForceUpdate =
UpdateService.instance.shouldForceUpdate(widget.latestVersionInfo!);
return WillPopScope(
onWillPop: () async => !shouldForceUpdate,
return PopScope(
canPop: !shouldForceUpdate,
child: AlertDialog(
key: const ValueKey("updateAppDialog"),
title: Column(

View file

@ -315,7 +315,23 @@ class _HomeWidgetState extends State<HomeWidget> {
final enableDrawer = LocalSyncService.instance.hasCompletedFirstImport();
final action = AppLifecycleService.instance.mediaExtensionAction.action;
return UserDetailsStateWidget(
child: WillPopScope(
child: PopScope(
canPop: false,
onPopInvoked: (didPop) async {
if (didPop) return;
if (_selectedTabIndex == 0) {
if (isSettingsOpen) {
Navigator.pop(context);
} else if (Platform.isAndroid && action == IntentAction.main) {
unawaited(MoveToBackground.moveTaskToBack());
} else {
Navigator.pop(context);
}
} else {
Bus.instance
.fire(TabChangedEvent(0, TabChangedEventSource.backButton));
}
},
child: Scaffold(
drawerScrimColor: getEnteColorScheme(context).strokeFainter,
drawerEnableOpenDragGesture: false,
@ -341,24 +357,6 @@ class _HomeWidgetState extends State<HomeWidget> {
),
resizeToAvoidBottomInset: false,
),
onWillPop: () async {
if (_selectedTabIndex == 0) {
if (isSettingsOpen) {
Navigator.pop(context);
return false;
}
if (Platform.isAndroid && action == IntentAction.main) {
unawaited(MoveToBackground.moveTaskToBack());
return false;
} else {
return true;
}
} else {
Bus.instance
.fire(TabChangedEvent(0, TabChangedEventSource.backButton));
return false;
}
},
),
);
}

View file

@ -137,9 +137,9 @@ class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
}
Widget get _lockScreen {
return WillPopScope(
return PopScope(
canPop: false,
child: this.widget.lockScreen,
onWillPop: () => Future.value(false),
);
}

View file

@ -63,14 +63,14 @@ class _ImageEditorPageState extends State<ImageEditorPage> {
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
return PopScope(
canPop: false,
onPopInvoked: (didPop) async {
if (_hasBeenEdited()) {
await _showExitConfirmationDialog(context);
} else {
replacePage(context, DetailPage(widget.detailPageConfig));
}
return false;
},
child: Scaffold(
appBar: AppBar(

View file

@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/cupertino.dart';
import "package:flutter/foundation.dart";
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:media_extension/media_extension.dart';
@ -53,47 +54,70 @@ class FileAppBar extends StatefulWidget {
class FileAppBarState extends State<FileAppBar> {
final _logger = Logger("FadingAppBar");
final List<Widget> _actions = [];
@override
void didUpdateWidget(FileAppBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.file.generatedID != widget.file.generatedID) {
_getActions();
}
}
@override
Widget build(BuildContext context) {
_logger.fine("building app bar ${widget.file.generatedID?.toString()}");
//When the widget is initialized, the actions are not available.
//Cannot call _getActions() in initState.
if (_actions.isEmpty) {
_getActions();
}
final isTrashedFile = widget.file is TrashFile;
final shouldShowActions = widget.shouldShowActions && !isTrashedFile;
return CustomAppBar(
ValueListenableBuilder(
valueListenable: widget.enableFullScreenNotifier,
builder: (context, bool isFullScreen, _) {
builder: (context, bool isFullScreen, child) {
return IgnorePointer(
ignoring: isFullScreen,
child: AnimatedOpacity(
opacity: isFullScreen ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.72),
Colors.black.withOpacity(0.6),
Colors.transparent,
],
stops: const [0, 0.2, 1],
),
),
child: _buildAppBar(),
),
child: child,
),
);
},
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.72),
Colors.black.withOpacity(0.6),
Colors.transparent,
],
stops: const [0, 0.2, 1],
),
),
child: AppBar(
iconTheme: const IconThemeData(
color: Colors.white,
), //same for both themes
actions: shouldShowActions ? _actions : [],
elevation: 0,
backgroundColor: const Color(0x00000000),
),
),
),
Size.fromHeight(Platform.isAndroid ? 84 : 96),
);
}
AppBar _buildAppBar() {
_logger.fine("building app bar ${widget.file.generatedID?.toString()}");
final List<Widget> actions = [];
final isTrashedFile = widget.file is TrashFile;
final shouldShowActions = widget.shouldShowActions && !isTrashedFile;
List<Widget> _getActions() {
_actions.clear();
final bool isOwnedByUser = widget.file.isOwner;
final bool isFileUploaded = widget.file.isUploaded;
bool isFileHidden = false;
@ -104,7 +128,7 @@ class FileAppBarState extends State<FileAppBar> {
false;
}
if (widget.file.isLiveOrMotionPhoto) {
actions.add(
_actions.add(
IconButton(
icon: const Icon(Icons.album_outlined),
onPressed: () {
@ -117,8 +141,8 @@ class FileAppBarState extends State<FileAppBar> {
);
}
// only show fav option for files owned by the user
if (!isFileHidden && isFileUploaded) {
actions.add(
if ((isOwnedByUser || kDebugMode) && !isFileHidden && isFileUploaded) {
_actions.add(
Padding(
padding: const EdgeInsets.all(8),
child: FavoriteWidget(widget.file),
@ -126,7 +150,7 @@ class FileAppBarState extends State<FileAppBar> {
);
}
if (!isFileUploaded) {
actions.add(
_actions.add(
UploadIconWidget(
file: widget.file,
key: ValueKey(widget.file.tag),
@ -241,7 +265,7 @@ class FileAppBarState extends State<FileAppBar> {
}
}
if (items.isNotEmpty) {
actions.add(
_actions.add(
PopupMenuButton(
itemBuilder: (context) {
return items;
@ -262,13 +286,7 @@ class FileAppBarState extends State<FileAppBar> {
),
);
}
return AppBar(
iconTheme:
const IconThemeData(color: Colors.white), //same for both themes
actions: shouldShowActions ? actions : [],
elevation: 0,
backgroundColor: const Color(0x00000000),
);
return _actions;
}
Future<void> _handleHideRequest(BuildContext context) async {

View file

@ -5,6 +5,7 @@ import "package:flutter/scheduler.dart";
import "package:photos/core/event_bus.dart";
import "package:photos/events/clear_and_unfocus_search_bar_event.dart";
import "package:photos/events/tab_changed_event.dart";
import "package:photos/generated/l10n.dart";
import "package:photos/models/search/index_of_indexed_stack.dart";
import "package:photos/models/search/search_result.dart";
import "package:photos/services/search_service.dart";
@ -130,17 +131,14 @@ class SearchWidgetState extends State<SearchWidget> {
color: colorScheme.backgroundBase,
child: Container(
color: colorScheme.fillFaint,
child: TextFormField(
child: TextField(
controller: textController,
focusNode: focusNode,
style: Theme.of(context).textTheme.titleMedium,
// Below parameters are to disable auto-suggestion
enableSuggestions: false,
autocorrect: false,
// Above parameters are to disable auto-suggestion
decoration: InputDecoration(
//TODO: Extract string
hintText: "Search",
hintText: S.of(context).search,
filled: true,
fillColor: getEnteColorScheme(context).fillFaint,
border: const UnderlineInputBorder(
@ -161,6 +159,9 @@ class SearchWidgetState extends State<SearchWidget> {
minHeight: 44,
minWidth: 44,
),
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
),
prefixIcon: Hero(
tag: "search_icon",
child: Icon(
@ -168,6 +169,7 @@ class SearchWidgetState extends State<SearchWidget> {
color: colorScheme.strokeFaint,
),
),
/*Using valueListenableBuilder inside a stateful widget because this widget is only rebuild when
setState is called when deboucncing is over and the spinner needs to be shown while debouncing */
suffixIcon: ValueListenableBuilder(

View file

@ -59,25 +59,41 @@ const (
DeletedObjectQueueLock = "deleted_objects_queue_lock"
)
// Create adds an entry for a file in the respective tables
func (c *FileController) Create(ctx context.Context, userID int64, file ente.File, userAgent string, app ente.App) (ente.File, error) {
func (c *FileController) validateFileCreateOrUpdateReq(userID int64, file ente.File) error {
objectPathPrefix := strconv.FormatInt(userID, 10) + "/"
if !strings.HasPrefix(file.File.ObjectKey, objectPathPrefix) || !strings.HasPrefix(file.Thumbnail.ObjectKey, objectPathPrefix) {
return file, stacktrace.Propagate(ente.ErrBadRequest, "Incorrect object key reported")
return stacktrace.Propagate(ente.ErrBadRequest, "Incorrect object key reported")
}
if file.EncryptedKey == "" || file.KeyDecryptionNonce == "" {
return stacktrace.Propagate(ente.ErrBadRequest, "EncryptedKey and KeyDecryptionNonce are required")
}
if file.File.DecryptionHeader == "" || file.Thumbnail.DecryptionHeader == "" {
return stacktrace.Propagate(ente.ErrBadRequest, "DecryptionHeader for file & thumb is required")
}
if file.UpdationTime == 0 {
return stacktrace.Propagate(ente.ErrBadRequest, "UpdationTime is required")
}
collection, err := c.CollectionRepo.Get(file.CollectionID)
if err != nil {
return file, stacktrace.Propagate(err, "")
return stacktrace.Propagate(err, "")
}
// Verify that user owns the collection.
// Warning: Do not remove this check
if collection.Owner.ID != userID || file.OwnerID != userID {
return file, stacktrace.Propagate(ente.ErrPermissionDenied, "")
return stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
if collection.IsDeleted {
return file, stacktrace.Propagate(ente.ErrNotFound, "collection has been deleted")
return stacktrace.Propagate(ente.ErrNotFound, "collection has been deleted")
}
return nil
}
// Create adds an entry for a file in the respective tables
func (c *FileController) Create(ctx context.Context, userID int64, file ente.File, userAgent string, app ente.App) (ente.File, error) {
err := c.validateFileCreateOrUpdateReq(userID, file)
if err != nil {
return file, stacktrace.Propagate(err, "")
}
hotDC := c.S3Config.GetHotDataCenter()
// sizeOf will do also HEAD check to ensure that the object exists in the
// current hot DC
@ -115,7 +131,7 @@ func (c *FileController) Create(ctx context.Context, userID int64, file ente.Fil
// all iz well
var usage int64
file, usage, err = c.FileRepo.Create(file, fileSize, thumbnailSize, fileSize+thumbnailSize, collection.Owner.ID, app)
file, usage, err = c.FileRepo.Create(file, fileSize, thumbnailSize, fileSize+thumbnailSize, userID, app)
if err != nil {
if err == ente.ErrDuplicateFileObjectFound || err == ente.ErrDuplicateThumbnailObjectFound {
var existing ente.File
@ -144,9 +160,9 @@ func (c *FileController) Create(ctx context.Context, userID int64, file ente.Fil
// Update verifies permissions and updates the specified file
func (c *FileController) Update(ctx context.Context, userID int64, file ente.File, app ente.App) (ente.UpdateFileResponse, error) {
var response ente.UpdateFileResponse
objectPathPrefix := strconv.FormatInt(userID, 10) + "/"
if !strings.HasPrefix(file.File.ObjectKey, objectPathPrefix) || !strings.HasPrefix(file.Thumbnail.ObjectKey, objectPathPrefix) {
return response, stacktrace.Propagate(ente.ErrBadRequest, "Incorrect object key reported")
err := c.validateFileCreateOrUpdateReq(userID, file)
if err != nil {
return response, stacktrace.Propagate(err, "")
}
ownerID, err := c.FileRepo.GetOwnerID(file.ID)
if err != nil {

View file

@ -3,11 +3,11 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@/media": "*",
"@/next": "*",
"@ente/accounts": "*",
"@ente/eslint-config": "*",
"@ente/shared": "*",
"jszip": "3.10.1",
"mime-types": "^2.1.35"
}
}

View file

@ -1,50 +1,24 @@
import { SlideshowContext } from "pages/slideshow";
import { useContext, useEffect, useState } from "react";
import { useEffect } from "react";
export default function PhotoAuditorium({
url,
nextSlideUrl,
}: {
interface PhotoAuditoriumProps {
url: string;
nextSlideUrl: string;
}) {
const { showNextSlide } = useContext(SlideshowContext);
const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false);
const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false);
const [prerenderTime, setPrerenderTime] = useState<number | null>(null);
showNextSlide: () => void;
}
export const PhotoAuditorium: React.FC<PhotoAuditoriumProps> = ({
url,
nextSlideUrl,
showNextSlide,
}) => {
useEffect(() => {
let timeout: NodeJS.Timeout;
let timeout2: NodeJS.Timeout;
if (nextSlidePrerendered) {
const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0;
const delayTime = Math.max(10000 - elapsedTime, 0);
if (elapsedTime >= 10000) {
setShowPreloadedNextSlide(true);
} else {
timeout = setTimeout(() => {
setShowPreloadedNextSlide(true);
}, delayTime);
}
if (showNextSlide) {
timeout2 = setTimeout(() => {
showNextSlide();
setNextSlidePrerendered(false);
setPrerenderTime(null);
setShowPreloadedNextSlide(false);
}, delayTime);
}
}
const timeoutId = window.setTimeout(() => {
showNextSlide();
}, 10000);
return () => {
if (timeout) clearTimeout(timeout);
if (timeout2) clearTimeout(timeout2);
if (timeoutId) clearTimeout(timeoutId);
};
}, [nextSlidePrerendered, showNextSlide, prerenderTime]);
}, [showNextSlide]);
return (
<div
@ -69,27 +43,22 @@ export default function PhotoAuditorium({
backdropFilter: "blur(10px)",
}}
>
<img
src={url}
style={{
maxWidth: "100%",
maxHeight: "100%",
display: showPreloadedNextSlide ? "none" : "block",
}}
/>
<img
src={nextSlideUrl}
style={{
maxWidth: "100%",
maxHeight: "100%",
display: showPreloadedNextSlide ? "block" : "none",
display: "none",
}}
onLoad={() => {
setNextSlidePrerendered(true);
setPrerenderTime(Date.now());
/>
<img
src={url}
style={{
maxWidth: "100%",
maxHeight: "100%",
}}
/>
</div>
</div>
);
}
};

View file

@ -1,95 +0,0 @@
import { SlideshowContext } from "pages/slideshow";
import { useContext, useEffect, useState } from "react";
export default function PhotoAuditorium({
url,
nextSlideUrl,
}: {
url: string;
nextSlideUrl: string;
}) {
const { showNextSlide } = useContext(SlideshowContext);
const [showPreloadedNextSlide, setShowPreloadedNextSlide] = useState(false);
const [nextSlidePrerendered, setNextSlidePrerendered] = useState(false);
const [prerenderTime, setPrerenderTime] = useState<number | null>(null);
useEffect(() => {
let timeout: NodeJS.Timeout;
let timeout2: NodeJS.Timeout;
if (nextSlidePrerendered) {
const elapsedTime = prerenderTime ? Date.now() - prerenderTime : 0;
const delayTime = Math.max(10000 - elapsedTime, 0);
if (elapsedTime >= 10000) {
setShowPreloadedNextSlide(true);
} else {
timeout = setTimeout(() => {
setShowPreloadedNextSlide(true);
}, delayTime);
}
if (showNextSlide) {
timeout2 = setTimeout(() => {
showNextSlide();
setNextSlidePrerendered(false);
setPrerenderTime(null);
setShowPreloadedNextSlide(false);
}, delayTime);
}
}
return () => {
if (timeout) clearTimeout(timeout);
if (timeout2) clearTimeout(timeout2);
};
}, [nextSlidePrerendered, showNextSlide, prerenderTime]);
return (
<div
style={{
width: "100vw",
height: "100vh",
backgroundImage: `url(${url})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundBlendMode: "multiply",
backgroundColor: "rgba(0, 0, 0, 0.5)",
}}
>
<div
style={{
height: "100%",
width: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backdropFilter: "blur(10px)",
}}
>
<img
src={url}
style={{
maxWidth: "100%",
maxHeight: "100%",
display: showPreloadedNextSlide ? "none" : "block",
}}
/>
<img
src={nextSlideUrl}
style={{
maxWidth: "100%",
maxHeight: "100%",
display: showPreloadedNextSlide ? "block" : "none",
}}
onLoad={() => {
setNextSlidePrerendered(true);
setPrerenderTime(Date.now());
}}
/>
</div>
</div>
);
}

View file

@ -1,55 +0,0 @@
import mime from "mime-types";
import { SlideshowContext } from "pages/slideshow";
import { useContext, useEffect, useRef } from "react";
export default function VideoAuditorium({
name,
url,
}: {
name: string;
url: string;
}) {
const { showNextSlide } = useContext(SlideshowContext);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
attemptPlay();
}, [url, videoRef]);
const attemptPlay = async () => {
if (videoRef.current) {
try {
await videoRef.current.play();
} catch {
showNextSlide();
}
}
};
return (
<div
style={{
width: "100vw",
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<video
ref={videoRef}
autoPlay
controls
style={{
maxWidth: "100vw",
maxHeight: "100vh",
}}
onError={showNextSlide}
onEnded={showNextSlide}
>
<source src={url} type={mime.lookup(name)} />
</video>
</div>
);
}

View file

@ -1,30 +0,0 @@
import { FILE_TYPE } from "constants/file";
import PhotoAuditorium from "./PhotoAuditorium";
// import VideoAuditorium from './VideoAuditorium';
interface fileProp {
fileName: string;
fileURL: string;
type: FILE_TYPE;
}
interface IProps {
file1: fileProp;
file2: fileProp;
}
export default function Theatre(props: IProps) {
switch (props.file1.type && props.file2.type) {
case FILE_TYPE.IMAGE:
return (
<PhotoAuditorium
url={props.file1.fileURL}
nextSlideUrl={props.file2.fileURL}
/>
);
// case FILE_TYPE.VIDEO:
// return (
// <VideoAuditorium name={props.fileName} url={props.fileURL} />
// );
}
}

View file

@ -1,9 +1,9 @@
import log from "@/next/log";
import PairedSuccessfullyOverlay from "components/PairedSuccessfullyOverlay";
import Theatre from "components/Theatre";
import { PhotoAuditorium } from "components/PhotoAuditorium";
import { FILE_TYPE } from "constants/file";
import { useRouter } from "next/router";
import { createContext, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import {
getCastCollection,
getLocalFiles,
@ -13,25 +13,20 @@ import { Collection } from "types/collection";
import { EnteFile } from "types/file";
import { getPreviewableImage, isRawFileFromFileName } from "utils/file";
export const SlideshowContext = createContext<{
showNextSlide: () => void;
}>(null);
const renderableFileURLCache = new Map<number, string>();
export default function Slideshow() {
const [collectionFiles, setCollectionFiles] = useState<EnteFile[]>([]);
const [currentFile, setCurrentFile] = useState<EnteFile | undefined>(
undefined,
);
const [nextFile, setNextFile] = useState<EnteFile | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [castToken, setCastToken] = useState<string>("");
const [castCollection, setCastCollection] = useState<
Collection | undefined
>(undefined);
>();
const [collectionFiles, setCollectionFiles] = useState<EnteFile[]>([]);
const [currentFileId, setCurrentFileId] = useState<number | undefined>();
const [currentFileURL, setCurrentFileURL] = useState<string | undefined>();
const [nextFileURL, setNextFileURL] = useState<string | undefined>();
const router = useRouter();
const syncCastFiles = async (token: string) => {
try {
@ -72,29 +67,16 @@ export default function Slideshow() {
const isFileEligibleForCast = (file: EnteFile) => {
const fileType = file.metadata.fileType;
if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO) {
if (fileType !== FILE_TYPE.IMAGE && fileType !== FILE_TYPE.LIVE_PHOTO)
return false;
}
const fileSizeLimit = 100 * 1024 * 1024;
if (file.info.fileSize > 100 * 1024 * 1024) return false;
if (file.info.fileSize > fileSizeLimit) {
return false;
}
const name = file.metadata.title;
if (fileType === FILE_TYPE.IMAGE) {
if (isRawFileFromFileName(name)) {
return false;
}
}
if (isRawFileFromFileName(file.metadata.title)) return false;
return true;
};
const router = useRouter();
useEffect(() => {
try {
const castToken = window.localStorage.getItem("castToken");
@ -117,9 +99,9 @@ export default function Slideshow() {
showNextSlide();
}, [collectionFiles]);
const showNextSlide = () => {
const showNextSlide = async () => {
const currentIndex = collectionFiles.findIndex(
(file) => file.id === currentFile?.id,
(file) => file.id === currentFileId,
);
const nextIndex = (currentIndex + 1) % collectionFiles.length;
@ -128,63 +110,44 @@ export default function Slideshow() {
const nextFile = collectionFiles[nextIndex];
const nextNextFile = collectionFiles[nextNextIndex];
setCurrentFile(nextFile);
setNextFile(nextNextFile);
let nextURL = renderableFileURLCache.get(nextFile.id);
let nextNextURL = renderableFileURLCache.get(nextNextFile.id);
if (!nextURL) {
try {
const blob = await getPreviewableImage(nextFile, castToken);
const url = URL.createObjectURL(blob);
renderableFileURLCache.set(nextFile.id, url);
nextURL = url;
} catch (e) {
return;
}
}
if (!nextNextURL) {
try {
const blob = await getPreviewableImage(nextNextFile, castToken);
const url = URL.createObjectURL(blob);
renderableFileURLCache.set(nextNextFile.id, url);
nextNextURL = url;
} catch (e) {
return;
}
}
setLoading(false);
setCurrentFileId(nextFile.id);
setCurrentFileURL(nextURL);
setNextFileURL(nextNextURL);
};
const [renderableFileURL, setRenderableFileURL] = useState<string>("");
const getRenderableFileURL = async () => {
if (!currentFile) return;
const cacheValue = renderableFileURLCache.get(currentFile.id);
if (cacheValue) {
setRenderableFileURL(cacheValue);
setLoading(false);
return;
}
try {
const blob = await getPreviewableImage(
currentFile as EnteFile,
castToken,
);
const url = URL.createObjectURL(blob);
renderableFileURLCache.set(currentFile?.id, url);
setRenderableFileURL(url);
} catch (e) {
return;
} finally {
setLoading(false);
}
};
useEffect(() => {
if (currentFile) {
getRenderableFileURL();
}
}, [currentFile]);
if (loading) return <PairedSuccessfullyOverlay />;
return (
<>
<SlideshowContext.Provider value={{ showNextSlide }}>
<Theatre
file1={{
fileName: currentFile?.metadata.title,
fileURL: renderableFileURL,
type: currentFile?.metadata.fileType,
}}
file2={{
fileName: nextFile?.metadata.title,
fileURL: renderableFileURL,
type: nextFile?.metadata.fileType,
}}
/>
</SlideshowContext.Provider>
{loading && <PairedSuccessfullyOverlay />}
</>
<PhotoAuditorium
url={currentFileURL}
nextSlideUrl={nextFileURL}
showNextSlide={showNextSlide}
/>
);
}

View file

@ -1,32 +0,0 @@
import JSZip from "jszip";
import { EnteFile } from "types/file";
import {
getFileExtensionWithDot,
getFileNameWithoutExtension,
} from "utils/file";
class LivePhoto {
image: Uint8Array;
video: Uint8Array;
imageNameTitle: string;
videoNameTitle: string;
}
export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => {
const originalName = getFileNameWithoutExtension(file.metadata.title);
const zip = await JSZip.loadAsync(zipBlob, { createFolders: true });
const livePhoto = new LivePhoto();
for (const zipFilename in zip.files) {
if (zipFilename.startsWith("image")) {
livePhoto.imageNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.image = await zip.files[zipFilename].async("uint8array");
} else if (zipFilename.startsWith("video")) {
livePhoto.videoNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.video = await zip.files[zipFilename].async("uint8array");
}
}
return livePhoto;
};

View file

@ -95,13 +95,6 @@ export interface ParsedExtractedMetadata {
height: number;
}
// This is used to prompt the user the make upload strategy choice
export interface ImportSuggestion {
rootFolderName: string;
hasNestedFolders: boolean;
hasRootLevelFileWithFolder: boolean;
}
export interface PublicUploadProps {
token: string;
passwordToken: string;

View file

@ -1,8 +1,8 @@
import { decodeLivePhoto } from "@/media/live-photo";
import log from "@/next/log";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { FILE_TYPE, RAW_FORMATS } from "constants/file";
import CastDownloadManager from "services/castDownloadManager";
import { decodeLivePhoto } from "services/livePhotoService";
import { getFileType } from "services/typeDetectionService";
import {
EncryptedEnteFile,
@ -85,18 +85,6 @@ export async function decryptFile(
}
}
export function getFileNameWithoutExtension(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return filename;
else return filename.slice(0, lastDotPosition);
}
export function getFileExtensionWithDot(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return "";
else return filename.slice(lastDotPosition);
}
export function generateStreamFromArrayBuffer(data: Uint8Array) {
return new ReadableStream({
async start(controller: ReadableStreamDefaultController) {
@ -115,6 +103,18 @@ export function isRawFileFromFileName(fileName: string) {
return false;
}
/**
* [Note: File name for local EnteFile objects]
*
* The title property in a file's metadata is the original file's name. The
* metadata of a file cannot be edited. So if later on the file's name is
* changed, then the edit is stored in the `editedName` property of the public
* metadata of the file.
*
* This function merges these edits onto the file object that we use locally.
* Effectively, post this step, the file's metadata.title can be used in lieu of
* its filename.
*/
export function mergeMetadata(files: EnteFile[]): EnteFile[] {
return files.map((file) => {
if (file.pubMagicMetadata?.data.editedTime) {
@ -137,8 +137,11 @@ export const getPreviewableImage = async (
await CastDownloadManager.downloadFile(castToken, file),
).blob();
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const livePhoto = await decodeLivePhoto(file, fileBlob);
fileBlob = new Blob([livePhoto.image]);
const { imageData } = await decodeLivePhoto(
file.metadata.title,
fileBlob,
);
fileBlob = new Blob([imageData]);
}
const fileType = await getFileType(
new File([fileBlob], file.metadata.title),

View file

@ -3,6 +3,7 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@/media": "*",
"@/next": "*",
"@date-io/date-fns": "^2.14.0",
"@ente/accounts": "*",
@ -25,7 +26,6 @@
"hdbscan": "0.0.1-alpha.5",
"heic-convert": "^2.0.0",
"idb": "^7.1.1",
"jszip": "3.10.1",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.1",
"localforage": "^1.9.0",

View file

@ -50,7 +50,7 @@ export default function AlbumCastDialog(props: Props) {
setFieldError,
) => {
try {
await doCast(value);
await doCast(value.trim());
props.onHide();
} catch (e) {
const error = e as Error;

View file

@ -1,3 +1,4 @@
import { nameAndExtension } from "@/next/file";
import log from "@/next/log";
import { FlexWrapper } from "@ente/shared/components/Container";
import PhotoOutlined from "@mui/icons-material/PhotoOutlined";
@ -7,11 +8,7 @@ import { FILE_TYPE } from "constants/file";
import { useEffect, useState } from "react";
import { EnteFile } from "types/file";
import { makeHumanReadableStorage } from "utils/billing";
import {
changeFileName,
splitFilenameAndExtension,
updateExistingFilePubMetadata,
} from "utils/file";
import { changeFileName, updateExistingFilePubMetadata } from "utils/file";
import { FileNameEditDialog } from "./FileNameEditDialog";
import InfoItem from "./InfoItem";
@ -65,9 +62,7 @@ export function RenderFileName({
const [extension, setExtension] = useState<string>();
useEffect(() => {
const [filename, extension] = splitFilenameAndExtension(
file.metadata.title,
);
const [filename, extension] = nameAndExtension(file.metadata.title);
setFilename(filename);
setExtension(extension);
}, [file]);

View file

@ -24,7 +24,7 @@ import {
import { getAccountsURL } from "@ente/shared/network/api";
import { THEME_COLOR } from "@ente/shared/themes/constants";
import { EnteMenuItem } from "components/Menu/EnteMenuItem";
import WatchFolder from "components/WatchFolder";
import { WatchFolder } from "components/WatchFolder";
import isElectron from "is-electron";
import { getAccountsToken } from "services/userService";
import { getDownloadAppMessage } from "utils/ui";
@ -206,7 +206,12 @@ export default function UtilitySection({ closeSidebar }) {
closeSidebar={closeSidebar}
setLoading={startLoading}
/>
<WatchFolder open={watchFolderView} onClose={closeWatchFolder} />
{isElectron() && (
<WatchFolder
open={watchFolderView}
onClose={closeWatchFolder}
/>
)}
<Preferences
open={preferencesView}
onClose={closePreferencesOptions}

View file

@ -1,3 +1,4 @@
import type { CollectionMapping } from "@/next/types/ipc";
import {
CenteredFlex,
SpaceBetweenFlex,
@ -8,23 +9,19 @@ import DialogTitleWithCloseButton, {
import { Button, Dialog, DialogContent, Typography } from "@mui/material";
import { t } from "i18next";
interface Props {
uploadToMultipleCollection: () => void;
interface CollectionMappingChoiceModalProps {
open: boolean;
onClose: () => void;
uploadToSingleCollection: () => void;
didSelect: (mapping: CollectionMapping) => void;
}
function UploadStrategyChoiceModal({
uploadToMultipleCollection,
uploadToSingleCollection,
...props
}: Props) {
const handleClose = dialogCloseHandler({
onClose: props.onClose,
});
export const CollectionMappingChoiceModal: React.FC<
CollectionMappingChoiceModalProps
> = ({ open, onClose, didSelect }) => {
const handleClose = dialogCloseHandler({ onClose });
return (
<Dialog open={props.open} onClose={handleClose}>
<Dialog open={open} onClose={handleClose}>
<DialogTitleWithCloseButton onClose={handleClose}>
{t("MULTI_FOLDER_UPLOAD")}
</DialogTitleWithCloseButton>
@ -39,8 +36,8 @@ function UploadStrategyChoiceModal({
size="medium"
color="accent"
onClick={() => {
props.onClose();
uploadToSingleCollection();
onClose();
didSelect("root");
}}
>
{t("UPLOAD_STRATEGY_SINGLE_COLLECTION")}
@ -52,8 +49,8 @@ function UploadStrategyChoiceModal({
size="medium"
color="accent"
onClick={() => {
props.onClose();
uploadToMultipleCollection();
onClose();
didSelect("parent");
}}
>
{t("UPLOAD_STRATEGY_COLLECTION_PER_FOLDER")}
@ -62,5 +59,4 @@ function UploadStrategyChoiceModal({
</DialogContent>
</Dialog>
);
}
export default UploadStrategyChoiceModal;
};

View file

@ -1,15 +1,11 @@
import { ensureElectron } from "@/next/electron";
import log from "@/next/log";
import type { Electron } from "@/next/types/ipc";
import type { CollectionMapping, Electron } from "@/next/types/ipc";
import { CustomError } from "@ente/shared/error";
import { isPromise } from "@ente/shared/utils";
import DiscFullIcon from "@mui/icons-material/DiscFull";
import UserNameInputDialog from "components/UserNameInputDialog";
import {
DEFAULT_IMPORT_SUGGESTION,
PICKED_UPLOAD_TYPE,
UPLOAD_STAGES,
UPLOAD_STRATEGY,
} from "constants/upload";
import { PICKED_UPLOAD_TYPE, UPLOAD_STAGES } from "constants/upload";
import { t } from "i18next";
import isElectron from "is-electron";
import { AppContext } from "pages/_app";
@ -17,14 +13,14 @@ import { GalleryContext } from "pages/gallery";
import { useContext, useEffect, useRef, useState } from "react";
import billingService from "services/billingService";
import { getLatestCollections } from "services/collectionService";
import ImportService from "services/importService";
import { setToUploadCollection } from "services/pending-uploads";
import {
getPublicCollectionUID,
getPublicCollectionUploaderName,
savePublicCollectionUploaderName,
} from "services/publicCollectionService";
import uploadManager from "services/upload/uploadManager";
import watchFolderService from "services/watchFolder/watchFolderService";
import watcher from "services/watch";
import { NotificationAttributes } from "types/Notification";
import { Collection } from "types/collection";
import {
@ -35,11 +31,7 @@ import {
SetLoading,
UploadTypeSelectorIntent,
} from "types/gallery";
import {
ElectronFile,
FileWithCollection,
ImportSuggestion,
} from "types/upload";
import { ElectronFile, FileWithCollection } from "types/upload";
import {
InProgressUpload,
SegregatedFinishedUploads,
@ -53,13 +45,15 @@ import {
getRootLevelFileWithFolderNotAllowMessage,
} from "utils/ui";
import {
DEFAULT_IMPORT_SUGGESTION,
filterOutSystemFiles,
getImportSuggestion,
groupFilesBasedOnParentFolder,
type ImportSuggestion,
} from "utils/upload";
import { SetCollectionNamerAttributes } from "../Collections/CollectionNamer";
import { CollectionMappingChoiceModal } from "./CollectionMappingChoiceModal";
import UploadProgress from "./UploadProgress";
import UploadStrategyChoiceModal from "./UploadStrategyChoiceModal";
import UploadTypeSelector from "./UploadTypeSelector";
const FIRST_ALBUM_NAME = "My First Album";
@ -137,6 +131,7 @@ export default function Uploader(props: Props) {
const closeUploadProgress = () => setUploadProgressView(false);
const showUserNameInputDialog = () => setUserNameInputDialogView(true);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const setCollectionName = (collectionName: string) => {
isPendingDesktopUpload.current = true;
pendingDesktopUploadCollectionName.current = collectionName;
@ -177,18 +172,27 @@ export default function Uploader(props: Props) {
}
if (isElectron()) {
ImportService.getPendingUploads().then(
({ files: electronFiles, collectionName, type }) => {
log.info(`found pending desktop upload, resuming uploads`);
resumeDesktopUpload(type, electronFiles, collectionName);
},
);
watchFolderService.init(
ensureElectron()
.pendingUploads()
.then((pending) => {
if (pending) {
log.info("Resuming pending desktop upload", pending);
resumeDesktopUpload(
pending.type == "files"
? PICKED_UPLOAD_TYPE.FILES
: PICKED_UPLOAD_TYPE.ZIPS,
pending.files,
pending.collectionName,
);
}
});
/* TODO(MR): This is the connection point, implement
watcher.init(
setElectronFiles,
setCollectionName,
props.syncWithRemote,
appContext.setIsFolderSyncRunning,
);
*/
}
}, [
publicCollectionGalleryContext.accessedThroughSharedURL,
@ -291,18 +295,16 @@ export default function Uploader(props: Props) {
}`,
);
if (uploadManager.isUploadRunning()) {
if (watchFolderService.isUploadRunning()) {
if (watcher.isUploadRunning()) {
// Pause watch folder sync on user upload
log.info(
"watchFolder upload was running, pausing it to run user upload",
"Folder watcher was uploading, pausing it to first run user upload",
);
// pause watch folder service on user upload
watchFolderService.pauseRunningSync();
watcher.pauseRunningSync();
} else {
log.info(
"an upload is already running, rejecting new upload request",
"Ignoring new upload request because an upload is already running",
);
// no-op
// a user upload is already in progress
return;
}
}
@ -330,7 +332,7 @@ export default function Uploader(props: Props) {
const importSuggestion = getImportSuggestion(
pickedUploadType.current,
toUploadFiles.current,
toUploadFiles.current.map((file) => file["path"]),
);
setImportSuggestion(importSuggestion);
@ -391,7 +393,7 @@ export default function Uploader(props: Props) {
};
const uploadFilesToNewCollections = async (
strategy: UPLOAD_STRATEGY,
strategy: CollectionMapping,
collectionName?: string,
) => {
try {
@ -405,7 +407,7 @@ export default function Uploader(props: Props) {
string,
(File | ElectronFile)[]
>();
if (strategy === UPLOAD_STRATEGY.SINGLE_COLLECTION) {
if (strategy == "root") {
collectionNameToFilesMap.set(
collectionName,
toUploadFiles.current,
@ -505,18 +507,19 @@ export default function Uploader(props: Props) {
if (
electron &&
!isPendingDesktopUpload.current &&
!watchFolderService.isUploadRunning()
!watcher.isUploadRunning()
) {
await ImportService.setToUploadCollection(collections);
await setToUploadCollection(collections);
// TODO (MR): What happens when we have both?
if (zipPaths.current) {
await electron.setToUploadFiles(
PICKED_UPLOAD_TYPE.ZIPS,
await electron.setPendingUploadFiles(
"zips",
zipPaths.current,
);
zipPaths.current = null;
}
await electron.setToUploadFiles(
PICKED_UPLOAD_TYPE.FILES,
await electron.setPendingUploadFiles(
"files",
filesWithCollectionToUploadIn.map(
({ file }) => (file as ElectronFile).path,
),
@ -532,14 +535,14 @@ export default function Uploader(props: Props) {
closeUploadProgress();
}
if (isElectron()) {
if (watchFolderService.isUploadRunning()) {
await watchFolderService.allFileUploadsDone(
if (watcher.isUploadRunning()) {
await watcher.allFileUploadsDone(
filesWithCollectionToUploadIn,
collections,
);
} else if (watchFolderService.isSyncPaused()) {
} else if (watcher.isSyncPaused()) {
// resume the service after user upload is done
watchFolderService.resumePausedSync();
watcher.resumePausedSync();
}
}
} catch (e) {
@ -605,10 +608,7 @@ export default function Uploader(props: Props) {
}
const uploadToSingleNewCollection = (collectionName: string) => {
uploadFilesToNewCollections(
UPLOAD_STRATEGY.SINGLE_COLLECTION,
collectionName,
);
uploadFilesToNewCollections("root", collectionName);
};
const showCollectionCreateModal = (suggestedName: string) => {
@ -647,7 +647,7 @@ export default function Uploader(props: Props) {
`upload pending files to collection - ${pendingDesktopUploadCollectionName.current}`,
);
uploadFilesToNewCollections(
UPLOAD_STRATEGY.SINGLE_COLLECTION,
"root",
pendingDesktopUploadCollectionName.current,
);
pendingDesktopUploadCollectionName.current = null;
@ -655,17 +655,13 @@ export default function Uploader(props: Props) {
log.info(
`pending upload - strategy - "multiple collections" `,
);
uploadFilesToNewCollections(
UPLOAD_STRATEGY.COLLECTION_PER_FOLDER,
);
uploadFilesToNewCollections("parent");
}
return;
}
if (isElectron() && pickedUploadType === PICKED_UPLOAD_TYPE.ZIPS) {
log.info("uploading zip files");
uploadFilesToNewCollections(
UPLOAD_STRATEGY.COLLECTION_PER_FOLDER,
);
uploadFilesToNewCollections("parent");
return;
}
if (isFirstUpload && !importSuggestion.rootFolderName) {
@ -784,16 +780,26 @@ export default function Uploader(props: Props) {
);
return;
}
uploadFilesToNewCollections(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER);
uploadFilesToNewCollections("parent");
};
const didSelectCollectionMapping = (mapping: CollectionMapping) => {
switch (mapping) {
case "root":
handleUploadToSingleCollection();
break;
case "parent":
handleUploadToMultipleCollections();
break;
}
};
return (
<>
<UploadStrategyChoiceModal
<CollectionMappingChoiceModal
open={choiceModalView}
onClose={handleChoiceModalClose}
uploadToSingleCollection={handleUploadToSingleCollection}
uploadToMultipleCollection={handleUploadToMultipleCollections}
didSelect={didSelectCollectionMapping}
/>
<UploadTypeSelector
show={props.uploadTypeSelectorView}

View file

@ -0,0 +1,326 @@
import { ensureElectron } from "@/next/electron";
import { basename } from "@/next/file";
import type { CollectionMapping, FolderWatch } from "@/next/types/ipc";
import { ensure } from "@/utils/ensure";
import {
FlexWrapper,
HorizontalFlex,
SpaceBetweenFlex,
VerticallyCentered,
} from "@ente/shared/components/Container";
import DialogTitleWithCloseButton from "@ente/shared/components/DialogBox/TitleWithCloseButton";
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
import CheckIcon from "@mui/icons-material/Check";
import DoNotDisturbOutlinedIcon from "@mui/icons-material/DoNotDisturbOutlined";
import FolderCopyOutlinedIcon from "@mui/icons-material/FolderCopyOutlined";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
import {
Box,
Button,
CircularProgress,
Dialog,
DialogContent,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import { styled } from "@mui/material/styles";
import { CollectionMappingChoiceModal } from "components/Upload/CollectionMappingChoiceModal";
import { t } from "i18next";
import { AppContext } from "pages/_app";
import React, { useContext, useEffect, useState } from "react";
import watcher from "services/watch";
import { areAllInSameDirectory } from "utils/upload";
interface WatchFolderProps {
open: boolean;
onClose: () => void;
}
/**
* View the state of and manage folder watches.
*
* This is the screen that controls that "watch folder" feature in the app.
*/
export const WatchFolder: React.FC<WatchFolderProps> = ({ open, onClose }) => {
// The folders we are watching
const [watches, setWatches] = useState<FolderWatch[] | undefined>();
// Temporarily stash the folder path while we show a choice dialog to the
// user to select the collection mapping.
const [savedFolderPath, setSavedFolderPath] = useState<
string | undefined
>();
// True when we're showing the choice dialog to ask the user to set the
// collection mapping.
const [choiceModalOpen, setChoiceModalOpen] = useState(false);
const appContext = useContext(AppContext);
useEffect(() => {
watcher.getWatches().then((ws) => setWatches(ws));
}, []);
useEffect(() => {
if (
appContext.watchFolderFiles &&
appContext.watchFolderFiles.length > 0
) {
handleFolderDrop(appContext.watchFolderFiles);
appContext.setWatchFolderFiles(null);
}
}, [appContext.watchFolderFiles]);
const handleFolderDrop = async (folders: FileList) => {
for (let i = 0; i < folders.length; i++) {
const folder: any = folders[i];
const path = (folder.path as string).replace(/\\/g, "/");
if (await ensureElectron().fs.isDir(path)) {
await selectCollectionMappingAndAddWatch(path);
}
}
};
const selectCollectionMappingAndAddWatch = async (path: string) => {
const filePaths = await ensureElectron().watch.findFiles(path);
if (areAllInSameDirectory(filePaths)) {
addWatch(path, "root");
} else {
setSavedFolderPath(path);
setChoiceModalOpen(true);
}
};
const addWatch = (folderPath: string, mapping: CollectionMapping) =>
watcher.addWatch(folderPath, mapping).then((ws) => setWatches(ws));
const addNewWatch = async () => {
const dirPath = await ensureElectron().selectDirectory();
if (dirPath) {
await selectCollectionMappingAndAddWatch(dirPath);
}
};
const removeWatch = async (watch: FolderWatch) =>
watcher.removeWatch(watch.folderPath).then((ws) => setWatches(ws));
const closeChoiceModal = () => setChoiceModalOpen(false);
const addWatchWithMapping = (mapping: CollectionMapping) => {
closeChoiceModal();
setSavedFolderPath(undefined);
addWatch(ensure(savedFolderPath), mapping);
};
return (
<>
<Dialog
open={open}
onClose={onClose}
PaperProps={{ sx: { height: "448px", maxWidth: "414px" } }}
>
<DialogTitleWithCloseButton
onClose={onClose}
sx={{ "&&&": { padding: "32px 16px 16px 24px" } }}
>
{t("WATCHED_FOLDERS")}
</DialogTitleWithCloseButton>
<DialogContent sx={{ flex: 1 }}>
<Stack spacing={1} p={1.5} height={"100%"}>
<WatchList {...{ watches, removeWatch }} />
<Button fullWidth color="accent" onClick={addNewWatch}>
<span>+</span>
<span
style={{
marginLeft: "8px",
}}
></span>
{t("ADD_FOLDER")}
</Button>
</Stack>
</DialogContent>
</Dialog>
<CollectionMappingChoiceModal
open={choiceModalOpen}
onClose={closeChoiceModal}
didSelect={addWatchWithMapping}
/>
</>
);
};
interface WatchList {
watches: FolderWatch[];
removeWatch: (watch: FolderWatch) => void;
}
const WatchList: React.FC<WatchList> = ({ watches, removeWatch }) => {
return watches.length === 0 ? (
<NoWatches />
) : (
<WatchesContainer>
{watches.map((watch) => {
return (
<WatchEntry
key={watch.folderPath}
watch={watch}
removeWatch={removeWatch}
/>
);
})}
</WatchesContainer>
);
};
const WatchesContainer = styled(Box)(() => ({
height: "278px",
overflow: "auto",
"&::-webkit-scrollbar": {
width: "4px",
},
}));
const NoWatches: React.FC = () => {
return (
<NoWatchesContainer>
<Stack spacing={1}>
<Typography variant="large" fontWeight={"bold"}>
{t("NO_FOLDERS_ADDED")}
</Typography>
<Typography py={0.5} variant={"small"} color="text.muted">
{t("FOLDERS_AUTOMATICALLY_MONITORED")}
</Typography>
<Typography variant={"small"} color="text.muted">
<FlexWrapper gap={1}>
<CheckmarkIcon />
{t("UPLOAD_NEW_FILES_TO_ENTE")}
</FlexWrapper>
</Typography>
<Typography variant={"small"} color="text.muted">
<FlexWrapper gap={1}>
<CheckmarkIcon />
{t("REMOVE_DELETED_FILES_FROM_ENTE")}
</FlexWrapper>
</Typography>
</Stack>
</NoWatchesContainer>
);
};
const NoWatchesContainer = styled(VerticallyCentered)({
textAlign: "left",
alignItems: "flex-start",
marginBottom: "32px",
});
const CheckmarkIcon: React.FC = () => {
return (
<CheckIcon
fontSize="small"
sx={{
display: "inline",
fontSize: "15px",
color: (theme) => theme.palette.secondary.main,
}}
/>
);
};
interface WatchEntryProps {
watch: FolderWatch;
removeWatch: (watch: FolderWatch) => void;
}
const WatchEntry: React.FC<WatchEntryProps> = ({ watch, removeWatch }) => {
const appContext = React.useContext(AppContext);
const confirmStopWatching = () => {
appContext.setDialogMessage({
title: t("STOP_WATCHING_FOLDER"),
content: t("STOP_WATCHING_DIALOG_MESSAGE"),
close: {
text: t("CANCEL"),
variant: "secondary",
},
proceed: {
action: () => removeWatch(watch),
text: t("YES_STOP"),
variant: "critical",
},
});
};
return (
<SpaceBetweenFlex>
<HorizontalFlex>
{watch.collectionMapping === "root" ? (
<Tooltip title={t("UPLOADED_TO_SINGLE_COLLECTION")}>
<FolderOpenIcon />
</Tooltip>
) : (
<Tooltip title={t("UPLOADED_TO_SEPARATE_COLLECTIONS")}>
<FolderCopyOutlinedIcon />
</Tooltip>
)}
<EntryContainer>
<EntryHeading watch={watch} />
<Typography color="text.muted" variant="small">
{watch.folderPath}
</Typography>
</EntryContainer>
</HorizontalFlex>
<EntryOptions {...{ confirmStopWatching }} />
</SpaceBetweenFlex>
);
};
const EntryContainer = styled(Box)({
marginLeft: "12px",
marginRight: "6px",
marginBottom: "12px",
});
interface EntryHeadingProps {
watch: FolderWatch;
}
const EntryHeading: React.FC<EntryHeadingProps> = ({ watch }) => {
const folderPath = watch.folderPath;
return (
<FlexWrapper gap={1}>
<Typography>{basename(folderPath)}</Typography>
{watcher.isSyncingFolder(folderPath) && (
<CircularProgress size={12} />
)}
</FlexWrapper>
);
};
interface EntryOptionsProps {
confirmStopWatching: () => void;
}
const EntryOptions: React.FC<EntryOptionsProps> = ({ confirmStopWatching }) => {
return (
<OverflowMenu
menuPaperProps={{
sx: {
backgroundColor: (theme) =>
theme.colors.background.elevated2,
},
}}
ariaControls={"watch-mapping-option"}
triggerButtonIcon={<MoreHorizIcon />}
>
<OverflowMenuOption
color="critical"
onClick={confirmStopWatching}
startIcon={<DoNotDisturbOutlinedIcon />}
>
{t("STOP_WATCHING")}
</OverflowMenuOption>
</OverflowMenu>
);
};

View file

@ -1,152 +0,0 @@
import DialogTitleWithCloseButton from "@ente/shared/components/DialogBox/TitleWithCloseButton";
import { Button, Dialog, DialogContent, Stack } from "@mui/material";
import UploadStrategyChoiceModal from "components/Upload/UploadStrategyChoiceModal";
import { PICKED_UPLOAD_TYPE, UPLOAD_STRATEGY } from "constants/upload";
import { t } from "i18next";
import { AppContext } from "pages/_app";
import { useContext, useEffect, useState } from "react";
import watchFolderService from "services/watchFolder/watchFolderService";
import { WatchMapping } from "types/watchFolder";
import { getImportSuggestion } from "utils/upload";
import { MappingList } from "./mappingList";
interface Iprops {
open: boolean;
onClose: () => void;
}
export default function WatchFolder({ open, onClose }: Iprops) {
const [mappings, setMappings] = useState<WatchMapping[]>([]);
const [inputFolderPath, setInputFolderPath] = useState("");
const [choiceModalOpen, setChoiceModalOpen] = useState(false);
const appContext = useContext(AppContext);
const electron = globalThis.electron;
useEffect(() => {
if (!electron) return;
watchFolderService.getWatchMappings().then((m) => setMappings(m));
}, []);
useEffect(() => {
if (
appContext.watchFolderFiles &&
appContext.watchFolderFiles.length > 0
) {
handleFolderDrop(appContext.watchFolderFiles);
appContext.setWatchFolderFiles(null);
}
}, [appContext.watchFolderFiles]);
const handleFolderDrop = async (folders: FileList) => {
for (let i = 0; i < folders.length; i++) {
const folder: any = folders[i];
const path = (folder.path as string).replace(/\\/g, "/");
if (await watchFolderService.isFolder(path)) {
await addFolderForWatching(path);
}
}
};
const addFolderForWatching = async (path: string) => {
if (!electron) return;
setInputFolderPath(path);
const files = await electron.getDirFiles(path);
const analysisResult = getImportSuggestion(
PICKED_UPLOAD_TYPE.FOLDERS,
files,
);
if (analysisResult.hasNestedFolders) {
setChoiceModalOpen(true);
} else {
handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION, path);
}
};
const handleAddFolderClick = async () => {
await handleFolderSelection();
};
const handleFolderSelection = async () => {
const folderPath = await watchFolderService.selectFolder();
if (folderPath) {
await addFolderForWatching(folderPath);
}
};
const handleAddWatchMapping = async (
uploadStrategy: UPLOAD_STRATEGY,
folderPath?: string,
) => {
folderPath = folderPath || inputFolderPath;
await watchFolderService.addWatchMapping(
folderPath.substring(folderPath.lastIndexOf("/") + 1),
folderPath,
uploadStrategy,
);
setInputFolderPath("");
setMappings(await watchFolderService.getWatchMappings());
};
const handleRemoveWatchMapping = async (mapping: WatchMapping) => {
await watchFolderService.removeWatchMapping(mapping.folderPath);
setMappings(await watchFolderService.getWatchMappings());
};
const closeChoiceModal = () => setChoiceModalOpen(false);
const uploadToSingleCollection = () => {
closeChoiceModal();
handleAddWatchMapping(UPLOAD_STRATEGY.SINGLE_COLLECTION);
};
const uploadToMultipleCollection = () => {
closeChoiceModal();
handleAddWatchMapping(UPLOAD_STRATEGY.COLLECTION_PER_FOLDER);
};
return (
<>
<Dialog
open={open}
onClose={onClose}
PaperProps={{ sx: { height: "448px", maxWidth: "414px" } }}
>
<DialogTitleWithCloseButton
onClose={onClose}
sx={{ "&&&": { padding: "32px 16px 16px 24px" } }}
>
{t("WATCHED_FOLDERS")}
</DialogTitleWithCloseButton>
<DialogContent sx={{ flex: 1 }}>
<Stack spacing={1} p={1.5} height={"100%"}>
<MappingList
mappings={mappings}
handleRemoveWatchMapping={handleRemoveWatchMapping}
/>
<Button
fullWidth
color="accent"
onClick={handleAddFolderClick}
>
<span>+</span>
<span
style={{
marginLeft: "8px",
}}
></span>
{t("ADD_FOLDER")}
</Button>
</Stack>
</DialogContent>
</Dialog>
<UploadStrategyChoiceModal
open={choiceModalOpen}
onClose={closeChoiceModal}
uploadToSingleCollection={uploadToSingleCollection}
uploadToMultipleCollection={uploadToMultipleCollection}
/>
</>
);
}

View file

@ -1,23 +0,0 @@
import { FlexWrapper } from "@ente/shared/components/Container";
import { CircularProgress, Typography } from "@mui/material";
import { AppContext } from "pages/_app";
import { useContext } from "react";
import watchFolderService from "services/watchFolder/watchFolderService";
import { WatchMapping } from "types/watchFolder";
interface Iprops {
mapping: WatchMapping;
}
export function EntryHeading({ mapping }: Iprops) {
const appContext = useContext(AppContext);
return (
<FlexWrapper gap={1}>
<Typography>{mapping.rootFolderName}</Typography>
{appContext.isFolderSyncRunning &&
watchFolderService.isMappingSyncInProgress(mapping) && (
<CircularProgress size={12} />
)}
</FlexWrapper>
);
}

View file

@ -1,69 +0,0 @@
import {
HorizontalFlex,
SpaceBetweenFlex,
} from "@ente/shared/components/Container";
import FolderCopyOutlinedIcon from "@mui/icons-material/FolderCopyOutlined";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import { Tooltip, Typography } from "@mui/material";
import { t } from "i18next";
import { AppContext } from "pages/_app";
import React from "react";
import { WatchMapping } from "types/watchFolder";
import { EntryContainer } from "../styledComponents";
import { UPLOAD_STRATEGY } from "constants/upload";
import { EntryHeading } from "./entryHeading";
import MappingEntryOptions from "./mappingEntryOptions";
interface Iprops {
mapping: WatchMapping;
handleRemoveMapping: (mapping: WatchMapping) => void;
}
export function MappingEntry({ mapping, handleRemoveMapping }: Iprops) {
const appContext = React.useContext(AppContext);
const stopWatching = () => {
handleRemoveMapping(mapping);
};
const confirmStopWatching = () => {
appContext.setDialogMessage({
title: t("STOP_WATCHING_FOLDER"),
content: t("STOP_WATCHING_DIALOG_MESSAGE"),
close: {
text: t("CANCEL"),
variant: "secondary",
},
proceed: {
action: stopWatching,
text: t("YES_STOP"),
variant: "critical",
},
});
};
return (
<SpaceBetweenFlex>
<HorizontalFlex>
{mapping &&
mapping.uploadStrategy === UPLOAD_STRATEGY.SINGLE_COLLECTION ? (
<Tooltip title={t("UPLOADED_TO_SINGLE_COLLECTION")}>
<FolderOpenIcon />
</Tooltip>
) : (
<Tooltip title={t("UPLOADED_TO_SEPARATE_COLLECTIONS")}>
<FolderCopyOutlinedIcon />
</Tooltip>
)}
<EntryContainer>
<EntryHeading mapping={mapping} />
<Typography color="text.muted" variant="small">
{mapping.folderPath}
</Typography>
</EntryContainer>
</HorizontalFlex>
<MappingEntryOptions confirmStopWatching={confirmStopWatching} />
</SpaceBetweenFlex>
);
}

View file

@ -1,33 +0,0 @@
import { t } from "i18next";
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
import DoNotDisturbOutlinedIcon from "@mui/icons-material/DoNotDisturbOutlined";
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
interface Iprops {
confirmStopWatching: () => void;
}
export default function MappingEntryOptions({ confirmStopWatching }: Iprops) {
return (
<OverflowMenu
menuPaperProps={{
sx: {
backgroundColor: (theme) =>
theme.colors.background.elevated2,
},
}}
ariaControls={"watch-mapping-option"}
triggerButtonIcon={<MoreHorizIcon />}
>
<OverflowMenuOption
color="critical"
onClick={confirmStopWatching}
startIcon={<DoNotDisturbOutlinedIcon />}
>
{t("STOP_WATCHING")}
</OverflowMenuOption>
</OverflowMenu>
);
}

View file

@ -1,26 +0,0 @@
import { WatchMapping } from "types/watchFolder";
import { MappingEntry } from "../mappingEntry";
import { MappingsContainer } from "../styledComponents";
import { NoMappingsContent } from "./noMappingsContent/noMappingsContent";
interface Iprops {
mappings: WatchMapping[];
handleRemoveWatchMapping: (value: WatchMapping) => void;
}
export function MappingList({ mappings, handleRemoveWatchMapping }: Iprops) {
return mappings.length === 0 ? (
<NoMappingsContent />
) : (
<MappingsContainer>
{mappings.map((mapping) => {
return (
<MappingEntry
key={mapping.rootFolderName}
mapping={mapping}
handleRemoveMapping={handleRemoveWatchMapping}
/>
);
})}
</MappingsContainer>
);
}

View file

@ -1,15 +0,0 @@
import CheckIcon from "@mui/icons-material/Check";
export function CheckmarkIcon() {
return (
<CheckIcon
fontSize="small"
sx={{
display: "inline",
fontSize: "15px",
color: (theme) => theme.palette.secondary.main,
}}
/>
);
}

View file

@ -1,33 +0,0 @@
import { Stack, Typography } from "@mui/material";
import { t } from "i18next";
import { FlexWrapper } from "@ente/shared/components/Container";
import { NoMappingsContainer } from "../../styledComponents";
import { CheckmarkIcon } from "./checkmarkIcon";
export function NoMappingsContent() {
return (
<NoMappingsContainer>
<Stack spacing={1}>
<Typography variant="large" fontWeight={"bold"}>
{t("NO_FOLDERS_ADDED")}
</Typography>
<Typography py={0.5} variant={"small"} color="text.muted">
{t("FOLDERS_AUTOMATICALLY_MONITORED")}
</Typography>
<Typography variant={"small"} color="text.muted">
<FlexWrapper gap={1}>
<CheckmarkIcon />
{t("UPLOAD_NEW_FILES_TO_ENTE")}
</FlexWrapper>
</Typography>
<Typography variant={"small"} color="text.muted">
<FlexWrapper gap={1}>
<CheckmarkIcon />
{t("REMOVE_DELETED_FILES_FROM_ENTE")}
</FlexWrapper>
</Typography>
</Stack>
</NoMappingsContainer>
);
}

View file

@ -1,23 +0,0 @@
import { VerticallyCentered } from "@ente/shared/components/Container";
import { Box } from "@mui/material";
import { styled } from "@mui/material/styles";
export const MappingsContainer = styled(Box)(() => ({
height: "278px",
overflow: "auto",
"&::-webkit-scrollbar": {
width: "4px",
},
}));
export const NoMappingsContainer = styled(VerticallyCentered)({
textAlign: "left",
alignItems: "flex-start",
marginBottom: "32px",
});
export const EntryContainer = styled(Box)({
marginLeft: "12px",
marginRight: "6px",
marginBottom: "12px",
});

View file

@ -1,11 +1,6 @@
import { ENCRYPTION_CHUNK_SIZE } from "@ente/shared/crypto/constants";
import { FILE_TYPE } from "constants/file";
import {
FileTypeInfo,
ImportSuggestion,
Location,
ParsedExtractedMetadata,
} from "types/upload";
import { FileTypeInfo, Location, ParsedExtractedMetadata } from "types/upload";
// list of format that were missed by type-detection for some files.
export const WHITELISTED_FILE_FORMATS: FileTypeInfo[] = [
@ -111,12 +106,6 @@ export const NULL_EXTRACTED_METADATA: ParsedExtractedMetadata = {
export const A_SEC_IN_MICROSECONDS = 1e6;
export const DEFAULT_IMPORT_SUGGESTION: ImportSuggestion = {
rootFolderName: "",
hasNestedFolders: false,
hasRootLevelFileWithFolder: false,
};
export const BLACK_THUMBNAIL_BASE64 =
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEB" +
"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQ" +

View file

@ -5,7 +5,7 @@ import {
logStartupBanner,
logUnhandledErrorsAndRejections,
} from "@/next/log-web";
import { AppUpdateInfo } from "@/next/types/ipc";
import { AppUpdate } from "@/next/types/ipc";
import {
APPS,
APP_TITLES,
@ -91,8 +91,6 @@ type AppContextType = {
closeMessageDialog: () => void;
setDialogMessage: SetDialogBoxAttributes;
setNotificationAttributes: SetNotificationAttributes;
isFolderSyncRunning: boolean;
setIsFolderSyncRunning: (isRunning: boolean) => void;
watchFolderView: boolean;
setWatchFolderView: (isOpen: boolean) => void;
watchFolderFiles: FileList;
@ -128,7 +126,6 @@ export default function App({ Component, pageProps }: AppProps) {
useState<DialogBoxAttributes>(null);
const [messageDialogView, setMessageDialogView] = useState(false);
const [dialogBoxV2View, setDialogBoxV2View] = useState(false);
const [isFolderSyncRunning, setIsFolderSyncRunning] = useState(false);
const [watchFolderView, setWatchFolderView] = useState(false);
const [watchFolderFiles, setWatchFolderFiles] = useState<FileList>(null);
const isMobile = useMediaQuery("(max-width:428px)");
@ -160,9 +157,9 @@ export default function App({ Component, pageProps }: AppProps) {
const electron = globalThis.electron;
if (!electron) return;
const showUpdateDialog = (updateInfo: AppUpdateInfo) => {
if (updateInfo.autoUpdatable) {
setDialogMessage(getUpdateReadyToInstallMessage(updateInfo));
const showUpdateDialog = (update: AppUpdate) => {
if (update.autoUpdatable) {
setDialogMessage(getUpdateReadyToInstallMessage(update));
} else {
setNotificationAttributes({
endIcon: <ArrowForward />,
@ -170,7 +167,7 @@ export default function App({ Component, pageProps }: AppProps) {
message: t("UPDATE_AVAILABLE"),
onClick: () =>
setDialogMessage(
getUpdateAvailableForDownloadMessage(updateInfo),
getUpdateAvailableForDownloadMessage(update),
),
});
}
@ -403,8 +400,6 @@ export default function App({ Component, pageProps }: AppProps) {
finishLoading,
closeMessageDialog,
setDialogMessage,
isFolderSyncRunning,
setIsFolderSyncRunning,
watchFolderView,
setWatchFolderView,
watchFolderFiles,

View file

@ -1,3 +1,4 @@
import { decodeLivePhoto } from "@/media/live-photo";
import { ensureElectron } from "@/next/electron";
import log from "@/next/log";
import { CustomError } from "@ente/shared/error";
@ -38,7 +39,6 @@ import { writeStream } from "utils/native-stream";
import { getAllLocalCollections } from "../collectionService";
import downloadManager from "../download";
import { getAllLocalFiles } from "../fileService";
import { decodeLivePhoto } from "../livePhotoService";
import { migrateExport } from "./migration";
/** Name of the JSON file in which we keep the state of the export. */
@ -1015,18 +1015,18 @@ class ExportService {
fileStream: ReadableStream<any>,
file: EnteFile,
) {
const electron = ensureElectron();
const fs = ensureElectron().fs;
const fileBlob = await new Response(fileStream).blob();
const livePhoto = await decodeLivePhoto(file, fileBlob);
const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob);
const imageExportName = await safeFileName(
collectionExportPath,
livePhoto.imageNameTitle,
electron.fs.exists,
livePhoto.imageFileName,
fs.exists,
);
const videoExportName = await safeFileName(
collectionExportPath,
livePhoto.videoNameTitle,
electron.fs.exists,
livePhoto.videoFileName,
fs.exists,
);
const livePhotoExportName = getLivePhotoExportName(
imageExportName,
@ -1038,7 +1038,9 @@ class ExportService {
livePhotoExportName,
);
try {
const imageStream = generateStreamFromArrayBuffer(livePhoto.image);
const imageStream = generateStreamFromArrayBuffer(
livePhoto.imageData,
);
await this.saveMetadataFile(
collectionExportPath,
imageExportName,
@ -1049,7 +1051,9 @@ class ExportService {
imageStream,
);
const videoStream = generateStreamFromArrayBuffer(livePhoto.video);
const videoStream = generateStreamFromArrayBuffer(
livePhoto.videoData,
);
await this.saveMetadataFile(
collectionExportPath,
videoExportName,
@ -1061,9 +1065,7 @@ class ExportService {
videoStream,
);
} catch (e) {
await electron.fs.rm(
`${collectionExportPath}/${imageExportName}`,
);
await fs.rm(`${collectionExportPath}/${imageExportName}`);
throw e;
}
} catch (e) {

View file

@ -1,3 +1,4 @@
import { decodeLivePhoto } from "@/media/live-photo";
import { ensureElectron } from "@/next/electron";
import log from "@/next/log";
import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
@ -7,7 +8,6 @@ import { FILE_TYPE } from "constants/file";
import { getLocalCollections } from "services/collectionService";
import downloadManager from "services/download";
import { getAllLocalFiles } from "services/fileService";
import { decodeLivePhoto } from "services/livePhotoService";
import { Collection } from "types/collection";
import {
CollectionExportNames,
@ -21,11 +21,11 @@ import {
} from "types/export";
import { EnteFile } from "types/file";
import { getNonEmptyPersonalCollections } from "utils/collection";
import { splitFilenameAndExtension } from "utils/ffmpeg";
import {
getIDBasedSortedFiles,
getPersonalFiles,
mergeMetadata,
splitFilenameAndExtension,
} from "utils/file";
import {
safeDirectoryName,
@ -318,15 +318,18 @@ async function getFileExportNamesFromExportedFiles(
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const fileStream = await downloadManager.getFile(file);
const fileBlob = await new Response(fileStream).blob();
const livePhoto = await decodeLivePhoto(file, fileBlob);
const { imageFileName, videoFileName } = await decodeLivePhoto(
file.metadata.title,
fileBlob,
);
const imageExportName = getUniqueFileExportNameForMigration(
collectionPath,
livePhoto.imageNameTitle,
imageFileName,
usedFilePaths,
);
const videoExportName = getUniqueFileExportNameForMigration(
collectionPath,
livePhoto.videoNameTitle,
videoFileName,
usedFilePaths,
);
fileExportName = getLivePhotoExportName(

View file

@ -1,74 +0,0 @@
import { ensureElectron } from "@/next/electron";
import log from "@/next/log";
import { PICKED_UPLOAD_TYPE } from "constants/upload";
import { Collection } from "types/collection";
import { ElectronFile, FileWithCollection } from "types/upload";
interface PendingUploads {
files: ElectronFile[];
collectionName: string;
type: PICKED_UPLOAD_TYPE;
}
class ImportService {
async getPendingUploads(): Promise<PendingUploads> {
try {
const pendingUploads =
(await ensureElectron().getPendingUploads()) as PendingUploads;
return pendingUploads;
} catch (e) {
if (e?.message?.includes("ENOENT: no such file or directory")) {
// ignore
} else {
log.error("failed to getPendingUploads ", e);
}
return { files: [], collectionName: null, type: null };
}
}
async setToUploadCollection(collections: Collection[]) {
let collectionName: string = null;
/* collection being one suggest one of two things
1. Either the user has upload to a single existing collection
2. Created a new single collection to upload to
may have had multiple folder, but chose to upload
to one album
hence saving the collection name when upload collection count is 1
helps the info of user choosing this options
and on next upload we can directly start uploading to this collection
*/
if (collections.length === 1) {
collectionName = collections[0].name;
}
await ensureElectron().setToUploadCollection(collectionName);
}
async updatePendingUploads(files: FileWithCollection[]) {
const filePaths = [];
for (const fileWithCollection of files) {
if (fileWithCollection.isLivePhoto) {
filePaths.push(
(fileWithCollection.livePhotoAssets.image as ElectronFile)
.path,
(fileWithCollection.livePhotoAssets.video as ElectronFile)
.path,
);
} else {
filePaths.push((fileWithCollection.file as ElectronFile).path);
}
}
await ensureElectron().setToUploadFiles(
PICKED_UPLOAD_TYPE.FILES,
filePaths,
);
}
async cancelRemainingUploads() {
const electron = ensureElectron();
await electron.setToUploadCollection(null);
await electron.setToUploadFiles(PICKED_UPLOAD_TYPE.ZIPS, []);
await electron.setToUploadFiles(PICKED_UPLOAD_TYPE.FILES, []);
}
}
export default new ImportService();

View file

@ -1,45 +0,0 @@
import JSZip from "jszip";
import { EnteFile } from "types/file";
import {
getFileExtensionWithDot,
getFileNameWithoutExtension,
} from "utils/file";
class LivePhoto {
image: Uint8Array;
video: Uint8Array;
imageNameTitle: string;
videoNameTitle: string;
}
export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => {
const originalName = getFileNameWithoutExtension(file.metadata.title);
const zip = await JSZip.loadAsync(zipBlob, { createFolders: true });
const livePhoto = new LivePhoto();
for (const zipFilename in zip.files) {
if (zipFilename.startsWith("image")) {
livePhoto.imageNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.image = await zip.files[zipFilename].async("uint8array");
} else if (zipFilename.startsWith("video")) {
livePhoto.videoNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.video = await zip.files[zipFilename].async("uint8array");
}
}
return livePhoto;
};
export const encodeLivePhoto = async (livePhoto: LivePhoto) => {
const zip = new JSZip();
zip.file(
"image" + getFileExtensionWithDot(livePhoto.imageNameTitle),
livePhoto.image,
);
zip.file(
"video" + getFileExtensionWithDot(livePhoto.videoNameTitle),
livePhoto.video,
);
return await zip.generateAsync({ type: "uint8array" });
};

View file

@ -145,7 +145,7 @@ class FaceService {
imageBitmap,
);
const blurValues =
syncContext.blurDetectionService.detectBlur(faceImages);
syncContext.blurDetectionService.detectBlur(faceImages, newMlFile.faces);
newMlFile.faces.forEach((f, i) => (f.blurValue = blurValues[i]));
imageBitmap.close();

View file

@ -1,6 +1,7 @@
import {
BlurDetectionMethod,
BlurDetectionService,
Face,
Versioned,
} from "types/machineLearning";
import { createGrayscaleIntMatrixFromNormalized2List } from "utils/image";
@ -16,18 +17,20 @@ class LaplacianBlurDetectionService implements BlurDetectionService {
};
}
public detectBlur(alignedFaces: Float32Array): number[] {
public detectBlur(alignedFaces: Float32Array, faces: Face[]): number[] {
const numFaces = Math.round(
alignedFaces.length /
(mobileFaceNetFaceSize * mobileFaceNetFaceSize * 3),
);
const blurValues: number[] = [];
for (let i = 0; i < numFaces; i++) {
const face = faces[i];
const direction = getFaceDirection(face);
const faceImage = createGrayscaleIntMatrixFromNormalized2List(
alignedFaces,
i,
);
const laplacian = this.applyLaplacian(faceImage);
const laplacian = this.applyLaplacian(faceImage, direction);
const variance = this.calculateVariance(laplacian);
blurValues.push(variance);
}
@ -61,42 +64,77 @@ class LaplacianBlurDetectionService implements BlurDetectionService {
return variance;
}
private padImage(image: number[][]): number[][] {
private padImage(
image: number[][],
removeSideColumns: number = 56,
direction: FaceDirection = "straight",
): number[][] {
// Exception is removeSideColumns is not even
if (removeSideColumns % 2 != 0) {
throw new Error("removeSideColumns must be even");
}
const numRows = image.length;
const numCols = image[0].length;
const paddedNumCols = numCols + 2 - removeSideColumns;
const paddedNumRows = numRows + 2;
// Create a new matrix with extra padding
const paddedImage: number[][] = Array.from(
{ length: numRows + 2 },
() => new Array(numCols + 2).fill(0),
{ length: paddedNumRows},
() => new Array(paddedNumCols).fill(0),
);
// Copy original image into the center of the padded image
for (let i = 0; i < numRows; i++) {
for (let j = 0; j < numCols; j++) {
paddedImage[i + 1][j + 1] = image[i][j];
if (direction === "straight") {
for (let i = 0; i < numRows; i++) {
for (let j = 0; j < paddedNumCols - 2; j++) {
paddedImage[i + 1][j + 1] =
image[i][j + Math.round(removeSideColumns / 2)];
}
}
} // If the face is facing left, we only take the right side of the face image
else if (direction === "left") {
for (let i = 0; i < numRows; i++) {
for (let j = 0; j < paddedNumCols - 2; j++) {
paddedImage[i + 1][j + 1] = image[i][j + removeSideColumns];
}
}
} // If the face is facing right, we only take the left side of the face image
else if (direction === "right") {
for (let i = 0; i < numRows; i++) {
for (let j = 0; j < paddedNumCols - 2; j++) {
paddedImage[i + 1][j + 1] = image[i][j];
}
}
}
// Reflect padding
// Top and bottom rows
for (let j = 1; j <= numCols; j++) {
for (let j = 1; j <= paddedNumCols - 2; j++) {
paddedImage[0][j] = paddedImage[2][j]; // Top row
paddedImage[numRows + 1][j] = paddedImage[numRows - 1][j]; // Bottom row
}
// Left and right columns
for (let i = 0; i < numRows + 2; i++) {
paddedImage[i][0] = paddedImage[i][2]; // Left column
paddedImage[i][numCols + 1] = paddedImage[i][numCols - 1]; // Right column
paddedImage[i][paddedNumCols - 1] =
paddedImage[i][paddedNumCols - 3]; // Right column
}
return paddedImage;
}
private applyLaplacian(image: number[][]): number[][] {
const paddedImage: number[][] = this.padImage(image);
const numRows = image.length;
const numCols = image[0].length;
private applyLaplacian(
image: number[][],
direction: FaceDirection = "straight",
): number[][] {
const paddedImage: number[][] = this.padImage(
image,
undefined,
direction,
);
const numRows = paddedImage.length - 2;
const numCols = paddedImage[0].length - 2;
// Create an output image initialized to 0
const outputImage: number[][] = Array.from({ length: numRows }, () =>
@ -129,3 +167,45 @@ class LaplacianBlurDetectionService implements BlurDetectionService {
}
export default new LaplacianBlurDetectionService();
type FaceDirection = "left" | "right" | "straight";
const getFaceDirection = (face: Face): FaceDirection => {
const landmarks = face.detection.landmarks;
const leftEye = landmarks[0];
const rightEye = landmarks[1];
const nose = landmarks[2];
const leftMouth = landmarks[3];
const rightMouth = landmarks[4];
const eyeDistanceX = Math.abs(rightEye.x - leftEye.x);
const eyeDistanceY = Math.abs(rightEye.y - leftEye.y);
const mouthDistanceY = Math.abs(rightMouth.y - leftMouth.y);
const faceIsUpright =
Math.max(leftEye.y, rightEye.y) + 0.5 * eyeDistanceY < nose.y &&
nose.y + 0.5 * mouthDistanceY < Math.min(leftMouth.y, rightMouth.y);
const noseStickingOutLeft =
nose.x < Math.min(leftEye.x, rightEye.x) &&
nose.x < Math.min(leftMouth.x, rightMouth.x);
const noseStickingOutRight =
nose.x > Math.max(leftEye.x, rightEye.x) &&
nose.x > Math.max(leftMouth.x, rightMouth.x);
const noseCloseToLeftEye =
Math.abs(nose.x - leftEye.x) < 0.2 * eyeDistanceX;
const noseCloseToRightEye =
Math.abs(nose.x - rightEye.x) < 0.2 * eyeDistanceX;
// if (faceIsUpright && (noseStickingOutLeft || noseCloseToLeftEye)) {
if (noseStickingOutLeft || (faceIsUpright && noseCloseToLeftEye)) {
return "left";
// } else if (faceIsUpright && (noseStickingOutRight || noseCloseToRightEye)) {
} else if (noseStickingOutRight || (faceIsUpright && noseCloseToRightEye)) {
return "right";
}
return "straight";
};

View file

@ -0,0 +1,42 @@
import { ensureElectron } from "@/next/electron";
import { Collection } from "types/collection";
import { ElectronFile, FileWithCollection } from "types/upload";
export const setToUploadCollection = async (collections: Collection[]) => {
let collectionName: string = null;
/* collection being one suggest one of two things
1. Either the user has upload to a single existing collection
2. Created a new single collection to upload to
may have had multiple folder, but chose to upload
to one album
hence saving the collection name when upload collection count is 1
helps the info of user choosing this options
and on next upload we can directly start uploading to this collection
*/
if (collections.length === 1) {
collectionName = collections[0].name;
}
await ensureElectron().setPendingUploadCollection(collectionName);
};
export const updatePendingUploads = async (files: FileWithCollection[]) => {
const filePaths = [];
for (const fileWithCollection of files) {
if (fileWithCollection.isLivePhoto) {
filePaths.push(
(fileWithCollection.livePhotoAssets.image as ElectronFile).path,
(fileWithCollection.livePhotoAssets.video as ElectronFile).path,
);
} else {
filePaths.push((fileWithCollection.file as ElectronFile).path);
}
}
await ensureElectron().setPendingUploadFiles("files", filePaths);
};
export const cancelRemainingUploads = async () => {
const electron = ensureElectron();
await electron.setPendingUploadCollection(undefined);
await electron.setPendingUploadFiles("zips", []);
await electron.setPendingUploadFiles("files", []);
};

View file

@ -1,10 +1,10 @@
import { encodeLivePhoto } from "@/media/live-photo";
import log from "@/next/log";
import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker";
import { CustomError } from "@ente/shared/error";
import { Remote } from "comlink";
import { FILE_TYPE } from "constants/file";
import { LIVE_PHOTO_ASSET_SIZE_LIMIT } from "constants/upload";
import { encodeLivePhoto } from "services/livePhotoService";
import { getFileType } from "services/typeDetectionService";
import {
ElectronFile,
@ -14,12 +14,6 @@ import {
LivePhotoAssets,
ParsedMetadataJSONMap,
} from "types/upload";
import {
getFileExtensionWithDot,
getFileNameWithoutExtension,
isImageOrVideo,
splitFilenameAndExtension,
} from "utils/file";
import { getFileTypeFromExtensionForLivePhotoClustering } from "utils/file/livePhoto";
import { getUint8ArrayView } from "../readerService";
import { extractFileMetadata } from "./fileService";
@ -107,16 +101,16 @@ export async function readLivePhoto(
},
);
const image = await getUint8ArrayView(livePhotoAssets.image);
const imageData = await getUint8ArrayView(livePhotoAssets.image);
const video = await getUint8ArrayView(livePhotoAssets.video);
const videoData = await getUint8ArrayView(livePhotoAssets.video);
return {
filedata: await encodeLivePhoto({
image,
video,
imageNameTitle: livePhotoAssets.image.name,
videoNameTitle: livePhotoAssets.video.name,
imageFileName: livePhotoAssets.image.name,
imageData,
videoFileName: livePhotoAssets.video.name,
videoData,
}),
thumbnail,
hasStaticThumbnail,
@ -304,3 +298,28 @@ function removePotentialLivePhotoSuffix(
return filenameWithoutExtension;
}
}
function getFileNameWithoutExtension(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return filename;
else return filename.slice(0, lastDotPosition);
}
function getFileExtensionWithDot(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return "";
else return filename.slice(lastDotPosition);
}
function splitFilenameAndExtension(filename: string): [string, string] {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return [filename, null];
else
return [
filename.slice(0, lastDotPosition),
filename.slice(lastDotPosition + 1),
];
}
const isImageOrVideo = (fileType: FILE_TYPE) =>
[FILE_TYPE.IMAGE, FILE_TYPE.VIDEO].includes(fileType);

View file

@ -8,13 +8,16 @@ import { Events, eventBus } from "@ente/shared/events";
import { Remote } from "comlink";
import { UPLOAD_RESULT, UPLOAD_STAGES } from "constants/upload";
import isElectron from "is-electron";
import ImportService from "services/importService";
import {
cancelRemainingUploads,
updatePendingUploads,
} from "services/pending-uploads";
import {
getLocalPublicFiles,
getPublicCollectionUID,
} from "services/publicCollectionService";
import { getDisableCFUploadProxyFlag } from "services/userService";
import watchFolderService from "services/watchFolder/watchFolderService";
import watcher from "services/watch";
import { Collection } from "types/collection";
import { EncryptedEnteFile, EnteFile } from "types/file";
import { SetFiles } from "types/gallery";
@ -177,7 +180,7 @@ class UploadManager {
if (e.message === CustomError.UPLOAD_CANCELLED) {
if (isElectron()) {
this.remainingFiles = [];
await ImportService.cancelRemainingUploads();
await cancelRemainingUploads();
}
} else {
log.error("uploading failed with error", e);
@ -387,11 +390,13 @@ class UploadManager {
uploadedFile: EncryptedEnteFile,
) {
if (isElectron()) {
await watchFolderService.onFileUpload(
fileUploadResult,
fileWithCollection,
uploadedFile,
);
if (watcher.isUploadRunning()) {
await watcher.onFileUpload(
fileUploadResult,
fileWithCollection,
uploadedFile,
);
}
}
}
@ -431,12 +436,12 @@ class UploadManager {
this.remainingFiles = this.remainingFiles.filter(
(file) => !areFileWithCollectionsSame(file, fileWithCollection),
);
await ImportService.updatePendingUploads(this.remainingFiles);
await updatePendingUploads(this.remainingFiles);
}
}
public shouldAllowNewUpload = () => {
return !this.uploadInProgress || watchFolderService.isUploadRunning();
return !this.uploadInProgress || watcher.isUploadRunning();
};
}

View file

@ -0,0 +1,647 @@
/**
* @file Interface with the Node.js layer of our desktop app to provide the
* watch folders functionality.
*/
import { ensureElectron } from "@/next/electron";
import { basename, dirname } from "@/next/file";
import log from "@/next/log";
import type {
CollectionMapping,
FolderWatch,
FolderWatchSyncedFile,
} from "@/next/types/ipc";
import { UPLOAD_RESULT } from "constants/upload";
import debounce from "debounce";
import uploadManager from "services/upload/uploadManager";
import { Collection } from "types/collection";
import { EncryptedEnteFile } from "types/file";
import { ElectronFile, FileWithCollection } from "types/upload";
import { groupFilesBasedOnCollectionID } from "utils/file";
import { isHiddenFile } from "utils/upload";
import { removeFromCollection } from "./collectionService";
import { getLocalFiles } from "./fileService";
/**
* Watch for file system folders and automatically update the corresponding Ente
* collections.
*
* This class relies on APIs exposed over the Electron IPC layer, and thus only
* works when we're running inside our desktop app.
*/
class FolderWatcher {
/** Pending file system events that we need to process. */
private eventQueue: WatchEvent[] = [];
/** The folder watch whose event we're currently processing */
private activeWatch: FolderWatch | undefined;
/**
* If the file system directory corresponding to the (root) folder path of a
* folder watch is deleted on disk, we note down that in this queue so that
* we can ignore any file system events that come for it next.
*
* TODO: is this really needed? the mappings are pre-checked first.
*/
private deletedFolderPaths: string[] = [];
/** `true` if we are using the uploader. */
private uploadRunning = false;
/** `true` if we are temporarily paused to let a user upload go through. */
private isPaused = false;
private filePathToUploadedFileIDMap = new Map<string, EncryptedEnteFile>();
private unUploadableFilePaths = new Set<string>();
/**
* A function to call when we want to enqueue a new upload of the given list
* of file paths to the given Ente collection.
*
* This is passed as a param to {@link init}.
*/
private upload: (collectionName: string, filePaths: string[]) => void;
/**
* A function to call when we want to sync with the backend.
*
* This is passed as a param to {@link init}.
*/
private syncWithRemote: () => void;
/** A helper function that debounces invocations of {@link runNextEvent}. */
private debouncedRunNextEvent: () => void;
constructor() {
this.debouncedRunNextEvent = debounce(() => this.runNextEvent(), 1000);
}
/**
* Initialize the watcher and start processing file system events.
*
* This is only called when we're running in the context of our desktop app.
*
* The caller provides us with the hooks we can use to actually upload the
* files, and to sync with remote (say after deletion).
*/
init(
upload: (collectionName: string, filePaths: string[]) => void,
syncWithRemote: () => void,
) {
this.upload = upload;
this.syncWithRemote = syncWithRemote;
this.registerListeners();
this.syncWithDisk();
}
/** `true` if we are currently using the uploader */
isUploadRunning() {
return this.uploadRunning;
}
/** `true` if syncing has been temporarily paused */
isSyncPaused() {
return this.isPaused;
}
/**
* Temporarily pause syncing and cancel any running uploads.
*
* This frees up the uploader for handling user initated uploads.
*/
pauseRunningSync() {
this.isPaused = true;
uploadManager.cancelRunningUpload();
}
/**
* Resume from a temporary pause, resyncing from disk.
*
* Sibling of {@link pauseRunningSync}.
*/
resumePausedSync() {
this.isPaused = false;
this.syncWithDisk();
}
/** Return the list of folders we are watching for changes. */
async getWatches(): Promise<FolderWatch[]> {
return await ensureElectron().watch.get();
}
/**
* Return true if we are currently syncing files that belong to the given
* {@link folderPath}.
*/
isSyncingFolder(folderPath: string) {
return this.activeWatch?.folderPath == folderPath;
}
/**
* Add a new folder watch for the given root {@link folderPath}
*
* @param mapping The {@link CollectionMapping} to use to decide which
* collection do files belonging to nested directories go to.
*
* @returns The updated list of watches.
*/
async addWatch(folderPath: string, mapping: CollectionMapping) {
const watches = await ensureElectron().watch.add(folderPath, mapping);
this.syncWithDisk();
return watches;
}
/**
* Remove the folder watch for the given root {@link folderPath}.
*
* @returns The updated list of watches.
*/
async removeWatch(folderPath: string) {
return await ensureElectron().watch.remove(folderPath);
}
private async syncWithDisk() {
try {
const watches = await this.getWatches();
if (!watches) return;
this.eventQueue = [];
const events = await deduceEvents(watches);
log.info(`Folder watch deduced ${events.length} events`);
this.eventQueue = this.eventQueue.concat(events);
this.debouncedRunNextEvent();
} catch (e) {
log.error("Ignoring error while syncing watched folders", e);
}
}
pushEvent(event: WatchEvent) {
this.eventQueue.push(event);
log.info("Folder watch event", event);
this.debouncedRunNextEvent();
}
private registerListeners() {
const watch = ensureElectron().watch;
// [Note: File renames during folder watch]
//
// Renames come as two file system events - an `onAddFile` + an
// `onRemoveFile` - in an arbitrary order.
watch.onAddFile((path: string, watch: FolderWatch) => {
this.pushEvent({
action: "upload",
collectionName: collectionNameForPath(path, watch),
folderPath: watch.folderPath,
filePath: path,
});
});
watch.onRemoveFile((path: string, watch: FolderWatch) => {
this.pushEvent({
action: "trash",
collectionName: collectionNameForPath(path, watch),
folderPath: watch.folderPath,
filePath: path,
});
});
watch.onRemoveDir((path: string, watch: FolderWatch) => {
if (path == watch.folderPath) {
log.info(
`Received file system delete event for a watched folder at ${path}`,
);
this.deletedFolderPaths.push(path);
}
});
}
private async runNextEvent() {
if (this.eventQueue.length == 0 || this.activeWatch || this.isPaused)
return;
const skip = (reason: string) => {
log.info(`Ignoring event since ${reason}`);
this.debouncedRunNextEvent();
};
const event = this.dequeueClubbedEvent();
log.info(
`Processing ${event.action} event for folder watch ${event.folderPath} (collectionName ${event.collectionName}, ${event.filePaths.length} files)`,
);
const watch = (await this.getWatches()).find(
(watch) => watch.folderPath == event.folderPath,
);
if (!watch) {
// Possibly stale
skip(`no folder watch for found for ${event.folderPath}`);
return;
}
if (event.action === "upload") {
const paths = pathsToUpload(event.filePaths, watch);
if (paths.length == 0) {
skip("none of the files need uploading");
return;
}
// Here we pass control to the uploader. When the upload is done,
// the uploader will notify us by calling allFileUploadsDone.
this.activeWatch = watch;
this.uploadRunning = true;
const collectionName = event.collectionName;
log.info(
`Folder watch requested upload of ${paths.length} files to collection ${collectionName}`,
);
this.upload(collectionName, paths);
} else {
if (this.pruneFileEventsFromDeletedFolderPaths()) {
skip("event was from a deleted folder path");
return;
}
const [removed, rest] = watch.syncedFiles.reduce(
([removed, rest], { path }) => {
(event.filePaths.includes(path) ? rest : removed).push(
watch,
);
return [removed, rest];
},
[[], []],
);
this.activeWatch = watch;
await this.moveToTrash(removed);
await ensureElectron().watch.updateSyncedFiles(
rest,
watch.folderPath,
);
this.activeWatch = undefined;
this.debouncedRunNextEvent();
}
}
/**
* Batch the next run of events with the same action, collection and folder
* path into a single clubbed event that contains the list of all effected
* file paths from the individual events.
*/
private dequeueClubbedEvent(): ClubbedWatchEvent | undefined {
const event = this.eventQueue.shift();
if (!event) return undefined;
const filePaths = [event.filePath];
while (
this.eventQueue.length > 0 &&
event.action === this.eventQueue[0].action &&
event.folderPath === this.eventQueue[0].folderPath &&
event.collectionName === this.eventQueue[0].collectionName
) {
filePaths.push(this.eventQueue[0].filePath);
this.eventQueue.shift();
}
return { ...event, filePaths };
}
/**
* Callback invoked by the uploader whenever a file we requested to
* {@link upload} gets uploaded.
*/
async onFileUpload(
fileUploadResult: UPLOAD_RESULT,
fileWithCollection: FileWithCollection,
file: EncryptedEnteFile,
) {
if (
[
UPLOAD_RESULT.ADDED_SYMLINK,
UPLOAD_RESULT.UPLOADED,
UPLOAD_RESULT.UPLOADED_WITH_STATIC_THUMBNAIL,
UPLOAD_RESULT.ALREADY_UPLOADED,
].includes(fileUploadResult)
) {
if (fileWithCollection.isLivePhoto) {
this.filePathToUploadedFileIDMap.set(
(fileWithCollection.livePhotoAssets.image as ElectronFile)
.path,
file,
);
this.filePathToUploadedFileIDMap.set(
(fileWithCollection.livePhotoAssets.video as ElectronFile)
.path,
file,
);
} else {
this.filePathToUploadedFileIDMap.set(
(fileWithCollection.file as ElectronFile).path,
file,
);
}
} else if (
[UPLOAD_RESULT.UNSUPPORTED, UPLOAD_RESULT.TOO_LARGE].includes(
fileUploadResult,
)
) {
if (fileWithCollection.isLivePhoto) {
this.unUploadableFilePaths.add(
(fileWithCollection.livePhotoAssets.image as ElectronFile)
.path,
);
this.unUploadableFilePaths.add(
(fileWithCollection.livePhotoAssets.video as ElectronFile)
.path,
);
} else {
this.unUploadableFilePaths.add(
(fileWithCollection.file as ElectronFile).path,
);
}
}
}
/**
* Callback invoked by the uploader whenever all the files we requested to
* {@link upload} get uploaded.
*/
async allFileUploadsDone(
filesWithCollection: FileWithCollection[],
collections: Collection[],
) {
const electron = ensureElectron();
const watch = this.activeWatch;
log.debug(() =>
JSON.stringify({
f: "watch/allFileUploadsDone",
filesWithCollection,
collections,
watch,
}),
);
const { syncedFiles, ignoredFiles } =
this.parseAllFileUploadsDone(filesWithCollection);
log.debug(() =>
JSON.stringify({
f: "watch/allFileUploadsDone",
syncedFiles,
ignoredFiles,
}),
);
if (syncedFiles.length > 0)
await electron.watch.updateSyncedFiles(
watch.syncedFiles.concat(syncedFiles),
watch.folderPath,
);
if (ignoredFiles.length > 0)
await electron.watch.updateIgnoredFiles(
watch.ignoredFiles.concat(ignoredFiles),
watch.folderPath,
);
this.activeWatch = undefined;
this.uploadRunning = false;
this.debouncedRunNextEvent();
}
private parseAllFileUploadsDone(filesWithCollection: FileWithCollection[]) {
const syncedFiles: FolderWatch["syncedFiles"] = [];
const ignoredFiles: FolderWatch["ignoredFiles"] = [];
for (const fileWithCollection of filesWithCollection) {
if (fileWithCollection.isLivePhoto) {
const imagePath = (
fileWithCollection.livePhotoAssets.image as ElectronFile
).path;
const videoPath = (
fileWithCollection.livePhotoAssets.video as ElectronFile
).path;
if (
this.filePathToUploadedFileIDMap.has(imagePath) &&
this.filePathToUploadedFileIDMap.has(videoPath)
) {
const imageFile = {
path: imagePath,
uploadedFileID:
this.filePathToUploadedFileIDMap.get(imagePath).id,
collectionID:
this.filePathToUploadedFileIDMap.get(imagePath)
.collectionID,
};
const videoFile = {
path: videoPath,
uploadedFileID:
this.filePathToUploadedFileIDMap.get(videoPath).id,
collectionID:
this.filePathToUploadedFileIDMap.get(videoPath)
.collectionID,
};
syncedFiles.push(imageFile);
syncedFiles.push(videoFile);
log.debug(
() =>
`added image ${JSON.stringify(
imageFile,
)} and video file ${JSON.stringify(
videoFile,
)} to uploadedFiles`,
);
} else if (
this.unUploadableFilePaths.has(imagePath) &&
this.unUploadableFilePaths.has(videoPath)
) {
ignoredFiles.push(imagePath);
ignoredFiles.push(videoPath);
log.debug(
() =>
`added image ${imagePath} and video file ${videoPath} to rejectedFiles`,
);
}
this.filePathToUploadedFileIDMap.delete(imagePath);
this.filePathToUploadedFileIDMap.delete(videoPath);
} else {
const filePath = (fileWithCollection.file as ElectronFile).path;
if (this.filePathToUploadedFileIDMap.has(filePath)) {
const file = {
path: filePath,
uploadedFileID:
this.filePathToUploadedFileIDMap.get(filePath).id,
collectionID:
this.filePathToUploadedFileIDMap.get(filePath)
.collectionID,
};
syncedFiles.push(file);
log.debug(() => `added file ${JSON.stringify(file)}`);
} else if (this.unUploadableFilePaths.has(filePath)) {
ignoredFiles.push(filePath);
log.debug(() => `added file ${filePath} to rejectedFiles`);
}
this.filePathToUploadedFileIDMap.delete(filePath);
}
}
return { syncedFiles, ignoredFiles };
}
private pruneFileEventsFromDeletedFolderPaths() {
const deletedFolderPath = this.deletedFolderPaths.shift();
if (!deletedFolderPath) return false;
this.eventQueue = this.eventQueue.filter(
(event) => !event.filePath.startsWith(deletedFolderPath),
);
return true;
}
private async moveToTrash(syncedFiles: FolderWatch["syncedFiles"]) {
try {
const files = await getLocalFiles();
const toTrashFilesMap = new Map<number, FolderWatchSyncedFile>();
for (const file of syncedFiles) {
toTrashFilesMap.set(file.uploadedFileID, file);
}
const filesToTrash = files.filter((file) => {
if (toTrashFilesMap.has(file.id)) {
const fileToTrash = toTrashFilesMap.get(file.id);
if (fileToTrash.collectionID === file.collectionID) {
return true;
}
}
});
const groupFilesByCollectionId =
groupFilesBasedOnCollectionID(filesToTrash);
for (const [
collectionID,
filesToTrash,
] of groupFilesByCollectionId.entries()) {
await removeFromCollection(collectionID, filesToTrash);
}
this.syncWithRemote();
} catch (e) {
log.error("error while trashing by IDs", e);
}
}
}
/** The singleton instance of the {@link FolderWatcher}. */
const watcher = new FolderWatcher();
export default watcher;
/**
* A file system watch event encapsulates a change that has occurred on disk
* that needs us to take some action within Ente to synchronize with the user's
* Ente collections.
*
* Events get added in two ways:
*
* - When the app starts, it reads the current state of files on disk and
* compares that with its last known state to determine what all events it
* missed. This is easier than it sounds as we have only two events: add and
* remove.
*
* - When the app is running, it gets live notifications from our file system
* watcher (from the Node.js layer) about changes that have happened on disk,
* which the app then enqueues onto the event queue if they pertain to the
* files we're interested in.
*/
interface WatchEvent {
/** The action to take */
action: "upload" | "trash";
/** The path of the root folder corresponding to the {@link FolderWatch}. */
folderPath: string;
/** The name of the Ente collection the file belongs to. */
collectionName: string;
/** The absolute path to the file under consideration. */
filePath: string;
}
/**
* A composite of multiple {@link WatchEvent}s that only differ in their
* {@link filePath}.
*
* When processing events, we combine a run of events with the same
* {@link action}, {@link folderPath} and {@link collectionName}. This allows us
* to process all the affected {@link filePaths} in one shot.
*/
type ClubbedWatchEvent = Omit<WatchEvent, "filePath"> & {
filePaths: string[];
};
/**
* Determine which events we need to process to synchronize the watched on-disk
* folders to their corresponding collections.
*/
const deduceEvents = async (watches: FolderWatch[]): Promise<WatchEvent[]> => {
const electron = ensureElectron();
const events: WatchEvent[] = [];
for (const watch of watches) {
const folderPath = watch.folderPath;
const filePaths = await electron.watch.findFiles(folderPath);
// Files that are on disk but not yet synced.
for (const filePath of pathsToUpload(filePaths, watch))
events.push({
action: "upload",
folderPath,
collectionName: collectionNameForPath(filePath, watch),
filePath,
});
// Previously synced files that are no longer on disk.
for (const filePath of pathsToRemove(filePaths, watch))
events.push({
action: "trash",
folderPath,
collectionName: collectionNameForPath(filePath, watch),
filePath,
});
}
return events;
};
/**
* Filter out hidden files and previously synced or ignored paths from
* {@link paths} to get the list of paths that need to be uploaded to the Ente
* collection.
*/
const pathsToUpload = (paths: string[], watch: FolderWatch) =>
paths
// Filter out hidden files (files whose names begins with a dot)
.filter((path) => !isHiddenFile(path))
// Files that are on disk but not yet synced or ignored.
.filter((path) => !isSyncedOrIgnoredPath(path, watch));
/**
* Return the paths to previously synced files that are no longer on disk and so
* must be removed from the Ente collection.
*/
const pathsToRemove = (paths: string[], watch: FolderWatch) =>
watch.syncedFiles
.map((f) => f.path)
.filter((path) => !paths.includes(path));
const isSyncedOrIgnoredPath = (path: string, watch: FolderWatch) =>
watch.ignoredFiles.includes(path) ||
watch.syncedFiles.find((f) => f.path === path);
const collectionNameForPath = (path: string, watch: FolderWatch) =>
watch.collectionMapping == "root"
? dirname(watch.folderPath)
: parentDirectoryName(path);
const parentDirectoryName = (path: string) => basename(dirname(path));

Some files were not shown because too many files have changed in this diff Show more