Merge branch 'main' into cast

This commit is contained in:
Neeraj Gupta 2024-04-23 14:35:37 +05:30
commit 8fd330c304
236 changed files with 7050 additions and 6067 deletions

View file

@ -0,0 +1,56 @@
name: "Internal Release - Photos"
on:
workflow_dispatch: # Allow manually running the action
env:
FLUTTER_VERSION: "3.19.3"
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: mobile
steps:
- name: Checkout code and submodules
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup JDK 17
uses: actions/setup-java@v1
with:
java-version: 17
- name: Install Flutter ${{ env.FLUTTER_VERSION }}
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Setup keys
uses: timheuer/base64-to-file@v1
with:
fileName: "keystore/ente_photos_key.jks"
encodedString: ${{ secrets.SIGNING_KEY_PHOTOS }}
- name: Build PlayStore AAB
run: |
flutter build appbundle --release --flavor playstore
env:
SIGNING_KEY_PATH: "/home/runner/work/_temp/keystore/ente_photos_key.jks"
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS_PHOTOS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD_PHOTOS }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD_PHOTOS }}
- name: Upload AAB to PlayStore
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
packageName: io.ente.photos
releaseFiles: mobile/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab
track: internal

View file

@ -9,7 +9,7 @@ on:
- "photos-v*"
env:
FLUTTER_VERSION: "3.19.5"
FLUTTER_VERSION: "3.19.3"
jobs:
build:
@ -25,6 +25,11 @@ jobs:
with:
submodules: recursive
- name: Setup JDK 17
uses: actions/setup-java@v1
with:
java-version: 17
- name: Install Flutter ${{ env.FLUTTER_VERSION }}
uses: subosito/flutter-action@v2
with:

View file

@ -78,12 +78,14 @@
"data": "Datei",
"importCodes": "Codes importieren",
"importTypePlainText": "Klartext",
"importTypeEnteEncrypted": "Verschlüsselter Ente-Export",
"passwordForDecryptingExport": "Passwort um den Export zu entschlüsseln",
"passwordEmptyError": "Passwort kann nicht leer sein",
"importFromApp": "Importiere Codes von {appName}",
"importGoogleAuthGuide": "Exportiere deine Accounts von Google Authenticator zu einem QR-Code, durch die \"Konten übertragen\" Option. Scanne den QR-Code danach mit einem anderen Gerät.\n\nTipp: Du kannst die Kamera eines Laptops verwenden, um ein Foto den dem QR-Code zu erstellen.",
"importSelectJsonFile": "Wähle eine JSON-Datei",
"importSelectAppExport": "{appName} Exportdatei auswählen",
"importEnteEncGuide": "Wähle die von Ente exportierte, verschlüsselte JSON-Datei",
"importRaivoGuide": "Verwenden Sie die Option \"Export OTPs to Zip archive\" in den Raivo-Einstellungen.\n\nEntpacken Sie die Zip-Datei und importieren Sie die JSON-Datei.",
"importBitwardenGuide": "Verwenden Sie die Option \"Tresor exportieren\" innerhalb der Bitwarden Tools und importieren Sie die unverschlüsselte JSON-Datei.",
"importAegisGuide": "Verwenden Sie die Option \"Tresor exportieren\" in den Aegis-Einstellungen.\n\nFalls Ihr Tresor verschlüsselt ist, müssen Sie das Passwort für den Tresor eingeben, um ihn zu entschlüsseln.",
@ -113,18 +115,22 @@
"copied": "Kopiert",
"pleaseTryAgain": "Bitte versuchen Sie es erneut",
"existingUser": "Bestehender Benutzer",
"newUser": "Neu bei Ente",
"delete": "Löschen",
"enterYourPasswordHint": "Geben Sie Ihr Passwort ein",
"forgotPassword": "Passwort vergessen",
"oops": "Hopla",
"suggestFeatures": "Features vorschlagen",
"faq": "FAQ",
"faq_q_1": "Wie sicher ist Auth?",
"faq_a_1": "Alle Codes, die du über Auth sicherst, werden Ende-zu-Ende-verschlüsselt gespeichert. Das bedeutet, dass nur du auf deine Codes zugreifen kannst. Unsere Anwendungen sind quelloffen und unsere Kryptografie wurde extern geprüft.",
"faq_q_2": "Kann ich auf meine Codes auf dem Desktop zugreifen?",
"faq_a_2": "Sie können auf Ihre Codes im Web via auth.ente.io zugreifen.",
"faq_q_3": "Wie kann ich Codes löschen?",
"faq_a_3": "Sie können einen Code löschen, indem Sie auf dem Code nach links wischen.",
"faq_q_4": "Wie kann ich das Projekt unterstützen?",
"faq_a_4": "Sie können die Entwicklung dieses Projekts unterstützen, indem Sie unsere Fotos-App auf ente.io abonnieren.",
"faq_q_5": "Wie kann ich die FaceID-Sperre in Auth aktivieren",
"faq_a_5": "Sie können FaceID unter Einstellungen → Sicherheit → Sperrbildschirm aktivieren.",
"somethingWentWrongMessage": "Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut",
"leaveFamily": "Familie verlassen",
@ -193,6 +199,10 @@
"recoveryKeySaveDescription": "Wir speichern diesen Schlüssel nicht. Sichern sie dieses diesen Schlüssel bestehend aus 24 Wörtern an einem sicheren Platz.",
"doThisLater": "Auf später verschieben",
"saveKey": "Schlüssel speichern",
"save": "Speichern",
"send": "Senden",
"saveOrSendDescription": "Möchtest du dies in deinem Speicher (standardmäßig im Ordner Downloads) oder an andere Apps senden?",
"saveOnlyDescription": "Möchtest du dies in deinem Speicher (standardmäßig im Ordner Downloads) speichern?",
"back": "Zurück",
"createAccount": "Account erstellen",
"passwordStrength": "Passwortstärke: {passwordStrengthValue}",
@ -340,6 +350,7 @@
"deleteCodeAuthMessage": "Authentifizieren, um Code zu löschen",
"showQRAuthMessage": "Authentifizieren, um QR-Code anzuzeigen",
"confirmAccountDeleteTitle": "Kontolöschung bestätigen",
"confirmAccountDeleteMessage": "Dieses Konto ist mit anderen Ente-Apps verknüpft, falls du welche verwendest.\n\nDeine hochgeladenen Daten werden in allen Ente-Apps zur Löschung vorgemerkt und dein Konto wird endgültig gelöscht.",
"androidBiometricHint": "Identität bestätigen",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."
@ -400,6 +411,7 @@
"doNotSignOut": "Nicht abmelden",
"hearUsWhereTitle": "Wie hast du von Ente erfahren? (optional)",
"hearUsExplanation": "Wir tracken keine App-Installationen. Es würde uns jedoch helfen, wenn du uns mitteilst, wie du von uns erfahren hast!",
"recoveryKeySaved": "Wiederherstellungsschlüssel im Downloads-Ordner gespeichert!",
"waitingForBrowserRequest": "Warten auf Browseranfrage...",
"waitingForVerification": "Warte auf Bestätigung...",
"passkey": "Passkey",

View file

@ -190,6 +190,7 @@
"recoveryKeySaveDescription": "Non memorizziamo questa chiave, per favore salva questa chiave di 24 parole in un posto sicuro.",
"doThisLater": "Fallo più tardi",
"saveKey": "Salva chiave",
"save": "Salva",
"back": "Indietro",
"createAccount": "Crea account",
"passwordStrength": "Forza password: {passwordStrengthValue}",
@ -396,5 +397,6 @@
"signOutOtherDevices": "Esci dagli altri dispositivi",
"doNotSignOut": "Non uscire",
"hearUsWhereTitle": "Dove hai sentito parlare di Ente? (opzionale)",
"hearUsExplanation": "Non teniamo traccia delle installazioni dell'app. Sarebbe utile se ci dicessi dove ci hai trovato!"
"hearUsExplanation": "Non teniamo traccia delle installazioni dell'app. Sarebbe utile se ci dicessi dove ci hai trovato!",
"passkey": "Passkey"
}

View file

@ -78,12 +78,14 @@
"data": "Dados",
"importCodes": "Importar códigos",
"importTypePlainText": "Texto simples",
"importTypeEnteEncrypted": "Exportação Ente criptografada",
"passwordForDecryptingExport": "Senha para descriptografar a exportação",
"passwordEmptyError": "O campo senha não pode estar vazio",
"importFromApp": "Importar códigos do {appName}",
"importGoogleAuthGuide": "Exporte suas contas do Google Authenticator para um QR code usando a opção \"Transferir contas\". Então, usando outro dispositivo, escaneie o QR code.\n\nDica: Você pode usar a câmera do seu notebook para fotografar o QR code.",
"importSelectJsonFile": "Selecione o arquivo JSON",
"importSelectAppExport": "Selecione o arquivo de exportação do aplicativo {appName}",
"importEnteEncGuide": "Selecione o arquivo JSON criptografado exportado do Ente",
"importRaivoGuide": "Use a opção \"Exportar OTPs para arquivo Zip\" nas configurações do Raivo.\n\nExtraia o arquivo zip e importe o arquivo JSON.",
"importBitwardenGuide": "Use a opção \"Exportar cofre\" nas configurações do Bitwarden e importe o arquivo JSON não criptografado.",
"importAegisGuide": "Use a opção \"Exportar cofre\" nas Configurações do Aegis.\n\nSe o seu cofre estiver criptografado, você precisará inserir a senha do cofre para descriptografá-lo.",
@ -113,18 +115,22 @@
"copied": "Copiado",
"pleaseTryAgain": "Por favor, tente novamente",
"existingUser": "Usuário Existente",
"newUser": "Novo no Ente",
"delete": "Excluir",
"enterYourPasswordHint": "Insira sua senha",
"forgotPassword": "Esqueci a senha",
"oops": "Oops",
"suggestFeatures": "Sugerir funcionalidades",
"faq": "Perguntas frequentes",
"faq_q_1": "Quão seguro é o Auth?",
"faq_a_1": "Todos os códigos que você faz backup via Auth são armazenados criptografados de ponta a ponta. Isso significa que somente você pode acessar seus códigos. Nossos aplicativos são de código aberto e nossa criptografia foi auditada externamente.",
"faq_q_2": "Eu posso acessar meus códigos no computador?",
"faq_a_2": "Você pode acessar seus códigos na web em auth.ente.io.",
"faq_q_3": "Como faço para excluir códigos?",
"faq_a_3": "Você pode excluir um código deslizando para a esquerda sobre esse item.",
"faq_q_4": "Como posso apoiar este projeto?",
"faq_a_4": "Você pode apoiar o desenvolvimento deste projeto assinando nosso aplicativo de Fotos em ente.io.",
"faq_q_5": "Como posso ativar o bloqueio facial no Auth",
"faq_a_5": "Você pode ativar o bloqueio facial em Configurações → Segurança → Tela de bloqueio.",
"somethingWentWrongMessage": "Algo deu errado. Por favor, tente outra vez",
"leaveFamily": "Sair da família",
@ -344,6 +350,7 @@
"deleteCodeAuthMessage": "Autenticar para excluir o código",
"showQRAuthMessage": "Autenticar para mostrar o QR code",
"confirmAccountDeleteTitle": "Confirmar exclusão de conta",
"confirmAccountDeleteMessage": "Esta conta está vinculada a outros aplicativos Ente, se você usa algum.\n\nSeus dados enviados, em todos os aplicativos Ente, serão agendados para exclusão, e sua conta será excluída permanentemente.",
"androidBiometricHint": "Verificar identidade",
"@androidBiometricHint": {
"description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters."

View file

@ -61,6 +61,7 @@
"incorrectPasswordTitle": "Felaktigt lösenord",
"welcomeBack": "Välkommen tillbaka!",
"changePassword": "Ändra lösenord",
"importCodes": "Importera koder",
"cancel": "Avbryt",
"yes": "Ja",
"no": "Nej",

View file

@ -94,12 +94,12 @@ Some extra ones specific to the code here are:
### Format conversion
The main tool we use is for arbitrary conversions is FFMPEG. To bundle a
The main tool we use is for arbitrary conversions is ffmpeg. To bundle a
(platform specific) static binary of ffmpeg with our app, we use
[ffmpeg-static](https://github.com/eugeneware/ffmpeg-static).
> There is a significant (~20x) speed difference between using the compiled
> FFMPEG binary and using the WASM one (that our renderer process already has).
> ffmpeg binary and using the wasm one (that our renderer process already has).
> Which is why we bundle it to speed up operations on the desktop app.
In addition, we also bundle a static Linux binary of imagemagick in our extra

View file

@ -44,8 +44,8 @@
"electron-builder-notarize": "^1.5",
"eslint": "^8",
"prettier": "^3",
"prettier-plugin-organize-imports": "^3.2",
"prettier-plugin-packagejson": "^2.4",
"prettier-plugin-organize-imports": "^3",
"prettier-plugin-packagejson": "^2",
"shx": "^0.3",
"typescript": "^5"
},

View file

@ -24,11 +24,12 @@ 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";
import { isDev } from "./main/utils-electron";
/**
* The URL where the renderer HTML is being served from.
@ -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();
@ -206,6 +205,8 @@ const createMainWindow = async () => {
window.webContents.reload();
});
// "The unresponsive event is fired when Chromium detects that your
// webContents is not responding to input messages for > 30 seconds."
window.webContents.on("unresponsive", () => {
log.error(
"Main window's webContents are unresponsive, will restart the renderer process",
@ -296,6 +297,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 +327,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 +342,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

@ -11,18 +11,7 @@ export function handleDownloads(mainWindow: BrowserWindow) {
});
}
export function handleExternalLinks(mainWindow: BrowserWindow) {
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(rendererURL)) {
shell.openExternal(url);
return { action: "deny" };
} else {
return { action: "allow" };
}
});
}
export function getUniqueSavePath(filename: string, directory: string): string {
function getUniqueSavePath(filename: string, directory: string): string {
let uniqueFileSavePath = path.join(directory, filename);
const { name: filenameWithoutExtension, ext: extension } =
path.parse(filename);
@ -42,12 +31,15 @@ export function getUniqueSavePath(filename: string, directory: string): string {
return uniqueFileSavePath;
}
function lowerCaseHeaders(responseHeaders: Record<string, string[]>) {
const headers: Record<string, string[]> = {};
for (const key of Object.keys(responseHeaders)) {
headers[key.toLowerCase()] = responseHeaders[key];
}
return headers;
export function handleExternalLinks(mainWindow: BrowserWindow) {
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (!url.startsWith(rendererURL)) {
shell.openExternal(url);
return { action: "deny" };
} else {
return { action: "allow" };
}
});
}
export function addAllowOriginHeader(mainWindow: BrowserWindow) {
@ -61,3 +53,11 @@ export function addAllowOriginHeader(mainWindow: BrowserWindow) {
},
);
}
function lowerCaseHeaders(responseHeaders: Record<string, string[]>) {
const headers: Record<string, string[]> = {};
for (const key of Object.keys(responseHeaders)) {
headers[key.toLowerCase()] = responseHeaders[key];
}
return headers;
}

View file

@ -10,7 +10,11 @@
import type { FSWatcher } from "chokidar";
import { ipcMain } from "electron/main";
import type { ElectronFile, FILE_PATH_TYPE, WatchMapping } from "../types/ipc";
import type {
CollectionMapping,
FolderWatch,
PendingUploads,
} from "../types/ipc";
import {
selectDirectory,
showUploadDirsDialog,
@ -19,13 +23,13 @@ import {
} from "./dialogs";
import {
fsExists,
fsIsDir,
fsMkdirIfNeeded,
fsReadTextFile,
fsRename,
fsRm,
fsRmdir,
fsWriteFile,
isFolder,
} from "./fs";
import { logToDisk } from "./log";
import {
@ -34,13 +38,13 @@ import {
updateAndRestart,
updateOnNextRestart,
} from "./services/app-update";
import { runFFmpegCmd } from "./services/ffmpeg";
import { convertToJPEG, generateImageThumbnail } from "./services/convert";
import { ffmpegExec } from "./services/ffmpeg";
import { getDirFiles } from "./services/fs";
import {
convertToJPEG,
generateImageThumbnail,
} from "./services/imageProcessor";
import { clipImageEmbedding, clipTextEmbedding } from "./services/ml-clip";
clipImageEmbedding,
clipTextEmbeddingIfAvailable,
} from "./services/ml-clip";
import { detectFaces, faceEmbedding } from "./services/ml-face";
import {
clearStores,
@ -49,18 +53,19 @@ 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";
import { openDirectory, openLogDirectory } from "./utils-electron";
/**
* Listen for IPC events sent/invoked by the renderer process, and route them to
@ -132,10 +137,12 @@ export const attachIPCHandlers = () => {
fsWriteFile(path, contents),
);
ipcMain.handle("fsIsDir", (_, dirPath: string) => fsIsDir(dirPath));
// - Conversion
ipcMain.handle("convertToJPEG", (_, fileData, filename) =>
convertToJPEG(fileData, filename),
ipcMain.handle("convertToJPEG", (_, fileName, imageData) =>
convertToJPEG(fileName, imageData),
);
ipcMain.handle(
@ -145,14 +152,14 @@ export const attachIPCHandlers = () => {
);
ipcMain.handle(
"runFFmpegCmd",
"ffmpegExec",
(
_,
cmd: string[],
inputFile: File | ElectronFile,
command: string[],
inputDataOrPath: Uint8Array | string,
outputFileName: string,
dontTimeout?: boolean,
) => runFFmpegCmd(cmd, inputFile, outputFileName, dontTimeout),
timeoutMS: number,
) => ffmpegExec(command, inputDataOrPath, outputFileName, timeoutMS),
);
// - ML
@ -161,8 +168,8 @@ export const attachIPCHandlers = () => {
clipImageEmbedding(jpegImageData),
);
ipcMain.handle("clipTextEmbedding", (_, text: string) =>
clipTextEmbedding(text),
ipcMain.handle("clipTextEmbeddingIfAvailable", (_, text: string) =>
clipTextEmbeddingIfAvailable(text),
);
ipcMain.handle("detectFaces", (_, input: Float32Array) =>
@ -183,28 +190,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 +218,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

@ -1,6 +1,6 @@
import log from "electron-log";
import util from "node:util";
import { isDev } from "./util";
import { isDev } from "./utils-electron";
/**
* Initialize logging in the main process.

View file

@ -7,9 +7,9 @@ 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";
import { isDev, openLogDirectory } from "./utils-electron";
/** Create and return the entries in the app's main menu bar */
export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
@ -23,6 +23,9 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
const macOSOnly = (options: MenuItemConstructorOptions[]) =>
process.platform == "darwin" ? options : [];
const devOnly = (options: MenuItemConstructorOptions[]) =>
isDev ? options : [];
const handleCheckForUpdates = () => forceCheckForAppUpdates(mainWindow);
const handleViewChangelog = () =>
@ -139,7 +142,9 @@ export const createApplicationMenu = async (mainWindow: BrowserWindow) => {
label: "View",
submenu: [
{ label: "Reload", role: "reload" },
{ label: "Toggle Dev Tools", role: "toggleDevTools" },
...devOnly([
{ label: "Toggle Dev Tools", role: "toggleDevTools" },
]),
{ type: "separator" },
{ label: "Toggle Full Screen", role: "togglefullscreen" },
],

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,23 +52,23 @@ 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();
let timeout: NodeJS.Timeout;
let timeoutId: ReturnType<typeof setTimeout>;
const fiveMinutes = 5 * 60 * 1000;
autoUpdater.on("update-downloaded", () => {
timeout = setTimeout(
timeoutId = setTimeout(
() => showUpdateDialog({ autoUpdatable: true, version }),
fiveMinutes,
);
});
autoUpdater.on("error", (error) => {
clearTimeout(timeout);
clearTimeout(timeoutId);
log.error("Auto update failed", error);
showUpdateDialog({ autoUpdatable: false, version });
});

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

@ -1,13 +1,69 @@
/** @file Image conversions */
import { existsSync } from "fs";
import fs from "node:fs/promises";
import path from "path";
import { CustomErrors, ElectronFile } from "../../types/ipc";
import { writeStream } from "../stream";
import { CustomErrorMessage, ElectronFile } from "../../types/ipc";
import log from "../log";
import { isPlatform } from "../platform";
import { generateTempFilePath } from "../temp";
import { execAsync, isDev } from "../util";
import { deleteTempFile } from "./ffmpeg";
import { writeStream } from "../stream";
import { execAsync, isDev } from "../utils-electron";
import { deleteTempFile, makeTempFilePath } from "../utils-temp";
export const convertToJPEG = async (
fileName: string,
imageData: Uint8Array,
): Promise<Uint8Array> => {
const inputFilePath = await makeTempFilePath(fileName);
const outputFilePath = await makeTempFilePath("output.jpeg");
// Construct the command first, it may throw on NotAvailable on win32.
const command = convertToJPEGCommand(inputFilePath, outputFilePath);
try {
await fs.writeFile(inputFilePath, imageData);
await execAsync(command);
return new Uint8Array(await fs.readFile(outputFilePath));
} finally {
try {
deleteTempFile(outputFilePath);
deleteTempFile(inputFilePath);
} catch (e) {
log.error("Ignoring error when cleaning up temp files", e);
}
}
};
const convertToJPEGCommand = (
inputFilePath: string,
outputFilePath: string,
) => {
switch (process.platform) {
case "darwin":
return [
"sips",
"-s",
"format",
"jpeg",
inputFilePath,
"--out",
outputFilePath,
];
case "linux":
return [
imageMagickPath(),
inputFilePath,
"-quality",
"100%",
outputFilePath,
];
default: // "win32"
throw new Error(CustomErrorMessage.NotAvailable);
}
};
/** Path to the Linux image-magick executable bundled with our app */
const imageMagickPath = () =>
path.join(isDev ? "build" : process.resourcesPath, "image-magick");
const IMAGE_MAGICK_PLACEHOLDER = "IMAGE_MAGICK";
const MAX_DIMENSION_PLACEHOLDER = "MAX_DIMENSION";
@ -19,16 +75,6 @@ const QUALITY_PLACEHOLDER = "QUALITY";
const MAX_QUALITY = 70;
const MIN_QUALITY = 50;
const SIPS_HEIC_CONVERT_COMMAND_TEMPLATE = [
"sips",
"-s",
"format",
"jpeg",
INPUT_PATH_PLACEHOLDER,
"--out",
OUTPUT_PATH_PLACEHOLDER,
];
const SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
"sips",
"-s",
@ -44,14 +90,6 @@ const SIPS_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
OUTPUT_PATH_PLACEHOLDER,
];
const IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE = [
IMAGE_MAGICK_PLACEHOLDER,
INPUT_PATH_PLACEHOLDER,
"-quality",
"100%",
OUTPUT_PATH_PLACEHOLDER,
];
const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
IMAGE_MAGICK_PLACEHOLDER,
INPUT_PATH_PLACEHOLDER,
@ -67,96 +105,6 @@ const IMAGE_MAGICK_THUMBNAIL_GENERATE_COMMAND_TEMPLATE = [
OUTPUT_PATH_PLACEHOLDER,
];
function getImageMagickStaticPath() {
return isDev
? "resources/image-magick"
: path.join(process.resourcesPath, "image-magick");
}
export async function convertToJPEG(
fileData: Uint8Array,
filename: string,
): Promise<Uint8Array> {
if (isPlatform("windows")) {
throw Error(CustomErrors.WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED);
}
const convertedFileData = await convertToJPEG_(fileData, filename);
return convertedFileData;
}
async function convertToJPEG_(
fileData: Uint8Array,
filename: string,
): Promise<Uint8Array> {
let tempInputFilePath: string;
let tempOutputFilePath: string;
try {
tempInputFilePath = await generateTempFilePath(filename);
tempOutputFilePath = await generateTempFilePath("output.jpeg");
await fs.writeFile(tempInputFilePath, fileData);
await execAsync(
constructConvertCommand(tempInputFilePath, tempOutputFilePath),
);
return new Uint8Array(await fs.readFile(tempOutputFilePath));
} catch (e) {
log.error("Failed to convert HEIC", e);
throw e;
} finally {
try {
await fs.rm(tempInputFilePath, { force: true });
} catch (e) {
log.error(`Failed to remove tempInputFile ${tempInputFilePath}`, e);
}
try {
await fs.rm(tempOutputFilePath, { force: true });
} catch (e) {
log.error(
`Failed to remove tempOutputFile ${tempOutputFilePath}`,
e,
);
}
}
}
function constructConvertCommand(
tempInputFilePath: string,
tempOutputFilePath: string,
) {
let convertCmd: string[];
if (isPlatform("mac")) {
convertCmd = SIPS_HEIC_CONVERT_COMMAND_TEMPLATE.map((cmdPart) => {
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return tempInputFilePath;
}
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
return tempOutputFilePath;
}
return cmdPart;
});
} else if (isPlatform("linux")) {
convertCmd = IMAGEMAGICK_HEIC_CONVERT_COMMAND_TEMPLATE.map(
(cmdPart) => {
if (cmdPart === IMAGE_MAGICK_PLACEHOLDER) {
return getImageMagickStaticPath();
}
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return tempInputFilePath;
}
if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
return tempOutputFilePath;
}
return cmdPart;
},
);
} else {
throw new Error(`Unsupported OS ${process.platform}`);
}
return convertCmd;
}
export async function generateImageThumbnail(
inputFile: File | ElectronFile,
maxDimension: number,
@ -165,13 +113,12 @@ 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);
const tempFilePath = await makeTempFilePath(inputFile.name);
await writeStream(tempFilePath, await inputFile.stream());
inputFilePath = tempFilePath;
createdTempInputFile = true;
@ -203,7 +150,7 @@ async function generateImageThumbnail_(
let tempOutputFilePath: string;
let quality = MAX_QUALITY;
try {
tempOutputFilePath = await generateTempFilePath("thumb.jpeg");
tempOutputFilePath = await makeTempFilePath("thumb.jpeg");
let thumbnail: Uint8Array;
do {
await execAsync(
@ -240,7 +187,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 +205,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 imageMagickPath();
}
if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return inputFilePath;

View file

@ -1,22 +1,19 @@
import pathToFfmpeg from "ffmpeg-static";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import { ElectronFile } from "../../types/ipc";
import { writeStream } from "../stream";
import log from "../log";
import { generateTempFilePath, getTempDirPath } from "../temp";
import { execAsync } from "../util";
import { withTimeout } from "../utils";
import { execAsync } from "../utils-electron";
import { deleteTempFile, makeTempFilePath } from "../utils-temp";
const INPUT_PATH_PLACEHOLDER = "INPUT";
const FFMPEG_PLACEHOLDER = "FFMPEG";
const OUTPUT_PATH_PLACEHOLDER = "OUTPUT";
const ffmpegPathPlaceholder = "FFMPEG";
const inputPathPlaceholder = "INPUT";
const outputPathPlaceholder = "OUTPUT";
/**
* Run a ffmpeg command
*
* [Note: FFMPEG in Electron]
* [Note: ffmpeg in Electron]
*
* There is a wasm build of FFMPEG, but that is currently 10-20 times slower
* There is a wasm build of ffmpeg, but that is currently 10-20 times slower
* that the native build. That is slow enough to be unusable for our purposes.
* https://ffmpegwasm.netlify.app/docs/performance
*
@ -36,79 +33,65 @@ const OUTPUT_PATH_PLACEHOLDER = "OUTPUT";
* $ file ente.app/Contents/Frameworks/Electron\ Framework.framework/Versions/Current/Libraries/libffmpeg.dylib
* .../libffmpeg.dylib: Mach-O 64-bit dynamically linked shared library arm64
*
* I'm not sure if our code is supposed to be able to use it, and how.
* But I'm not sure if our code is supposed to be able to use it, and how.
*/
export async function runFFmpegCmd(
cmd: string[],
inputFile: File | ElectronFile,
export const ffmpegExec = async (
command: string[],
inputDataOrPath: Uint8Array | string,
outputFileName: string,
dontTimeout?: boolean,
) {
let inputFilePath = null;
let createdTempInputFile = null;
timeoutMS: number,
): Promise<Uint8Array> => {
// TODO (MR): This currently copies files for both input and output. This
// needs to be tested extremely large video files when invoked downstream of
// `convertToMP4` in the web code.
let inputFilePath: string;
let isInputFileTemporary: boolean;
if (typeof inputDataOrPath == "string") {
inputFilePath = inputDataOrPath;
isInputFileTemporary = false;
} else {
inputFilePath = await makeTempFilePath("input" /* arbitrary */);
isInputFileTemporary = true;
await fs.writeFile(inputFilePath, inputDataOrPath);
}
let outputFilePath: string | undefined;
try {
if (!existsSync(inputFile.path)) {
const tempFilePath = await generateTempFilePath(inputFile.name);
await writeStream(tempFilePath, await inputFile.stream());
inputFilePath = tempFilePath;
createdTempInputFile = true;
} else {
inputFilePath = inputFile.path;
}
const outputFileData = await runFFmpegCmd_(
cmd,
outputFilePath = await makeTempFilePath(outputFileName);
const cmd = substitutePlaceholders(
command,
inputFilePath,
outputFileName,
dontTimeout,
outputFilePath,
);
return new File([outputFileData], outputFileName);
} finally {
if (createdTempInputFile) {
await deleteTempFile(inputFilePath);
}
}
}
export async function runFFmpegCmd_(
cmd: string[],
if (timeoutMS) await withTimeout(execAsync(cmd), 30 * 1000);
else await execAsync(cmd);
return fs.readFile(outputFilePath);
} finally {
if (isInputFileTemporary) await deleteTempFile(inputFilePath);
if (outputFilePath) await deleteTempFile(outputFilePath);
}
};
const substitutePlaceholders = (
command: string[],
inputFilePath: string,
outputFileName: string,
dontTimeout = false,
) {
let tempOutputFilePath: string;
try {
tempOutputFilePath = await generateTempFilePath(outputFileName);
cmd = cmd.map((cmdPart) => {
if (cmdPart === FFMPEG_PLACEHOLDER) {
return ffmpegBinaryPath();
} else if (cmdPart === INPUT_PATH_PLACEHOLDER) {
return inputFilePath;
} else if (cmdPart === OUTPUT_PATH_PLACEHOLDER) {
return tempOutputFilePath;
} else {
return cmdPart;
}
});
if (dontTimeout) {
await execAsync(cmd);
outputFilePath: string,
) =>
command.map((segment) => {
if (segment == ffmpegPathPlaceholder) {
return ffmpegBinaryPath();
} else if (segment == inputPathPlaceholder) {
return inputFilePath;
} else if (segment == outputPathPlaceholder) {
return outputFilePath;
} else {
await promiseWithTimeout(execAsync(cmd), 30 * 1000);
return segment;
}
if (!existsSync(tempOutputFilePath)) {
throw new Error("ffmpeg output file not found");
}
const outputFile = await fs.readFile(tempOutputFilePath);
return new Uint8Array(outputFile);
} catch (e) {
log.error("FFMPEG command failed", e);
throw e;
} finally {
await deleteTempFile(tempOutputFilePath);
}
}
});
/**
* Return the path to the `ffmpeg` binary.
@ -122,40 +105,3 @@ const ffmpegBinaryPath = () => {
// https://github.com/eugeneware/ffmpeg-static/issues/16
return pathToFfmpeg.replace("app.asar", "app.asar.unpacked");
};
export async function writeTempFile(fileStream: Uint8Array, fileName: string) {
const tempFilePath = await generateTempFilePath(fileName);
await fs.writeFile(tempFilePath, fileStream);
return tempFilePath;
}
export async function deleteTempFile(tempFilePath: string) {
const tempDirPath = await getTempDirPath();
if (!tempFilePath.startsWith(tempDirPath))
log.error("Attempting to delete a non-temp file ${tempFilePath}");
await fs.rm(tempFilePath, { force: true });
}
const promiseWithTimeout = async <T>(
request: Promise<T>,
timeout: number,
): Promise<T> => {
const timeoutRef: {
current: NodeJS.Timeout;
} = { current: null };
const rejectOnTimeout = new Promise<null>((_, reject) => {
timeoutRef.current = setTimeout(
() => reject(new Error("Operation timed out")),
timeout,
);
});
const requestWithTimeOutCancellation = async () => {
const resp = await request;
clearTimeout(timeoutRef.current);
return resp;
};
return await Promise.race([
requestWithTimeOutCancellation(),
rejectOnTimeout,
]);
};

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

@ -5,115 +5,22 @@
*
* @see `web/apps/photos/src/services/clip-service.ts` for more details.
*/
import { existsSync } from "fs";
import jpeg from "jpeg-js";
import fs from "node:fs/promises";
import * as ort from "onnxruntime-node";
import Tokenizer from "../../thirdparty/clip-bpe-ts/mod";
import { CustomErrors } from "../../types/ipc";
import { writeStream } from "../stream";
import log from "../log";
import { generateTempFilePath } from "../temp";
import { deleteTempFile } from "./ffmpeg";
import {
createInferenceSession,
downloadModel,
modelPathDownloadingIfNeeded,
modelSavePath,
} from "./ml";
import { writeStream } from "../stream";
import { deleteTempFile, makeTempFilePath } from "../utils-temp";
import { makeCachedInferenceSession } from "./ml";
const textModelName = "clip-text-vit-32-uint8.onnx";
const textModelByteSize = 64173509; // 61.2 MB
const imageModelName = "clip-image-vit-32-float32.onnx";
const imageModelByteSize = 351468764; // 335.2 MB
let activeImageModelDownload: Promise<string> | undefined;
const imageModelPathDownloadingIfNeeded = async () => {
try {
if (activeImageModelDownload) {
log.info("Waiting for CLIP image model download to finish");
await activeImageModelDownload;
} else {
activeImageModelDownload = modelPathDownloadingIfNeeded(
imageModelName,
imageModelByteSize,
);
return await activeImageModelDownload;
}
} finally {
activeImageModelDownload = undefined;
}
};
let textModelDownloadInProgress = false;
/* TODO(MR): use the generic method. Then we can remove the exports for the
internal details functions that we use here */
const textModelPathDownloadingIfNeeded = async () => {
if (textModelDownloadInProgress)
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
const modelPath = modelSavePath(textModelName);
if (!existsSync(modelPath)) {
log.info("CLIP text model not found, downloading");
textModelDownloadInProgress = true;
downloadModel(modelPath, textModelName)
.catch((e) => {
// log but otherwise ignore
log.error("CLIP text model download failed", e);
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
} else {
const localFileSize = (await fs.stat(modelPath)).size;
if (localFileSize !== textModelByteSize) {
log.error(
`CLIP text model size ${localFileSize} does not match the expected size, downloading again`,
);
textModelDownloadInProgress = true;
downloadModel(modelPath, textModelName)
.catch((e) => {
// log but otherwise ignore
log.error("CLIP text model download failed", e);
})
.finally(() => {
textModelDownloadInProgress = false;
});
throw Error(CustomErrors.MODEL_DOWNLOAD_PENDING);
}
}
return modelPath;
};
let imageSessionPromise: Promise<any> | undefined;
const onnxImageSession = async () => {
if (!imageSessionPromise) {
imageSessionPromise = (async () => {
const modelPath = await imageModelPathDownloadingIfNeeded();
return createInferenceSession(modelPath);
})();
}
return imageSessionPromise;
};
let _textSession: any = null;
const onnxTextSession = async () => {
if (!_textSession) {
const modelPath = await textModelPathDownloadingIfNeeded();
_textSession = await createInferenceSession(modelPath);
}
return _textSession;
};
const cachedCLIPImageSession = makeCachedInferenceSession(
"clip-image-vit-32-float32.onnx",
351468764 /* 335.2 MB */,
);
export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
const tempFilePath = await generateTempFilePath("");
const tempFilePath = await makeTempFilePath("");
const imageStream = new Response(jpegImageData.buffer).body;
await writeStream(tempFilePath, imageStream);
try {
@ -124,19 +31,20 @@ export const clipImageEmbedding = async (jpegImageData: Uint8Array) => {
};
const clipImageEmbedding_ = async (jpegFilePath: string) => {
const imageSession = await onnxImageSession();
const session = await cachedCLIPImageSession();
const t1 = Date.now();
const rgbData = await getRGBData(jpegFilePath);
const feeds = {
input: new ort.Tensor("float32", rgbData, [1, 3, 224, 224]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
const results = await session.run(feeds);
log.debug(
() =>
`onnx/clip image embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
);
const imageEmbedding = results["output"].data; // Float32Array
/* Need these model specific casts to type the result */
const imageEmbedding = results["output"].data as Float32Array;
return normalizeEmbedding(imageEmbedding);
};
@ -221,6 +129,11 @@ const normalizeEmbedding = (embedding: Float32Array) => {
return embedding;
};
const cachedCLIPTextSession = makeCachedInferenceSession(
"clip-text-vit-32-uint8.onnx",
64173509 /* 61.2 MB */,
);
let _tokenizer: Tokenizer = null;
const getTokenizer = () => {
if (!_tokenizer) {
@ -229,8 +142,21 @@ const getTokenizer = () => {
return _tokenizer;
};
export const clipTextEmbedding = async (text: string) => {
const imageSession = await onnxTextSession();
export const clipTextEmbeddingIfAvailable = async (text: string) => {
const sessionOrStatus = await Promise.race([
cachedCLIPTextSession(),
"downloading-model",
]);
// Don't wait for the download to complete
if (typeof sessionOrStatus == "string") {
console.log(
"Ignoring CLIP text embedding request because model download is pending",
);
return undefined;
}
const session = sessionOrStatus;
const t1 = Date.now();
const tokenizer = getTokenizer();
const tokenizedText = Int32Array.from(tokenizer.encodeForCLIP(text));
@ -238,11 +164,11 @@ export const clipTextEmbedding = async (text: string) => {
input: new ort.Tensor("int32", tokenizedText, [1, 77]),
};
const t2 = Date.now();
const results = await imageSession.run(feeds);
const results = await session.run(feeds);
log.debug(
() =>
`onnx/clip text embedding took ${Date.now() - t1} ms (prep: ${t2 - t1} ms, inference: ${Date.now() - t2} ms)`,
);
const textEmbedding = results["output"].data;
const textEmbedding = results["output"].data as Float32Array;
return normalizeEmbedding(textEmbedding);
};

View file

@ -8,78 +8,15 @@
*/
import * as ort from "onnxruntime-node";
import log from "../log";
import { createInferenceSession, modelPathDownloadingIfNeeded } from "./ml";
import { makeCachedInferenceSession } from "./ml";
const faceDetectionModelName = "yolov5s_face_640_640_dynamic.onnx";
const faceDetectionModelByteSize = 30762872; // 29.3 MB
const faceEmbeddingModelName = "mobilefacenet_opset15.onnx";
const faceEmbeddingModelByteSize = 5286998; // 5 MB
let activeFaceDetectionModelDownload: Promise<string> | undefined;
const faceDetectionModelPathDownloadingIfNeeded = async () => {
try {
if (activeFaceDetectionModelDownload) {
log.info("Waiting for face detection model download to finish");
await activeFaceDetectionModelDownload;
} else {
activeFaceDetectionModelDownload = modelPathDownloadingIfNeeded(
faceDetectionModelName,
faceDetectionModelByteSize,
);
return await activeFaceDetectionModelDownload;
}
} finally {
activeFaceDetectionModelDownload = undefined;
}
};
let _faceDetectionSession: Promise<ort.InferenceSession> | undefined;
const faceDetectionSession = async () => {
if (!_faceDetectionSession) {
_faceDetectionSession =
faceDetectionModelPathDownloadingIfNeeded().then((modelPath) =>
createInferenceSession(modelPath),
);
}
return _faceDetectionSession;
};
let activeFaceEmbeddingModelDownload: Promise<string> | undefined;
const faceEmbeddingModelPathDownloadingIfNeeded = async () => {
try {
if (activeFaceEmbeddingModelDownload) {
log.info("Waiting for face embedding model download to finish");
await activeFaceEmbeddingModelDownload;
} else {
activeFaceEmbeddingModelDownload = modelPathDownloadingIfNeeded(
faceEmbeddingModelName,
faceEmbeddingModelByteSize,
);
return await activeFaceEmbeddingModelDownload;
}
} finally {
activeFaceEmbeddingModelDownload = undefined;
}
};
let _faceEmbeddingSession: Promise<ort.InferenceSession> | undefined;
const faceEmbeddingSession = async () => {
if (!_faceEmbeddingSession) {
_faceEmbeddingSession =
faceEmbeddingModelPathDownloadingIfNeeded().then((modelPath) =>
createInferenceSession(modelPath),
);
}
return _faceEmbeddingSession;
};
const cachedFaceDetectionSession = makeCachedInferenceSession(
"yolov5s_face_640_640_dynamic.onnx",
30762872 /* 29.3 MB */,
);
export const detectFaces = async (input: Float32Array) => {
const session = await faceDetectionSession();
const session = await cachedFaceDetectionSession();
const t = Date.now();
const feeds = {
input: new ort.Tensor("float32", input, [1, 3, 640, 640]),
@ -89,6 +26,11 @@ export const detectFaces = async (input: Float32Array) => {
return results["output"].data;
};
const cachedFaceEmbeddingSession = makeCachedInferenceSession(
"mobilefacenet_opset15.onnx",
5286998 /* 5 MB */,
);
export const faceEmbedding = async (input: Float32Array) => {
// Dimension of each face (alias)
const mobileFaceNetFaceSize = 112;
@ -98,11 +40,11 @@ export const faceEmbedding = async (input: Float32Array) => {
const n = Math.round(input.length / (z * z * 3));
const inputTensor = new ort.Tensor("float32", input, [n, z, z, 3]);
const session = await faceEmbeddingSession();
const session = await cachedFaceEmbeddingSession();
const t = Date.now();
const feeds = { img_inputs: inputTensor };
const results = await session.run(feeds);
log.debug(() => `onnx/yolo face embedding took ${Date.now() - t} ms`);
// TODO: What's with this type? It works in practice, but double check.
return (results.embeddings as unknown as any)["cpuData"]; // as Float32Array;
/* Need these model specific casts to extract and type the result */
return (results.embeddings as unknown as any)["cpuData"] as Float32Array;
};

View file

@ -1,5 +1,5 @@
/**
* @file AI/ML related functionality.
* @file AI/ML related functionality, generic layer.
*
* @see also `ml-clip.ts`, `ml-face.ts`.
*
@ -15,8 +15,51 @@ import { existsSync } from "fs";
import fs from "node:fs/promises";
import path from "node:path";
import * as ort from "onnxruntime-node";
import { writeStream } from "../stream";
import log from "../log";
import { writeStream } from "../stream";
/**
* Return a function that can be used to trigger a download of the specified
* model, and the creating of an ONNX inference session initialized using it.
*
* Multiple parallel calls to the returned function are fine, it ensures that
* the the model will be downloaded and the session created using it only once.
* All pending calls to it meanwhile will just await on the same promise.
*
* And once the promise is resolved, the create ONNX inference session will be
* cached, so subsequent calls to the returned function will just reuse the same
* session.
*
* {@link makeCachedInferenceSession} can itself be called anytime, it doesn't
* actively trigger a download until the returned function is called.
*
* @param modelName The name of the model to download.
* @param modelByteSize The size in bytes that we expect the model to have. If
* the size of the downloaded model does not match the expected size, then we
* will redownload it.
*
* @returns a function. calling that function returns a promise to an ONNX
* session.
*/
export const makeCachedInferenceSession = (
modelName: string,
modelByteSize: number,
) => {
let session: Promise<ort.InferenceSession> | undefined;
const download = () =>
modelPathDownloadingIfNeeded(modelName, modelByteSize);
const createSession = (modelPath: string) =>
createInferenceSession(modelPath);
const cachedInferenceSession = () => {
if (!session) session = download().then(createSession);
return session;
};
return cachedInferenceSession;
};
/**
* Download the model named {@link modelName} if we don't already have it.
@ -26,7 +69,7 @@ import log from "../log";
*
* @returns the path to the model on the local machine.
*/
export const modelPathDownloadingIfNeeded = async (
const modelPathDownloadingIfNeeded = async (
modelName: string,
expectedByteSize: number,
) => {
@ -49,10 +92,10 @@ export const modelPathDownloadingIfNeeded = async (
};
/** Return the path where the given {@link modelName} is meant to be saved */
export const modelSavePath = (modelName: string) =>
const modelSavePath = (modelName: string) =>
path.join(app.getPath("userData"), "models", modelName);
export const downloadModel = async (saveLocation: string, name: string) => {
const downloadModel = async (saveLocation: string, name: string) => {
// `mkdir -p` the directory where we want to save the model.
const saveDir = path.dirname(saveLocation);
await fs.mkdir(saveDir, { recursive: true });
@ -69,7 +112,7 @@ export const downloadModel = async (saveLocation: string, name: string) => {
/**
* Crete an ONNX {@link InferenceSession} with some defaults.
*/
export const createInferenceSession = async (modelPath: string) => {
const createInferenceSession = async (modelPath: string) => {
return await ort.InferenceSession.create(modelPath, {
// Restrict the number of threads to 1
intraOpNumThreads: 1,

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

@ -1,35 +0,0 @@
import { app } from "electron/main";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "path";
const CHARACTERS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
export async function getTempDirPath() {
const tempDirPath = path.join(app.getPath("temp"), "ente");
await fs.mkdir(tempDirPath, { recursive: true });
return tempDirPath;
}
function generateTempName(length: number) {
let result = "";
const charactersLength = CHARACTERS.length;
for (let i = 0; i < length; i++) {
result += CHARACTERS.charAt(
Math.floor(Math.random() * charactersLength),
);
}
return result;
}
export async function generateTempFilePath(formatSuffix: string) {
let tempFilePath: string;
do {
const tempDirPath = await getTempDirPath();
const namePrefix = generateTempName(10);
tempFilePath = path.join(tempDirPath, namePrefix + "-" + formatSuffix);
} while (existsSync(tempFilePath));
return tempFilePath;
}

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

@ -0,0 +1,64 @@
import { app } from "electron/main";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "path";
/**
* Our very own directory within the system temp directory. Go crazy, but
* remember to clean up, especially in exception handlers.
*/
const enteTempDirPath = async () => {
const result = path.join(app.getPath("temp"), "ente");
await fs.mkdir(result, { recursive: true });
return result;
};
const randomPrefix = (length: number) => {
const CHARACTERS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
const charactersLength = CHARACTERS.length;
for (let i = 0; i < length; i++) {
result += CHARACTERS.charAt(
Math.floor(Math.random() * charactersLength),
);
}
return result;
};
/**
* Return the path to a temporary file with the given {@link formatSuffix}.
*
* The function returns the path to a file in the system temp directory (in an
* Ente specific folder therin) with a random prefix and the given
* {@link formatSuffix}. It ensures that there is no existing file with the same
* name already.
*
* Use {@link deleteTempFile} to remove this file when you're done.
*/
export const makeTempFilePath = async (formatSuffix: string) => {
const tempDir = await enteTempDirPath();
let result: string;
do {
result = path.join(tempDir, randomPrefix(10) + "-" + formatSuffix);
} while (existsSync(result));
return result;
};
/**
* Delete a temporary file at the given path if it exists.
*
* This is the same as a vanilla {@link fs.rm}, except it first checks that the
* given path is within the Ente specific directory in the system temp
* directory. This acts as an additional safety check.
*
* @param tempFilePath The path to the temporary file to delete. This path
* should've been previously created using {@link makeTempFilePath}.
*/
export const deleteTempFile = async (tempFilePath: string) => {
const tempDir = await enteTempDirPath();
if (!tempFilePath.startsWith(tempDir))
throw new Error(`Attempting to delete a non-temp file ${tempFilePath}`);
await fs.rm(tempFilePath, { force: true });
};

35
desktop/src/main/utils.ts Normal file
View file

@ -0,0 +1,35 @@
/**
* @file grab bag of utitity functions.
*
* Many of these are verbatim copies of functions from web code since there
* isn't currently a common package that both of them share.
*/
/**
* Wait for {@link ms} milliseconds
*
* This function is a promisified `setTimeout`. It returns a promise that
* resolves after {@link ms} milliseconds.
*/
export const wait = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
/**
* Await the given {@link promise} for {@link timeoutMS} milliseconds. If it
* does not resolve within {@link timeoutMS}, then reject with a timeout error.
*/
export const withTimeout = async <T>(promise: Promise<T>, ms: number) => {
let timeoutId: ReturnType<typeof setTimeout>;
const rejectOnTimeout = new Promise<T>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error("Operation timed out")),
ms,
);
});
const promiseAndCancelTimeout = async () => {
const result = await promise;
clearTimeout(timeoutId);
return result;
};
return Promise.race([promiseAndCancelTimeout(), rejectOnTimeout]);
};

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,15 +119,16 @@ const fsReadTextFile = (path: string): Promise<string> =>
const fsWriteFile = (path: string, contents: string): Promise<void> =>
ipcRenderer.invoke("fsWriteFile", path, contents);
// - AUDIT below this
const fsIsDir = (dirPath: string): Promise<boolean> =>
ipcRenderer.invoke("fsIsDir", dirPath);
// - Conversion
const convertToJPEG = (
fileData: Uint8Array,
filename: string,
fileName: string,
imageData: Uint8Array,
): Promise<Uint8Array> =>
ipcRenderer.invoke("convertToJPEG", fileData, filename);
ipcRenderer.invoke("convertToJPEG", fileName, imageData);
const generateImageThumbnail = (
inputFile: File | ElectronFile,
@ -140,18 +142,18 @@ const generateImageThumbnail = (
maxSize,
);
const runFFmpegCmd = (
cmd: string[],
inputFile: File | ElectronFile,
const ffmpegExec = (
command: string[],
inputDataOrPath: Uint8Array | string,
outputFileName: string,
dontTimeout?: boolean,
): Promise<File> =>
timeoutMS: number,
): Promise<Uint8Array> =>
ipcRenderer.invoke(
"runFFmpegCmd",
cmd,
inputFile,
"ffmpegExec",
command,
inputDataOrPath,
outputFileName,
dontTimeout,
timeoutMS,
);
// - ML
@ -159,8 +161,10 @@ const runFFmpegCmd = (
const clipImageEmbedding = (jpegImageData: Uint8Array): Promise<Float32Array> =>
ipcRenderer.invoke("clipImageEmbedding", jpegImageData);
const clipTextEmbedding = (text: string): Promise<Float32Array> =>
ipcRenderer.invoke("clipTextEmbedding", text);
const clipTextEmbeddingIfAvailable = (
text: string,
): Promise<Float32Array | undefined> =>
ipcRenderer.invoke("clipTextEmbeddingIfAvailable", text);
const detectFaces = (input: Float32Array): Promise<Float32Array> =>
ipcRenderer.invoke("detectFaces", input);
@ -188,117 +192,121 @@ 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);
// - TODO: AUDIT below this
// -
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.*`
//
// There are a few related concepts at play here, and it might be worthwhile to
// read their (excellent) documentation to get an understanding;
//`
// - ContextIsolation:
// https://www.electronjs.org/docs/latest/tutorial/context-isolation
//
// - IPC https://www.electronjs.org/docs/latest/tutorial/ipc
//
// [Note: Transferring large amount of data over IPC]
//
// Electron's IPC implementation uses the HTML standard Structured Clone
// Algorithm to serialize objects passed between processes.
// https://www.electronjs.org/docs/latest/tutorial/ipc#object-serialization
//
// In particular, ArrayBuffer is eligible for structured cloning.
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
//
// Also, ArrayBuffer is "transferable", which means it is a zero-copy operation
// operation when it happens across threads.
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
//
// In our case though, we're not dealing with threads but separate processes. So
// the ArrayBuffer will be copied:
// > "parameters, errors and return values are **copied** when they're sent over
// the bridge".
// 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].
/**
* These objects exposed here will become available to the JS code in our
* renderer (the web/ code) as `window.ElectronAPIs.*`
*
* There are a few related concepts at play here, and it might be worthwhile to
* read their (excellent) documentation to get an understanding;
*`
* - ContextIsolation:
* https://www.electronjs.org/docs/latest/tutorial/context-isolation
*
* - IPC https://www.electronjs.org/docs/latest/tutorial/ipc
*
* ---
*
* [Note: Transferring large amount of data over IPC]
*
* Electron's IPC implementation uses the HTML standard Structured Clone
* Algorithm to serialize objects passed between processes.
* https://www.electronjs.org/docs/latest/tutorial/ipc#object-serialization
*
* In particular, ArrayBuffer is eligible for structured cloning.
* https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
*
* Also, ArrayBuffer is "transferable", which means it is a zero-copy operation
* operation when it happens across threads.
* https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
*
* In our case though, we're not dealing with threads but separate processes. So
* the ArrayBuffer will be copied:
*
* > "parameters, errors and return values are **copied** when they're sent over
* > the bridge".
* >
* > 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].
*/
contextBridge.exposeInMainWorld("electron", {
// - General
appVersion,
logToDisk,
openDirectory,
@ -309,12 +317,14 @@ contextBridge.exposeInMainWorld("electron", {
onMainWindowFocus,
// - App update
onAppUpdateAvailable,
updateAndRestart,
updateOnNextRestart,
skipAppUpdate,
// - FS
fs: {
exists: fsExists,
rename: fsRename,
@ -323,42 +333,51 @@ contextBridge.exposeInMainWorld("electron", {
rm: fsRm,
readTextFile: fsReadTextFile,
writeFile: fsWriteFile,
isDir: fsIsDir,
},
// - Conversion
convertToJPEG,
generateImageThumbnail,
runFFmpegCmd,
ffmpegExec,
// - ML
clipImageEmbedding,
clipTextEmbedding,
clipTextEmbeddingIfAvailable,
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,29 +5,40 @@
* 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.
* See: [Note: Custom errors across Electron/Renderer boundary]
*
* [Note: Custom errors across Electron/Renderer boundary]
*
* We need to use the `message` field to disambiguate between errors thrown by
* the main process when invoked from the renderer process. This is because:
*
* > Errors thrown throw `handle` in the main process are not transparent as
* > they are serialized and only the `message` property from the original error
* > is provided to the renderer process.
* >
* > - https://www.electronjs.org/docs/latest/tutorial/ipc
* >
* > Ref: https://github.com/electron/electron/issues/24427
* Note: this is not a type, and cannot be used in preload.js; it is only meant
* for use in the main process code.
*/
export const CustomErrors = {
WINDOWS_NATIVE_IMAGE_PROCESSING_NOT_SUPPORTED:
"Windows native image processing is not supported",
UNSUPPORTED_PLATFORM: (platform: string, arch: string) =>
`Unsupported platform - ${platform} ${arch}`,
MODEL_DOWNLOAD_PENDING:
"Model download pending, skipping clip search request",
export const CustomErrorMessage = {
NotAvailable: "This feature in not available on the current OS/arch",
};
/**
@ -51,32 +62,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

@ -125,7 +125,7 @@
dependencies:
eslint-visitor-keys "^3.3.0"
"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1":
"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1":
version "4.10.0"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63"
integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==
@ -285,7 +285,7 @@
resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz#b979ebad3919799c979b17c72621c0bc0a31c6c4"
integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==
"@types/json-schema@^7.0.12":
"@types/json-schema@^7.0.15":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
@ -303,9 +303,9 @@
integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==
"@types/node@*", "@types/node@^20.9.0":
version "20.11.30"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.30.tgz#9c33467fc23167a347e73834f788f4b9f399d66f"
integrity sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==
version "20.12.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.7.tgz#04080362fa3dd6c5822061aa3124f5c152cff384"
integrity sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==
dependencies:
undici-types "~5.26.4"
@ -334,7 +334,7 @@
dependencies:
"@types/node" "*"
"@types/semver@^7.5.0":
"@types/semver@^7.5.8":
version "7.5.8"
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e"
integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==
@ -352,90 +352,90 @@
"@types/node" "*"
"@typescript-eslint/eslint-plugin@^7":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz#de61c3083842fc6ac889d2fc83c9a96b55ab8328"
integrity sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw==
version "7.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.6.0.tgz#1f5df5cda490a0bcb6fbdd3382e19f1241024242"
integrity sha512-gKmTNwZnblUdnTIJu3e9kmeRRzV2j1a/LUO27KNNAnIC5zjy1aSvXSRp4rVNlmAoHlQ7HzX42NbKpcSr4jF80A==
dependencies:
"@eslint-community/regexpp" "^4.5.1"
"@typescript-eslint/scope-manager" "7.4.0"
"@typescript-eslint/type-utils" "7.4.0"
"@typescript-eslint/utils" "7.4.0"
"@typescript-eslint/visitor-keys" "7.4.0"
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "7.6.0"
"@typescript-eslint/type-utils" "7.6.0"
"@typescript-eslint/utils" "7.6.0"
"@typescript-eslint/visitor-keys" "7.6.0"
debug "^4.3.4"
graphemer "^1.4.0"
ignore "^5.2.4"
ignore "^5.3.1"
natural-compare "^1.4.0"
semver "^7.5.4"
ts-api-utils "^1.0.1"
semver "^7.6.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/parser@^7":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.4.0.tgz#540f4321de1e52b886c0fa68628af1459954c1f1"
integrity sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ==
version "7.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.6.0.tgz#0aca5de3045d68b36e88903d15addaf13d040a95"
integrity sha512-usPMPHcwX3ZoPWnBnhhorc14NJw9J4HpSXQX4urF2TPKG0au0XhJoZyX62fmvdHONUkmyUe74Hzm1//XA+BoYg==
dependencies:
"@typescript-eslint/scope-manager" "7.4.0"
"@typescript-eslint/types" "7.4.0"
"@typescript-eslint/typescript-estree" "7.4.0"
"@typescript-eslint/visitor-keys" "7.4.0"
"@typescript-eslint/scope-manager" "7.6.0"
"@typescript-eslint/types" "7.6.0"
"@typescript-eslint/typescript-estree" "7.6.0"
"@typescript-eslint/visitor-keys" "7.6.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz#acfc69261f10ece7bf7ece1734f1713392c3655f"
integrity sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==
"@typescript-eslint/scope-manager@7.6.0":
version "7.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.6.0.tgz#1e9972f654210bd7500b31feadb61a233f5b5e9d"
integrity sha512-ngttyfExA5PsHSx0rdFgnADMYQi+Zkeiv4/ZxGYUWd0nLs63Ha0ksmp8VMxAIC0wtCFxMos7Lt3PszJssG/E6w==
dependencies:
"@typescript-eslint/types" "7.4.0"
"@typescript-eslint/visitor-keys" "7.4.0"
"@typescript-eslint/types" "7.6.0"
"@typescript-eslint/visitor-keys" "7.6.0"
"@typescript-eslint/type-utils@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.4.0.tgz#cfcaab21bcca441c57da5d3a1153555e39028cbd"
integrity sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw==
"@typescript-eslint/type-utils@7.6.0":
version "7.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.6.0.tgz#644f75075f379827d25fe0713e252ccd4e4a428c"
integrity sha512-NxAfqAPNLG6LTmy7uZgpK8KcuiS2NZD/HlThPXQRGwz6u7MDBWRVliEEl1Gj6U7++kVJTpehkhZzCJLMK66Scw==
dependencies:
"@typescript-eslint/typescript-estree" "7.4.0"
"@typescript-eslint/utils" "7.4.0"
"@typescript-eslint/typescript-estree" "7.6.0"
"@typescript-eslint/utils" "7.6.0"
debug "^4.3.4"
ts-api-utils "^1.0.1"
ts-api-utils "^1.3.0"
"@typescript-eslint/types@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.4.0.tgz#ee9dafa75c99eaee49de6dcc9348b45d354419b6"
integrity sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==
"@typescript-eslint/types@7.6.0":
version "7.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.6.0.tgz#53dba7c30c87e5f10a731054266dd905f1fbae38"
integrity sha512-h02rYQn8J+MureCvHVVzhl69/GAfQGPQZmOMjG1KfCl7o3HtMSlPaPUAPu6lLctXI5ySRGIYk94clD/AUMCUgQ==
"@typescript-eslint/typescript-estree@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz#12dbcb4624d952f72c10a9f4431284fca24624f4"
integrity sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==
"@typescript-eslint/typescript-estree@7.6.0":
version "7.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.6.0.tgz#112a3775563799fd3f011890ac8322f80830ac17"
integrity sha512-+7Y/GP9VuYibecrCQWSKgl3GvUM5cILRttpWtnAu8GNL9j11e4tbuGZmZjJ8ejnKYyBRb2ddGQ3rEFCq3QjMJw==
dependencies:
"@typescript-eslint/types" "7.4.0"
"@typescript-eslint/visitor-keys" "7.4.0"
"@typescript-eslint/types" "7.6.0"
"@typescript-eslint/visitor-keys" "7.6.0"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
minimatch "9.0.3"
semver "^7.5.4"
ts-api-utils "^1.0.1"
minimatch "^9.0.4"
semver "^7.6.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/utils@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.4.0.tgz#d889a0630cab88bddedaf7c845c64a00576257bd"
integrity sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg==
"@typescript-eslint/utils@7.6.0":
version "7.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.6.0.tgz#e400d782280b6f724c8a1204269d984c79202282"
integrity sha512-x54gaSsRRI+Nwz59TXpCsr6harB98qjXYzsRxGqvA5Ue3kQH+FxS7FYU81g/omn22ML2pZJkisy6Q+ElK8pBCA==
dependencies:
"@eslint-community/eslint-utils" "^4.4.0"
"@types/json-schema" "^7.0.12"
"@types/semver" "^7.5.0"
"@typescript-eslint/scope-manager" "7.4.0"
"@typescript-eslint/types" "7.4.0"
"@typescript-eslint/typescript-estree" "7.4.0"
semver "^7.5.4"
"@types/json-schema" "^7.0.15"
"@types/semver" "^7.5.8"
"@typescript-eslint/scope-manager" "7.6.0"
"@typescript-eslint/types" "7.6.0"
"@typescript-eslint/typescript-estree" "7.6.0"
semver "^7.6.0"
"@typescript-eslint/visitor-keys@7.4.0":
version "7.4.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz#0c8ff2c1f8a6fe8d7d1a57ebbd4a638e86a60a94"
integrity sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==
"@typescript-eslint/visitor-keys@7.6.0":
version "7.6.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.6.0.tgz#d1ce13145844379021e1f9bd102c1d78946f4e76"
integrity sha512-4eLB7t+LlNUmXzfOu1VAIAdkjbu5xNSerURS9X/S5TUKWFRpXRQZbmtPqgKmYx8bj3J0irtQXSiWAOY82v+cgw==
dependencies:
"@typescript-eslint/types" "7.4.0"
eslint-visitor-keys "^3.4.1"
"@typescript-eslint/types" "7.6.0"
eslint-visitor-keys "^3.4.3"
"@ungap/structured-clone@^1.2.0":
version "1.2.0"
@ -1140,9 +1140,9 @@ ejs@^3.1.8:
jake "^10.8.5"
electron-builder-notarize@^1.5:
version "1.5.1"
resolved "https://registry.yarnpkg.com/electron-builder-notarize/-/electron-builder-notarize-1.5.1.tgz#e00b868a67ef20a77f00017606626f24fdbdc445"
integrity sha512-xS7s9gE+1AcJIuJ4DU/LqCrmRypE1zOR/6b66egKzgP/UVh9YSa7rINos34gF/KcueNDQU39HcXcCEKiEI5wPQ==
version "1.5.2"
resolved "https://registry.yarnpkg.com/electron-builder-notarize/-/electron-builder-notarize-1.5.2.tgz#540185b57a336fc6eec01bfe092a3b4764459255"
integrity sha512-vo6RGgIFYxMk2yp59N4NsvmAYfB7ncYi6gV9Fcq2TVKxEn2tPXrSjIKB2e/pu+5iXIY6BHNZNXa75F3DHgOOLA==
dependencies:
dotenv "^8.2.0"
electron-notarize "^1.1.1"
@ -1215,9 +1215,9 @@ electron-updater@^6.1:
tiny-typed-emitter "^2.1.0"
electron@^29:
version "29.1.5"
resolved "https://registry.yarnpkg.com/electron/-/electron-29.1.5.tgz#b745b4d201c1ac9f84d6aa034126288dde34d5a1"
integrity sha512-1uWGRw/ffA62lcrklxGUgVxVtOHojsg/nwsYr+/F9cVjipZJn8iPv/ABGIIexhmUqWcho8BqfTJ4osCBa29gBg==
version "29.3.0"
resolved "https://registry.yarnpkg.com/electron/-/electron-29.3.0.tgz#8e65cb08e9c0952c66d3196e1b5c811c43b8c5b0"
integrity sha512-ZxFKm0/v48GSoBuO3DdnMlCYXefEUKUHLMsKxyXY4nZGgzbBKpF/X8haZa2paNj23CLfsCKBOtfc2vsEQiOOsA==
dependencies:
"@electron/get" "^2.0.0"
"@types/node" "^20.9.0"
@ -1835,7 +1835,7 @@ ieee754@^1.1.13:
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
ignore@^5.2.0, ignore@^5.2.4:
ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1:
version "5.3.1"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
@ -2190,13 +2190,6 @@ mimic-response@^3.1.0:
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
minimatch@9.0.3, minimatch@^9.0.1:
version "9.0.3"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
dependencies:
brace-expansion "^2.0.1"
minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
@ -2211,6 +2204,20 @@ minimatch@^5.0.1, minimatch@^5.1.1:
dependencies:
brace-expansion "^2.0.1"
minimatch@^9.0.1:
version "9.0.3"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
dependencies:
brace-expansion "^2.0.1"
minimatch@^9.0.4:
version "9.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51"
integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==
dependencies:
brace-expansion "^2.0.1"
minimist@^1.2.3, minimist@^1.2.6:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
@ -2482,17 +2489,17 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier-plugin-organize-imports@^3.2:
prettier-plugin-organize-imports@^3:
version "3.2.4"
resolved "https://registry.yarnpkg.com/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-3.2.4.tgz#77967f69d335e9c8e6e5d224074609309c62845e"
integrity sha512-6m8WBhIp0dfwu0SkgfOxJqh+HpdyfqSSLfKKRZSFbDuEQXDDndb8fTpRWkUrX/uBenkex3MgnVk0J3b3Y5byog==
prettier-plugin-packagejson@^2.4:
version "2.4.12"
resolved "https://registry.yarnpkg.com/prettier-plugin-packagejson/-/prettier-plugin-packagejson-2.4.12.tgz#eeb917dad83ae42d0caccc9f26d3728b5c4f2434"
integrity sha512-hifuuOgw5rHHTdouw9VrhT8+Nd7UwxtL1qco8dUfd4XUFQL6ia3xyjSxhPQTsGnSYFraTWy5Omb+MZm/OWDTpQ==
prettier-plugin-packagejson@^2:
version "2.5.0"
resolved "https://registry.yarnpkg.com/prettier-plugin-packagejson/-/prettier-plugin-packagejson-2.5.0.tgz#23d2cb8b1f7840702d35e3a5078e564ea0bc63e0"
integrity sha512-6XkH3rpin5QEQodBSVNg+rBo4r91g/1mCaRwS1YGdQJZ6jwqrg2UchBsIG9tpS1yK1kNBvOt84OILsX8uHzBGg==
dependencies:
sort-package-json "2.8.0"
sort-package-json "2.10.0"
synckit "0.9.0"
prettier@^3:
@ -2711,7 +2718,7 @@ semver@^6.2.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.3.2, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4:
semver@^7.3.2, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.6.0:
version "7.6.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d"
integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==
@ -2800,10 +2807,10 @@ sort-object-keys@^1.1.3:
resolved "https://registry.yarnpkg.com/sort-object-keys/-/sort-object-keys-1.1.3.tgz#bff833fe85cab147b34742e45863453c1e190b45"
integrity sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==
sort-package-json@2.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/sort-package-json/-/sort-package-json-2.8.0.tgz#6a46439ad0fef77f091e678e103f03ecbea575c8"
integrity sha512-PxeNg93bTJWmDGnu0HADDucoxfFiKkIr73Kv85EBThlI1YQPdc0XovBgg2llD0iABZbu2SlKo8ntGmOP9wOj/g==
sort-package-json@2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/sort-package-json/-/sort-package-json-2.10.0.tgz#6be07424bf3b7db9fbb1bdd69e7945f301026d8a"
integrity sha512-MYecfvObMwJjjJskhxYfuOADkXp1ZMMnCFC8yhp+9HDsk7HhR336hd7eiBs96lTXfiqmUNI+WQCeCMRBhl251g==
dependencies:
detect-indent "^7.0.1"
detect-newline "^4.0.0"
@ -2811,6 +2818,7 @@ sort-package-json@2.8.0:
git-hooks-list "^3.0.0"
globby "^13.1.2"
is-plain-obj "^4.1.0"
semver "^7.6.0"
sort-object-keys "^1.1.3"
source-map-support@^0.5.19:
@ -3018,7 +3026,7 @@ truncate-utf8-bytes@^1.0.0:
dependencies:
utf8-byte-length "^1.0.1"
ts-api-utils@^1.0.1:
ts-api-utils@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1"
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==

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

@ -46,7 +46,7 @@ You can alternatively install the build from PlayStore or F-Droid.
## 🧑‍💻 Building from source
1. [Install Flutter v3.19.5](https://flutter.dev/docs/get-started/install).
1. [Install Flutter v3.19.3](https://flutter.dev/docs/get-started/install).
2. Pull in all submodules with `git submodule update --init --recursive`

View file

@ -1 +1 @@
ente фотографии
ente Фото

View file

@ -10,19 +10,19 @@ PODS:
- Flutter
- file_saver (0.0.1):
- Flutter
- Firebase/CoreOnly (10.22.0):
- FirebaseCore (= 10.22.0)
- Firebase/Messaging (10.22.0):
- Firebase/CoreOnly (10.24.0):
- FirebaseCore (= 10.24.0)
- Firebase/Messaging (10.24.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 10.22.0)
- firebase_core (2.29.0):
- Firebase/CoreOnly (= 10.22.0)
- FirebaseMessaging (~> 10.24.0)
- firebase_core (2.30.0):
- Firebase/CoreOnly (= 10.24.0)
- Flutter
- firebase_messaging (14.7.19):
- Firebase/Messaging (= 10.22.0)
- firebase_messaging (14.8.1):
- Firebase/Messaging (= 10.24.0)
- firebase_core
- Flutter
- FirebaseCore (10.22.0):
- FirebaseCore (10.24.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.12)
- GoogleUtilities/Logger (~> 7.12)
@ -33,7 +33,7 @@ PODS:
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- PromisesObjC (~> 2.1)
- FirebaseMessaging (10.22.0):
- FirebaseMessaging (10.24.0):
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleDataTransport (~> 9.3)
@ -175,7 +175,7 @@ PODS:
- SDWebImage (5.19.1):
- SDWebImage/Core (= 5.19.1)
- SDWebImage/Core (5.19.1)
- SDWebImageWebPCoder (0.14.5):
- SDWebImageWebPCoder (0.14.6):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- Sentry/HybridSDK (8.21.0):
@ -193,14 +193,14 @@ PODS:
- sqflite (0.0.3):
- Flutter
- FlutterMacOS
- sqlite3 (3.45.1):
- sqlite3/common (= 3.45.1)
- sqlite3/common (3.45.1)
- sqlite3/fts5 (3.45.1):
- "sqlite3 (3.45.3+1)":
- "sqlite3/common (= 3.45.3+1)"
- "sqlite3/common (3.45.3+1)"
- "sqlite3/fts5 (3.45.3+1)":
- sqlite3/common
- sqlite3/perf-threadsafe (3.45.1):
- "sqlite3/perf-threadsafe (3.45.3+1)":
- sqlite3/common
- sqlite3/rtree (3.45.1):
- "sqlite3/rtree (3.45.3+1)":
- sqlite3/common
- sqlite3_flutter_libs (0.0.1):
- Flutter
@ -404,13 +404,13 @@ SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: 797fd7297b7e1be954432743a0b3f90038e45a71
firebase_core: aaadbddb3cb2ee3792b9804f9dbb63e5f6f7b55c
firebase_messaging: e65050bf9b187511d80ea3a4de7cf5573d2c7543
FirebaseCore: 0326ec9b05fbed8f8716cddbf0e36894a13837f7
Firebase: 91fefd38712feb9186ea8996af6cbdef41473442
firebase_core: 66b99b4fb4e5d7cc4e88d4c195fe986681f3466a
firebase_messaging: 0eb0425d28b4f4af147cdd4adcaf7c0100df28ed
FirebaseCore: 11dc8a16dfb7c5e3c3f45ba0e191a33ac4f50894
FirebaseCoreInternal: bcb5acffd4ea05e12a783ecf835f2210ce3dc6af
FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e
FirebaseMessaging: 9f71037fd9db3376a4caa54e5a3949d1027b4b6e
FirebaseMessaging: 4d52717dd820707cc4eadec5eb981b4832ec8d5d
fk_user_agent: 1f47ec39291e8372b1d692b50084b0d54103c545
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
@ -452,14 +452,14 @@ SPEC CHECKSUMS:
receive_sharing_intent: 6837b01768e567fe8562182397bf43d63d8c6437
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb
SDWebImageWebPCoder: c94f09adbca681822edad9e532ac752db713eabf
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
Sentry: ebc12276bd17613a114ab359074096b6b3725203
sentry_flutter: 88ebea3f595b0bc16acc5bedacafe6d60c12dcd5
SentryPrivate: d651efb234cf385ec9a1cdd3eff94b5e78a0e0fe
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
sqlite3: 73b7fc691fdc43277614250e04d183740cb15078
sqlite3: 02d1f07eaaa01f80a1c16b4b31dfcbb3345ee01a
sqlite3_flutter_libs: af0e8fe9bce48abddd1ffdbbf839db0302d72d80
Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e
uni_links: d97da20c7701486ba192624d99bffaaffcfc298a

View file

@ -13,18 +13,13 @@ import 'package:media_extension/media_extension_action_types.dart';
import 'package:photos/ente_theme_data.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/l10n/l10n.dart";
import "package:photos/models/collection/collection_items.dart";
import 'package:photos/services/app_lifecycle_service.dart';
import "package:photos/services/collections_service.dart";
import "package:photos/services/favorites_service.dart";
import "package:photos/services/home_widget_service.dart";
import "package:photos/services/machine_learning/machine_learning_controller.dart";
import 'package:photos/services/sync_service.dart';
import 'package:photos/ui/tabs/home_widget.dart';
import "package:photos/ui/viewer/actions/file_viewer.dart";
import "package:photos/ui/viewer/gallery/collection_page.dart";
import "package:photos/utils/intent_util.dart";
import "package:photos/utils/navigation_util.dart";
class EnteApp extends StatefulWidget {
final Future<void> Function(String) runBackgroundTask;
@ -66,39 +61,14 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
void didChangeDependencies() {
super.didChangeDependencies();
_checkForWidgetLaunch();
hw.HomeWidget.widgetClicked.listen(_launchedFromWidget);
}
void _checkForWidgetLaunch() {
hw.HomeWidget.initiallyLaunchedFromHomeWidget().then(_launchedFromWidget);
}
Future<void> _launchedFromWidget(Uri? uri) async {
if (uri == null) return;
final collectionID =
await FavoritesService.instance.getFavoriteCollectionID();
if (collectionID == null) {
return;
}
final collection = CollectionsService.instance.getCollectionByID(
collectionID,
hw.HomeWidget.initiallyLaunchedFromHomeWidget().then(
(uri) => HomeWidgetService.instance.onLaunchFromWidget(uri, context),
);
if (collection == null) {
return;
}
unawaited(HomeWidgetService.instance.initHomeWidget());
final thumbnail = await CollectionsService.instance.getCover(collection);
unawaited(
routeToPage(
context,
CollectionPage(
CollectionWithThumbnail(
collection,
thumbnail,
),
),
),
hw.HomeWidget.widgetClicked.listen(
(uri) => HomeWidgetService.instance.onLaunchFromWidget(uri, context),
);
}

View file

@ -16,6 +16,7 @@ const int jan011981Time = 347155200000000;
const int galleryLoadStartTime = -8000000000000000; // Wednesday, March 6, 1748
const int galleryLoadEndTime = 9223372036854775807; // 2^63 -1
const int batchSize = 1000;
const int batchSizeCopy = 100;
const photoGridSizeDefault = 4;
const photoGridSizeMin = 2;
const photoGridSizeMax = 6;
@ -38,13 +39,6 @@ const dragSensitivity = 8;
const supportEmail = 'support@ente.io';
// Default values for various feature flags
class FFDefault {
static const bool enableStripe = true;
static const bool disableCFWorker = false;
static const bool enablePasskey = false;
}
// this is the chunk size of the un-encrypted file which is read and encrypted before uploading it as a single part.
const multipartPartSize = 20 * 1024 * 1024;

View file

@ -16,7 +16,6 @@ import "package:photos/services/filter/db_filters.dart";
import 'package:photos/utils/file_uploader_util.dart';
import 'package:sqflite/sqflite.dart';
import 'package:sqflite_migration/sqflite_migration.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite3;
import 'package:sqlite_async/sqlite_async.dart' as sqlite_async;
class FilesDB {
@ -103,20 +102,15 @@ class FilesDB {
// only have a single app-wide reference to the database
static Future<Database>? _dbFuture;
static Future<sqlite3.Database>? _ffiDBFuture;
static Future<sqlite_async.SqliteDatabase>? _sqliteAsyncDBFuture;
@Deprecated("Use sqliteAsyncDB instead (sqlite_async)")
Future<Database> get database async {
// lazily instantiate the db the first time it is accessed
_dbFuture ??= _initDatabase();
return _dbFuture!;
}
Future<sqlite3.Database> get ffiDB async {
_ffiDBFuture ??= _initFFIDatabase();
return _ffiDBFuture!;
}
Future<sqlite_async.SqliteDatabase> get sqliteAsyncDB async {
_sqliteAsyncDBFuture ??= _initSqliteAsyncDatabase();
return _sqliteAsyncDBFuture!;
@ -131,14 +125,6 @@ class FilesDB {
return await openDatabaseWithMigration(path, dbConfig);
}
Future<sqlite3.Database> _initFFIDatabase() async {
final Directory documentsDirectory =
await getApplicationDocumentsDirectory();
final String path = join(documentsDirectory.path, _databaseName);
_logger.info("DB path " + path);
return sqlite3.sqlite3.open(path);
}
Future<sqlite_async.SqliteDatabase> _initSqliteAsyncDatabase() async {
final Directory documentsDirectory =
await getApplicationDocumentsDirectory();
@ -478,11 +464,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.sqliteAsyncDB;
final results = await db.getAll(
'SELECT * FROM $filesTable WHERE $columnGeneratedID = ?',
[generatedID],
);
if (results.isEmpty) {
return null;
@ -491,11 +476,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.sqliteAsyncDB;
final results = await db.getAll(
'SELECT * FROM $filesTable WHERE $columnUploadedFileID = ? AND $columnCollectionID = ?',
[
uploadedID,
collectionID,
],
@ -506,29 +490,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.sqliteAsyncDB;
final results = await db.getAll(
'SELECT $columnUploadedFileID FROM $filesTable'
' WHERE $columnCollectionID = ? AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)',
[
collectionID,
],
);
@ -539,13 +506,36 @@ class FilesDB {
return ids;
}
Future<BackedUpFileIDs> getBackedUpIDs() async {
Future<(Set<int>, Map<String, int>)> getUploadAndHash(
int collectionID,
) async {
final db = await instance.database;
final results = await db.query(
filesTable,
columns: [columnLocalID, columnUploadedFileID, columnFileSize],
columns: [columnUploadedFileID, columnHash],
where:
'$columnLocalID IS NOT NULL AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)',
'$columnCollectionID = ? AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1)',
whereArgs: [
collectionID,
],
);
final ids = <int>{};
final hash = <String, int>{};
for (final result in results) {
ids.add(result[columnUploadedFileID] as int);
if (result[columnHash] != null) {
hash[result[columnHash] as String] =
result[columnUploadedFileID] as int;
}
}
return (ids, hash);
}
Future<BackedUpFileIDs> getBackedUpIDs() async {
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>{};
@ -681,13 +671,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;
@ -697,14 +686,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;
@ -726,20 +714,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 =
@ -757,7 +747,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 >= " +
@ -772,44 +762,10 @@ class FilesDB {
}
}
whereClause += ")";
final results = await db.query(
filesTable,
where: whereClause,
orderBy: '$columnCreationTime ' + order,
);
final files = convertToFiles(results);
return applyDBFilters(
files,
DBFilterOptions(ignoredCollectionIDs: ignoredCollectionIDs),
);
}
Future<List<EnteFile>> getFilesCreatedWithinDurationsSync(
List<List<int>> durations,
Set<int> ignoredCollectionIDs, {
int? visibility,
String order = 'ASC',
}) async {
if (durations.isEmpty) {
return <EnteFile>[];
}
final db = await instance.ffiDB;
String whereClause = "( ";
for (int index = 0; index < durations.length; index++) {
whereClause += "($columnCreationTime >= " +
durations[index][0].toString() +
" AND $columnCreationTime < " +
durations[index][1].toString() +
")";
if (index != durations.length - 1) {
whereClause += " OR ";
} else if (visibility != null) {
whereClause += ' AND $columnMMdVisibility = $visibility';
}
}
whereClause += ")";
final results = db.select(
'select * from $filesTable where $whereClause order by $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(
@ -1041,6 +997,29 @@ class FilesDB {
return convertToFiles(rows);
}
Future<Map<String, EnteFile>>
getUserOwnedFilesWithSameHashForGivenListOfFiles(
List<EnteFile> files,
int userID,
) async {
final db = await sqliteAsyncDB;
final List<String> hashes = [];
for (final file in files) {
if (file.hash != null && file.hash != '') {
hashes.add(file.hash!);
}
}
if (hashes.isEmpty) {
return {};
}
final inParam = hashes.map((e) => "'$e'").join(',');
final rows = await db.execute('''
SELECT * FROM $filesTable WHERE $columnHash IN ($inParam) AND $columnOwnerID = $userID;
''');
final matchedFiles = convertToFiles(rows);
return Map.fromIterable(matchedFiles, key: (e) => e.hash);
}
Future<List<EnteFile>> getUploadedFilesWithHashes(
FileHashData hashData,
FileType fileType,

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

@ -21,12 +21,12 @@ import 'package:photos/core/network/network.dart';
import 'package:photos/db/upload_locks_db.dart';
import 'package:photos/ente_theme_data.dart';
import "package:photos/l10n/l10n.dart";
import "package:photos/service_locator.dart";
import 'package:photos/services/app_lifecycle_service.dart';
import 'package:photos/services/billing_service.dart';
import 'package:photos/services/collections_service.dart';
import "package:photos/services/entity_service.dart";
import 'package:photos/services/favorites_service.dart';
import 'package:photos/services/feature_flag_service.dart';
import 'package:photos/services/home_widget_service.dart';
import 'package:photos/services/local_file_update_service.dart';
import 'package:photos/services/local_sync_service.dart';
@ -178,6 +178,7 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
_isProcessRunning = true;
_logger.info("Initializing... inBG =$isBackground via: $via");
final SharedPreferences preferences = await SharedPreferences.getInstance();
await _logFGHeartBeatInfo();
unawaited(_scheduleHeartBeat(preferences, isBackground));
AppLifecycleService.instance.init(preferences);
@ -191,6 +192,7 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
CryptoUtil.init();
await Configuration.instance.init();
await NetworkClient.instance.init();
ServiceLocator.instance.init(preferences, NetworkClient.instance.enteDio);
await UserService.instance.init();
await EntityService.instance.init();
LocationService.instance.init(preferences);
@ -224,7 +226,7 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
);
});
}
unawaited(FeatureFlagService.instance.init());
unawaited(SemanticSearchService.instance.init());
MachineLearningController.instance.init();
// Can not including existing tf/ml binaries as they are not being built

View file

@ -9,7 +9,7 @@ import 'package:photos/core/constants.dart';
import 'package:photos/models/file/file_type.dart';
import 'package:photos/models/location/location.dart';
import "package:photos/models/metadata/file_magic.dart";
import 'package:photos/services/feature_flag_service.dart';
import "package:photos/service_locator.dart";
import 'package:photos/utils/date_time_util.dart';
import 'package:photos/utils/exif_util.dart';
import 'package:photos/utils/file_uploader_util.dart';
@ -244,8 +244,7 @@ class EnteFile {
String get downloadUrl {
final endpoint = Configuration.instance.getHttpEndpoint();
if (endpoint != kDefaultProductionEndpoint ||
FeatureFlagService.instance.disableCFWorker()) {
if (endpoint != kDefaultProductionEndpoint || flagService.disableCFWorker) {
return endpoint + "/files/download/" + uploadedFileID.toString();
} else {
return "https://files.ente.io/?fileID=" + uploadedFileID.toString();
@ -258,8 +257,7 @@ class EnteFile {
String get thumbnailUrl {
final endpoint = Configuration.instance.getHttpEndpoint();
if (endpoint != kDefaultProductionEndpoint ||
FeatureFlagService.instance.disableCFWorker()) {
if (endpoint != kDefaultProductionEndpoint || flagService.disableCFWorker) {
return endpoint + "/files/preview/" + uploadedFileID.toString();
} else {
return "https://thumbnails.ente.io/?fileID=" + uploadedFileID.toString();

View file

@ -0,0 +1,28 @@
import "package:dio/dio.dart";
import "package:ente_feature_flag/ente_feature_flag.dart";
import "package:shared_preferences/shared_preferences.dart";
class ServiceLocator {
late final SharedPreferences prefs;
late final Dio enteDio;
// instance
ServiceLocator._privateConstructor();
static final ServiceLocator instance = ServiceLocator._privateConstructor();
init(SharedPreferences prefs, Dio enteDio) {
this.prefs = prefs;
this.enteDio = enteDio;
}
}
FlagService? _flagService;
FlagService get flagService {
_flagService ??= FlagService(
ServiceLocator.instance.prefs,
ServiceLocator.instance.enteDio,
);
return _flagService!;
}

View file

@ -28,7 +28,9 @@ import 'package:photos/models/collection/collection.dart';
import 'package:photos/models/collection/collection_file_item.dart';
import 'package:photos/models/collection/collection_items.dart';
import 'package:photos/models/file/file.dart';
import "package:photos/models/files_split.dart";
import "package:photos/models/metadata/collection_magic.dart";
import "package:photos/service_locator.dart";
import 'package:photos/services/app_lifecycle_service.dart';
import "package:photos/services/favorites_service.dart";
import 'package:photos/services/file_magic_service.dart';
@ -187,6 +189,23 @@ class CollectionsService {
return result;
}
bool allowUpload(int collectionID) {
final Collection? c = _collectionIDToCollections[collectionID];
if (c == null) {
_logger.info('discardUpload: collectionMissing $collectionID');
return false;
}
if (c.isDeleted) {
_logger.info('discardUpload: collectionDeleted $collectionID');
return false;
}
if (!c.isOwner(_config.getUserID()!)) {
_logger.info('discardUpload: notOwner $collectionID');
return false;
}
return true;
}
Future<List<Collection>> getArchivedCollection() async {
final allCollections = getCollectionsForUI();
return allCollections
@ -1148,11 +1167,56 @@ class CollectionsService {
return collection;
}
Future<void> addToCollection(int collectionID, List<EnteFile> files) async {
final containsUploadedFile = files.firstWhereOrNull(
(element) => element.uploadedFileID != null,
) !=
null;
Future<void> addOrCopyToCollection(
int dstCollectionID,
List<EnteFile> files,
) async {
final splitResult = FilesSplit.split(files, _config.getUserID()!);
if (splitResult.pendingUploads.isNotEmpty) {
throw ArgumentError('File should be already uploaded');
}
if (splitResult.ownedByCurrentUser.isNotEmpty) {
await _addToCollection(dstCollectionID, splitResult.ownedByCurrentUser);
}
if (splitResult.ownedByOtherUsers.isNotEmpty) {
if (!flagService.internalUser) {
throw ArgumentError('Cannot add files owned by other users');
}
late final List<EnteFile> filesToCopy;
late final List<EnteFile> filesToAdd;
(filesToAdd, filesToCopy) = (await _splitFilesToAddAndCopy(
splitResult.ownedByOtherUsers,
));
if (filesToAdd.isNotEmpty) {
_logger.info(
"found existing ${filesToAdd.length} files with same hash, adding symlinks",
);
await _addToCollection(dstCollectionID, filesToAdd);
}
// group files by collectionID
final Map<int, List<EnteFile>> filesByCollection = {};
for (final file in filesToCopy) {
if (filesByCollection.containsKey(file.collectionID!)) {
filesByCollection[file.collectionID!]!.add(file.copyWith());
} else {
filesByCollection[file.collectionID!] = [file.copyWith()];
}
}
for (final entry in filesByCollection.entries) {
final srcCollectionID = entry.key;
final files = entry.value;
await _copyToCollection(
files,
dstCollectionID: dstCollectionID,
srcCollectionID: srcCollectionID,
);
}
}
}
Future<void> _addToCollection(int collectionID, List<EnteFile> files) async {
final containsUploadedFile = files.any((e) => e.isUploaded);
if (containsUploadedFile) {
final existingFileIDsInCollection =
await FilesDB.instance.getUploadedFileIDs(collectionID);
@ -1166,6 +1230,13 @@ class CollectionsService {
_logger.info("nothing to add to the collection");
return;
}
final anyFileOwnedByOther =
files.any((e) => e.ownerID != null && e.ownerID != _config.getUserID());
if (anyFileOwnedByOther) {
throw ArgumentError(
'Cannot add files owned by other users, they should be copied',
);
}
final params = <String, dynamic>{};
params["collectionID"] = collectionID;
@ -1263,6 +1334,126 @@ class CollectionsService {
}
}
Future<void> _copyToCollection(
List<EnteFile> files, {
required int dstCollectionID,
required int srcCollectionID,
}) async {
_validateCopyInput(dstCollectionID, srcCollectionID, files);
final batchedFiles = files.chunks(batchSizeCopy);
final params = <String, dynamic>{};
params["dstCollectionID"] = dstCollectionID;
params["srcCollectionID"] = srcCollectionID;
for (final batch in batchedFiles) {
params["files"] = [];
for (final batchFile in batch) {
final fileKey = getFileKey(batchFile);
_logger.info(
"srcCollection : $srcCollectionID file: ${batchFile.uploadedFileID} key: ${CryptoUtil.bin2base64(fileKey)} ",
);
final encryptedKeyData =
CryptoUtil.encryptSync(fileKey, getCollectionKey(dstCollectionID));
batchFile.encryptedKey =
CryptoUtil.bin2base64(encryptedKeyData.encryptedData!);
batchFile.keyDecryptionNonce =
CryptoUtil.bin2base64(encryptedKeyData.nonce!);
params["files"].add(
CollectionFileItem(
batchFile.uploadedFileID!,
batchFile.encryptedKey!,
batchFile.keyDecryptionNonce!,
).toMap(),
);
}
try {
final res = await _enteDio.post(
"/files/copy",
data: params,
);
final oldToCopiedFileIDMap = Map<int, int>.from(
(res.data["oldToNewFileIDMap"] as Map<String, dynamic>).map(
(key, value) => MapEntry(int.parse(key), value as int),
),
);
for (final file in batch) {
final int uploadIDForOriginalFIle = file.uploadedFileID!;
if (oldToCopiedFileIDMap.containsKey(uploadIDForOriginalFIle)) {
file.generatedID = null;
file.collectionID = dstCollectionID;
file.uploadedFileID = oldToCopiedFileIDMap[uploadIDForOriginalFIle];
file.ownerID = _config.getUserID();
oldToCopiedFileIDMap.remove(uploadIDForOriginalFIle);
} else {
throw Exception("Failed to copy file ${file.uploadedFileID}");
}
}
if (oldToCopiedFileIDMap.isNotEmpty) {
throw Exception(
"Failed to map following uploadKey ${oldToCopiedFileIDMap.keys}",
);
}
await _filesDB.insertMultiple(batch);
Bus.instance
.fire(CollectionUpdatedEvent(dstCollectionID, batch, "copiedTo"));
} catch (e) {
rethrow;
}
}
}
Future<(List<EnteFile>, List<EnteFile>)> _splitFilesToAddAndCopy(
List<EnteFile> othersFile,
) async {
final hashToUserFile =
await _filesDB.getUserOwnedFilesWithSameHashForGivenListOfFiles(
othersFile,
_config.getUserID()!,
);
final List<EnteFile> filesToCopy = [];
final List<EnteFile> filesToAdd = [];
for (final EnteFile file in othersFile) {
if (hashToUserFile.containsKey(file.hash ?? '')) {
final userFile = hashToUserFile[file.hash]!;
if (userFile.fileType == file.fileType) {
filesToAdd.add(userFile);
} else {
filesToCopy.add(file);
}
} else {
filesToCopy.add(file);
}
}
return (filesToAdd, filesToCopy);
}
void _validateCopyInput(
int destCollectionID,
int srcCollectionID,
List<EnteFile> files,
) {
final dstCollection = _collectionIDToCollections[destCollectionID];
final srcCollection = _collectionIDToCollections[srcCollectionID];
if (dstCollection == null || !dstCollection.isOwner(_config.getUserID()!)) {
throw ArgumentError(
'Destination collection not found ${dstCollection == null} or not owned by user ',
);
}
if (srcCollection == null) {
throw ArgumentError('Source collection not found');
}
// verify that all fileIds belong to srcCollection and isn't owned by current user
for (final f in files) {
if (f.collectionID != srcCollectionID ||
f.ownerID == _config.getUserID()) {
_logger.warning(
'file $f does not belong to srcCollection $srcCollection or is owned by current user ${f.ownerID}',
);
throw ArgumentError('');
}
}
}
Future<EnteFile> linkLocalFileToExistingUploadedFileInAnotherCollection(
int destCollectionID, {
required EnteFile localFileToUpload,
@ -1481,10 +1672,13 @@ class CollectionsService {
for (final file in batch) {
params["fileIDs"].add(file.uploadedFileID);
}
await _enteDio.post(
final resp = await _enteDio.post(
"/collections/v3/remove-files",
data: params,
);
if (resp.statusCode != 200) {
throw Exception("Failed to remove files from collection");
}
await _filesDB.removeFromCollection(collectionID, params["fileIDs"]);
Bus.instance

View file

@ -24,6 +24,7 @@ class FavoritesService {
late FilesDB _filesDB;
int? _cachedFavoritesCollectionID;
final Set<int> _cachedFavUploadedIDs = {};
final Map<String, int> _cachedFavFileHases = {};
final Set<String> _cachedPendingLocalIDs = {};
late StreamSubscription<CollectionUpdatedEvent>
_collectionUpdatesSubscription;
@ -60,9 +61,12 @@ class FavoritesService {
Future<void> _warmUpCache() async {
final favCollection = await _getFavoritesCollection();
if (favCollection != null) {
final uploadedIDs =
await FilesDB.instance.getUploadedFileIDs(favCollection.id);
Set<int> uploadedIDs;
Map<String, int> fileHashes;
(uploadedIDs, fileHashes) =
await FilesDB.instance.getUploadAndHash(favCollection.id);
_cachedFavUploadedIDs.addAll(uploadedIDs);
_cachedFavFileHases.addAll(fileHashes);
}
}
@ -87,6 +91,9 @@ class FavoritesService {
return false;
}
if (file.uploadedFileID != null) {
if (file.ownerID != _config.getUserID() && file.hash != null) {
return _cachedFavFileHases.containsKey(file.hash!);
}
return _cachedFavUploadedIDs.contains(file.uploadedFileID);
} else if (file.localID != null) {
return _cachedPendingLocalIDs.contains(file.localID);
@ -99,6 +106,9 @@ class FavoritesService {
if (collection == null || file.uploadedFileID == null) {
return false;
}
if (file.ownerID != _config.getUserID() && file.hash != null) {
return _cachedFavFileHases.containsKey(file.hash!);
}
return _filesDB.doesFileExistInCollection(
file.uploadedFileID!,
collection.id,
@ -110,10 +120,14 @@ class FavoritesService {
required bool favFlag,
}) {
final Set<int> updatedIDs = {};
final Map<String, int> hashes = {};
final Set<String> localIDs = {};
for (var file in files) {
if (file.uploadedFileID != null) {
updatedIDs.add(file.uploadedFileID!);
if (file.hash != null) {
hashes[file.hash!] = file.uploadedFileID!;
}
} else if (file.localID != null || file.localID != "") {
/* Note: Favorite un-uploaded files
For such files, as we don't have uploaded IDs yet, we will cache
@ -124,8 +138,12 @@ class FavoritesService {
}
if (favFlag) {
_cachedFavUploadedIDs.addAll(updatedIDs);
_cachedFavFileHases.addAll(hashes);
} else {
_cachedFavUploadedIDs.removeAll(updatedIDs);
for (var hash in hashes.keys) {
_cachedFavFileHases.remove(hash);
}
}
}
@ -137,7 +155,7 @@ class FavoritesService {
await _filesDB.insert(file);
Bus.instance.fire(CollectionUpdatedEvent(collectionID, files, "addTFav"));
} else {
await _collectionsService.addToCollection(collectionID, files);
await _collectionsService.addOrCopyToCollection(collectionID, files);
}
_updateFavoriteFilesCache(files, favFlag: true);
RemoteSyncService.instance.sync(silently: true).ignore();
@ -153,11 +171,11 @@ class FavoritesService {
throw AssertionError("Can only favorite uploaded items");
}
if (files.any((f) => f.ownerID != currentUserID)) {
throw AssertionError("Can not favortie files owned by others");
throw AssertionError("Can not favorite files owned by others");
}
final collectionID = await _getOrCreateFavoriteCollectionID();
if (favFlag) {
await _collectionsService.addToCollection(collectionID, files);
await _collectionsService.addOrCopyToCollection(collectionID, files);
} else {
final Collection? favCollection = await _getFavoritesCollection();
await _collectionActions.moveFilesFromCurrentCollection(
@ -169,17 +187,30 @@ class FavoritesService {
_updateFavoriteFilesCache(files, favFlag: favFlag);
}
Future<void> removeFromFavorites(BuildContext context, EnteFile file) async {
final fileID = file.uploadedFileID;
if (fileID == null) {
Future<void> removeFromFavorites(
BuildContext context,
EnteFile file,
) async {
final inUploadID = file.uploadedFileID;
if (inUploadID == null) {
// Do nothing, ignore
} else {
final Collection? favCollection = await _getFavoritesCollection();
// The file might be part of another collection. For unfav, we need to
// move file from the fav collection to the .
if (file.ownerID != _config.getUserID() &&
_cachedFavFileHases.containsKey(file.hash!)) {
final EnteFile? favFile = await FilesDB.instance.getUploadedFile(
_cachedFavFileHases[file.hash!]!,
favCollection!.id,
);
if (favFile != null) {
file = favFile;
}
}
if (file.collectionID != favCollection!.id) {
final EnteFile? favFile = await FilesDB.instance.getUploadedFile(
fileID,
file.uploadedFileID!,
favCollection.id,
);
if (favFile != null) {

View file

@ -1,142 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:photos/core/configuration.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/core/network/network.dart';
import 'package:shared_preferences/shared_preferences.dart';
class FeatureFlagService {
FeatureFlagService._privateConstructor();
static final FeatureFlagService instance =
FeatureFlagService._privateConstructor();
static const _featureFlagsKey = "feature_flags_key";
static final _internalUserIDs = const String.fromEnvironment(
"internal_user_ids",
defaultValue: "1,2,3,4,191,125,1580559962388044,1580559962392434,10000025",
).split(",").map((element) {
return int.parse(element);
}).toSet();
final _logger = Logger("FeatureFlagService");
FeatureFlags? _featureFlags;
late SharedPreferences _prefs;
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
// Fetch feature flags from network in async manner.
// Intention of delay is to give more CPU cycles to other tasks
Future.delayed(
const Duration(seconds: 5),
() {
fetchFeatureFlags();
},
);
}
FeatureFlags _getFeatureFlags() {
_featureFlags ??=
FeatureFlags.fromJson(_prefs.getString(_featureFlagsKey)!);
// if nothing is cached, use defaults as temporary fallback
if (_featureFlags == null) {
return FeatureFlags.defaultFlags;
}
return _featureFlags!;
}
bool disableCFWorker() {
try {
return _getFeatureFlags().disableCFWorker;
} catch (e) {
_logger.severe(e);
return FFDefault.disableCFWorker;
}
}
bool enableStripe() {
if (Platform.isIOS) {
return false;
}
try {
return _getFeatureFlags().enableStripe;
} catch (e) {
_logger.severe(e);
return FFDefault.enableStripe;
}
}
bool enablePasskey() {
try {
if (isInternalUserOrDebugBuild()) {
return true;
}
return _getFeatureFlags().enablePasskey;
} catch (e) {
_logger.info('error in enablePasskey check', e);
return FFDefault.enablePasskey;
}
}
bool isInternalUserOrDebugBuild() {
final String? email = Configuration.instance.getEmail();
final userID = Configuration.instance.getUserID();
return (email != null && email.endsWith("@ente.io")) ||
_internalUserIDs.contains(userID) ||
kDebugMode;
}
Future<void> fetchFeatureFlags() async {
try {
final response = await NetworkClient.instance
.getDio()
.get("https://static.ente.io/feature_flags.json");
final flagsResponse = FeatureFlags.fromMap(response.data);
await _prefs.setString(_featureFlagsKey, flagsResponse.toJson());
_featureFlags = flagsResponse;
} catch (e) {
_logger.severe("Failed to sync feature flags ", e);
}
}
}
class FeatureFlags {
static FeatureFlags defaultFlags = FeatureFlags(
disableCFWorker: FFDefault.disableCFWorker,
enableStripe: FFDefault.enableStripe,
enablePasskey: FFDefault.enablePasskey,
);
final bool disableCFWorker;
final bool enableStripe;
final bool enablePasskey;
FeatureFlags({
required this.disableCFWorker,
required this.enableStripe,
required this.enablePasskey,
});
Map<String, dynamic> toMap() {
return {
"disableCFWorker": disableCFWorker,
"enableStripe": enableStripe,
"enablePasskey": enablePasskey,
};
}
String toJson() => json.encode(toMap());
factory FeatureFlags.fromJson(String source) =>
FeatureFlags.fromMap(json.decode(source));
factory FeatureFlags.fromMap(Map<String, dynamic> json) {
return FeatureFlags(
disableCFWorker: json["disableCFWorker"] ?? FFDefault.disableCFWorker,
enableStripe: json["enableStripe"] ?? FFDefault.enableStripe,
enablePasskey: json["enablePasskey"] ?? FFDefault.enablePasskey,
);
}
}

View file

@ -1,26 +1,38 @@
import 'package:photos/models/file/file.dart';
import "package:photos/services/filter/filter.dart";
// CollectionsIgnoreFilter will filter out files that are in present in the
// given collections. This is useful for filtering out files that are in archive
// or hidden collections from home page and other places
class CollectionsIgnoreFilter extends Filter {
// CollectionsOrHashIgnoreFilter will filter out all files that are in present in the
// given collections collectionIDs. This is useful for filtering out files that are in archive
// or hidden collections from home page and other places. Based on flag, it will also filter out
// shared files if the user already as another file with the same hash.
class CollectionsAndSavedFileFilter extends Filter {
final Set<int> collectionIDs;
final bool ignoreSavedFiles;
final int ownerID;
Set<int>? _ignoredUploadIDs;
Set<String> ownedFileHashes = {};
CollectionsIgnoreFilter(this.collectionIDs, List<EnteFile> files) : super() {
CollectionsAndSavedFileFilter(
this.collectionIDs,
this.ownerID,
List<EnteFile> files,
this.ignoreSavedFiles,
) : super() {
init(files);
}
void init(List<EnteFile> files) {
_ignoredUploadIDs = {};
if (collectionIDs.isEmpty) return;
for (var file in files) {
if (file.collectionID != null &&
file.isUploaded &&
collectionIDs.contains(file.collectionID!)) {
_ignoredUploadIDs!.add(file.uploadedFileID!);
if (file.collectionID != null && file.isUploaded) {
if (collectionIDs.contains(file.collectionID!)) {
_ignoredUploadIDs!.add(file.uploadedFileID!);
} else if (ignoreSavedFiles &&
file.ownerID == ownerID &&
(file.hash ?? '').isNotEmpty) {
ownedFileHashes.add(file.hash!);
}
}
}
}
@ -37,6 +49,16 @@ class CollectionsIgnoreFilter extends Filter {
}
return true;
}
return !_ignoredUploadIDs!.contains(file.uploadedFileID!);
if (_ignoredUploadIDs!.contains(file.uploadedFileID!)) {
return false; // this file should be filtered out
}
if (ignoreSavedFiles &&
file.ownerID != ownerID &&
(file.hash ?? '').isNotEmpty) {
// if the file is shared and the user already has a file with the same hash
// then filter it out by returning false
return !ownedFileHashes.contains(file.hash!);
}
return true;
}
}

View file

@ -1,3 +1,4 @@
import "package:photos/core/configuration.dart";
import 'package:photos/models/file/file.dart';
import "package:photos/services/filter/collection_ignore.dart";
import "package:photos/services/filter/dedupe_by_upload_id.dart";
@ -12,11 +13,14 @@ class DBFilterOptions {
Set<int>? ignoredCollectionIDs;
bool dedupeUploadID;
bool hideIgnoredForUpload;
// If true, shared files that are already saved in the users account will be ignored.
bool ignoreSavedFiles;
DBFilterOptions({
this.ignoredCollectionIDs,
this.hideIgnoredForUpload = false,
this.dedupeUploadID = true,
this.ignoreSavedFiles = false,
});
static DBFilterOptions dedupeOption = DBFilterOptions(
@ -42,12 +46,18 @@ Future<List<EnteFile>> applyDBFilters(
if (options.dedupeUploadID) {
filters.add(DedupeUploadIDFilter());
}
if (options.ignoredCollectionIDs != null &&
options.ignoredCollectionIDs!.isNotEmpty) {
final collectionIgnoreFilter =
CollectionsIgnoreFilter(options.ignoredCollectionIDs!, files);
if ((options.ignoredCollectionIDs ?? <int>{}).isNotEmpty ||
options.ignoreSavedFiles) {
final collectionIgnoreFilter = CollectionsAndSavedFileFilter(
options.ignoredCollectionIDs ?? <int>{},
Configuration.instance.getUserID() ?? 0,
files,
options.ignoreSavedFiles,
);
filters.add(collectionIgnoreFilter);
}
final List<EnteFile> filterFiles = [];
for (final file in files) {
if (filters.every((f) => f.filter(file))) {

View file

@ -8,9 +8,14 @@ import "package:logging/logging.dart";
import "package:photos/core/configuration.dart";
import "package:photos/core/constants.dart";
import "package:photos/db/files_db.dart";
import "package:photos/models/collection/collection_items.dart";
import "package:photos/models/file/file_type.dart";
import "package:photos/services/collections_service.dart";
import "package:photos/services/favorites_service.dart";
import "package:photos/ui/viewer/file/detail_page.dart";
import "package:photos/ui/viewer/gallery/collection_page.dart";
import "package:photos/utils/file_util.dart";
import "package:photos/utils/navigation_util.dart";
import "package:photos/utils/preload_util.dart";
class HomeWidgetService {
@ -171,4 +176,49 @@ class HomeWidgetService {
);
_logger.info(">>> SlideshowWidget cleared");
}
Future<void> onLaunchFromWidget(Uri? uri, BuildContext context) async {
if (uri == null) return;
final collectionID =
await FavoritesService.instance.getFavoriteCollectionID();
if (collectionID == null) {
return;
}
final collection = CollectionsService.instance.getCollectionByID(
collectionID,
);
if (collection == null) {
return;
}
final thumbnail = await CollectionsService.instance.getCover(collection);
final previousGeneratedId =
await hw.HomeWidget.getWidgetData<int>("home_widget_last_img");
final res = previousGeneratedId != null
? await FilesDB.instance.getFile(
previousGeneratedId,
)
: null;
routeToPage(
context,
CollectionPage(
CollectionWithThumbnail(
collection,
thumbnail,
),
),
).ignore();
if (res == null) return;
final page = DetailPage(
DetailPageConfiguration(List.unmodifiable([res]), null, 0, "collection"),
);
routeToPage(context, page, forceCustomPageRoute: true).ignore();
}
}

View file

@ -107,7 +107,7 @@ class MemoriesService extends ChangeNotifier {
}
final ignoredCollections =
CollectionsService.instance.archivedOrHiddenCollectionIds();
final files = await _filesDB.getFilesCreatedWithinDurationsSync(
final files = await _filesDB.getFilesCreatedWithinDurations(
durations,
ignoredCollections,
visibility: visibleVisibility,

View file

@ -23,9 +23,9 @@ import "package:photos/models/file/extensions/file_props.dart";
import 'package:photos/models/file/file.dart';
import 'package:photos/models/file/file_type.dart';
import 'package:photos/models/upload_strategy.dart';
import "package:photos/service_locator.dart";
import 'package:photos/services/app_lifecycle_service.dart';
import 'package:photos/services/collections_service.dart';
import "package:photos/services/feature_flag_service.dart";
import 'package:photos/services/ignored_files_service.dart';
import 'package:photos/services/local_file_update_service.dart';
import "package:photos/services/notification_service.dart";
@ -185,7 +185,7 @@ class RemoteSyncService {
rethrow;
} else {
_logger.severe("Error executing remote sync ", e, s);
if (FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
if (flagService.internalUser) {
rethrow;
}
}

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

@ -184,7 +184,8 @@ extension CollectionFileActions on CollectionActions {
}
}
if (files.isNotEmpty) {
await CollectionsService.instance.addToCollection(collectionID, files);
await CollectionsService.instance
.addOrCopyToCollection(collectionID, files);
}
unawaited(RemoteSyncService.instance.sync(silently: true));
await dialog?.hide();

View file

@ -140,7 +140,7 @@ class CollectionActions {
req,
);
logger.finest("adding files to share to new album");
await collectionsService.addToCollection(collection.id, files);
await collectionsService.addOrCopyToCollection(collection.id, files);
logger.finest("creating public link for the newly created album");
await CollectionsService.instance.createShareUrl(collection);
await dialog.hide();

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

@ -41,6 +41,7 @@ class HomeGalleryWidget extends StatelessWidget {
hideIgnoredForUpload: true,
dedupeUploadID: true,
ignoredCollectionIDs: collectionsToHide,
ignoreSavedFiles: true,
);
if (hasSelectedAllForBackup) {
result = await FilesDB.instance.getAllLocalAndUploadedFiles(

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

@ -1,6 +1,6 @@
import 'package:flutter/cupertino.dart';
import 'package:photos/core/configuration.dart';
import 'package:photos/services/feature_flag_service.dart';
import "package:photos/service_locator.dart";
import 'package:photos/services/update_service.dart';
import "package:photos/ui/payment/store_subscription_page.dart";
import 'package:photos/ui/payment/stripe_subscription_page.dart';
@ -9,8 +9,7 @@ StatefulWidget getSubscriptionPage({bool isOnBoarding = false}) {
if (UpdateService.instance.isIndependentFlavor()) {
return StripeSubscriptionPage(isOnboarding: isOnBoarding);
}
if (FeatureFlagService.instance.enableStripe() &&
_isUserCreatedPostStripeSupport()) {
if (flagService.enableStripe && _isUserCreatedPostStripeSupport()) {
return StripeSubscriptionPage(isOnboarding: isOnBoarding);
} else {
return StoreSubscriptionPage(isOnboarding: isOnBoarding);

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

@ -5,7 +5,7 @@ import "package:intl/intl.dart";
import "package:photos/core/event_bus.dart";
import 'package:photos/events/embedding_updated_event.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/services/feature_flag_service.dart";
import "package:photos/service_locator.dart";
import 'package:photos/services/machine_learning/semantic_search/frameworks/ml_framework.dart';
import 'package:photos/services/machine_learning/semantic_search/semantic_search_service.dart';
import "package:photos/theme/ente_theme.dart";
@ -151,7 +151,7 @@ class _MachineLearningSettingsPageState
const SizedBox(
height: 12,
),
FeatureFlagService.instance.isInternalUserOrDebugBuild()
flagService.internalUser
? MenuItemWidget(
leadingIcon: Icons.delete_sweep_outlined,
captionedTextWidget: CaptionedTextWidget(

View file

@ -10,7 +10,7 @@ import 'package:photos/events/two_factor_status_change_event.dart';
import "package:photos/generated/l10n.dart";
import "package:photos/l10n/l10n.dart";
import "package:photos/models/user_details.dart";
import "package:photos/services/feature_flag_service.dart";
import 'package:photos/service_locator.dart';
import 'package:photos/services/local_authentication_service.dart';
import "package:photos/services/passkey_service.dart";
import 'package:photos/services/user_service.dart';
@ -70,8 +70,6 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
final Completer completer = Completer();
final List<Widget> children = [];
if (_config.hasConfiguredAccount()) {
final bool isInternalUser =
FeatureFlagService.instance.isInternalUserOrDebugBuild();
children.addAll(
[
sectionOptionSpacing,
@ -103,8 +101,8 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
},
),
),
if (isInternalUser) sectionOptionSpacing,
if (isInternalUser)
if (flagService.passKeyEnabled) sectionOptionSpacing,
if (flagService.passKeyEnabled)
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: context.l10n.passkey,

View file

@ -7,7 +7,7 @@ import 'package:photos/core/configuration.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/events/opened_settings_event.dart';
import "package:photos/generated/l10n.dart";
import 'package:photos/services/feature_flag_service.dart';
import "package:photos/service_locator.dart";
import "package:photos/services/storage_bonus_service.dart";
import 'package:photos/theme/colors.dart';
import 'package:photos/theme/ente_theme.dart';
@ -140,8 +140,7 @@ class SettingsPage extends StatelessWidget {
const AboutSectionWidget(),
]);
if (hasLoggedIn &&
FeatureFlagService.instance.isInternalUserOrDebugBuild()) {
if (hasLoggedIn && flagService.internalUser) {
contents.addAll([sectionSpacing, const DebugSectionWidget()]);
}
contents.add(const AppVersionWidget());

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;
}
},
),
);
}

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