Compare commits

..

92 Commits

Author SHA1 Message Date
mngshm
daa09136ef [server] remove option to set storageLimit from Member Invite workflow 2025-02-24 17:14:56 +05:30
mngshm
82ebcf66a5 fix: allow null values in storage_limit column 2025-02-24 17:12:13 +05:30
Vishnu Mohandas
490a7221e7 [docs] Update (#5157) 2025-02-24 16:20:50 +05:30
vishnukvmd
1b98f782f1 Update 2025-02-24 16:19:54 +05:30
Vishnu Mohandas
a46a64e386 [docs] Update (#5156) 2025-02-24 16:03:54 +05:30
vishnukvmd
492ea61bb7 Update 2025-02-24 16:03:28 +05:30
Neeraj
9447f1c767 [server] Remove embeddings handler (#5154)
## Description

## Tests
2025-02-24 15:11:47 +05:30
Neeraj Gupta
76b2a73f9a clean up 2025-02-24 15:00:33 +05:30
Neeraj Gupta
4bbe1ae0d2 [server] Remove embeddings handler 2025-02-24 14:49:21 +05:30
Manav Rathi
b15b707600 [server] Limit support while fetching collections (#5148)
## Description
This endpoint let's us add limit which fetching collections.

## Tests
2025-02-24 14:18:44 +05:30
Neeraj
454363d772 [server] Minor refactor (#5152)
## Description
Moving few methods related to collection_files in diff file.
## Tests
2025-02-24 14:08:28 +05:30
Vishnu Mohandas
196fa2c8a4 [docs] Update (#5153)
## Description

## Tests
2025-02-24 14:05:34 +05:30
vishnukvmd
4c3ca8a565 Update 2025-02-24 14:05:02 +05:30
Neeraj Gupta
15aea42b96 [server] Minor refactor 2025-02-24 14:04:39 +05:30
Vishnu Mohandas
29c7f587f6 [docs] Update (#5151) 2025-02-24 13:55:36 +05:30
vishnukvmd
5f0bb21491 Update 2025-02-24 13:54:59 +05:30
Vishnu Mohandas
c882ce0f98 [docs] Update (#5149) 2025-02-24 13:07:25 +05:30
vishnukvmd
02dde7f6a2 Update 2025-02-24 13:07:04 +05:30
Neeraj Gupta
d268e1f309 Fix 2025-02-24 12:39:55 +05:30
Neeraj Gupta
245e78ac42 [server] Limit support while fetching collections 2025-02-24 12:29:20 +05:30
Neeraj
fa55bd88a2 [auth] New translations (#5144)
New translations from
[Crowdin](https://crowdin.com/project/ente-authenticator-app)
2025-02-24 11:13:56 +05:30
Neeraj
11538236c0 [auth] Upgrade dio (#5137)
## Description
Upgrade dio 5.4.0 -> 5.8.0+1
2025-02-24 11:12:56 +05:30
Manav Rathi
09996f77ea [web] [desktop] Enable Japanese translations (#5146) 2025-02-24 10:26:38 +05:30
Manav Rathi
3f512bc959 lf 2025-02-24 10:08:16 +05:30
Manav Rathi
8b0990bd6c [web] [desktop] Enable Japanese translations 2025-02-24 09:58:52 +05:30
Manav Rathi
70ff886252 [web] New translations (#5143)
New translations from
[Crowdin](https://crowdin.com/project/ente-photos-web)
2025-02-24 09:46:57 +05:30
Crowdin Bot
4314d42ab4 New Crowdin translations by GitHub Action 2025-02-24 01:17:26 +00:00
Crowdin Bot
8abe6957d7 New Crowdin translations by GitHub Action 2025-02-24 00:35:43 +00:00
Aman Raj Singh Mourya
d6ec6809c2 [auth] Use native dio adapter 2025-02-22 00:53:16 +05:30
Aman Raj Singh Mourya
8fe9b9571a [auth] Upgrade dio 2025-02-22 00:49:22 +05:30
Aman Raj Singh Mourya
d667cc4f98 [auth] Set cronetHttpNoPlay=true while building apk for droid 2025-02-22 00:48:53 +05:30
Manav Rathi
5806eb6e60 [web] Update storage card when family member's storage limit is configured (#5135)
Sibling of https://github.com/ente-io/ente/pull/5123
2025-02-21 17:42:21 +05:30
Manav Rathi
8830deb619 [web] Update storage card when family member's storage limit is configured
Sibling of https://github.com/ente-io/ente/pull/5123
2025-02-21 17:22:54 +05:30
Manav Rathi
5e32e975df [web] New translations (#5134)
New translations from
[Crowdin](https://crowdin.com/project/ente-photos-web)
2025-02-21 16:54:41 +05:30
Crowdin Bot
8633dabd92 New Crowdin translations by GitHub Action 2025-02-21 11:22:42 +00:00
Manav Rathi
c256f0a8c4 [web] Misc cleanup (#5133) 2025-02-21 16:52:01 +05:30
Manav Rathi
b150bbe15e Rename 2025-02-21 16:40:51 +05:30
Manav Rathi
7f69fa5d65 Reorder 2025-02-21 16:36:50 +05:30
Manav Rathi
3968dd93e9 R 2025-02-21 16:34:09 +05:30
Manav Rathi
557bdd142b R 2025-02-21 16:32:29 +05:30
Manav Rathi
b89da99c96 Rename 2025-02-21 16:29:54 +05:30
Manav Rathi
e79050a3b6 ann2 2025-02-21 16:28:42 +05:30
Manav Rathi
5e4707b695 ann 2025-02-21 16:28:42 +05:30
Manav Rathi
fd0c2866a2 Doc 2025-02-21 16:28:42 +05:30
Manav Rathi
69eee661d1 Prop 2025-02-21 16:28:42 +05:30
Manav Rathi
c78578fce5 [web] Speed up file selection for libraries with 100k+ files (#5132)
(Drastically!)
2025-02-21 16:15:51 +05:30
Manav Rathi
a0f103be9b Fix 2025-02-21 16:05:15 +05:30
Manav Rathi
04ede4326a Cleanup scaffolding 2025-02-21 15:59:57 +05:30
Manav Rathi
7cb9bc3eb7 Reduce some duplication 2025-02-21 15:53:29 +05:30
Manav Rathi
488402156f take 1 2025-02-21 15:45:46 +05:30
Manav Rathi
e80e602786 bespoke 1 2025-02-21 15:38:59 +05:30
Manav Rathi
5d553afea7 Rearrange 2025-02-21 15:28:29 +05:30
Neeraj
9a25356abf [mob] Update storage card when family member's storage limit is configured (#5123)
## Description

## Tests
2025-02-21 15:15:35 +05:30
Neeraj Gupta
5625733429 Merge remote-tracking branch 'origin/main' into family_limits 2025-02-21 14:59:18 +05:30
Manav Rathi
d2dd08391a Investigate speeding up the checkbox action too 2025-02-21 14:47:37 +05:30
Neeraj Gupta
9d06db2b6b [mob] Fix free storage calculation for mob upload 2025-02-21 14:44:50 +05:30
Neeraj Gupta
80049b11ba [mob] Handle null result 2025-02-21 14:42:59 +05:30
Manav Rathi
28160b04b9 Clean 2025-02-21 14:22:47 +05:30
Manav Rathi
02441239d5 fast 4 2025-02-21 14:11:11 +05:30
Manav Rathi
760b50b417 fast 3 2025-02-21 14:08:20 +05:30
Manav Rathi
d118e0e63e fast 1 2025-02-21 14:07:07 +05:30
Manav Rathi
49f9caac90 wip speed up 2025-02-21 14:05:38 +05:30
Prateek Sunal
ea875730dd [mob] streaming patches (#5122)
## Description

Quality of Life fixes:
- [x] Queue fixes
- [x] Android Impeller fix
- [x] No video_player_media_kit proxy, just using media_kit directory

Quality of Dev fixes:
- [x] Use master branch of media_kit
- [x] extract common functions from native player and media kit for
seconds to duration.
2025-02-21 13:34:01 +05:30
Neeraj
e3b03db06f [server] Clean up old logic for collection diff (#5130)
## Description
We still have some traffic from old mobile client v0.7.xx that is making
call to the v1 endpoint.
## Tests
2025-02-21 13:25:14 +05:30
Neeraj Gupta
b030c4e182 [server] Clean up old logic for collection diff 2025-02-21 13:22:45 +05:30
Manav Rathi
1a39846d25 Swap 2025-02-21 13:05:16 +05:30
Manav Rathi
e44020f93a Swap 2025-02-21 13:02:58 +05:30
Neeraj
435621496c [server] Move refactor (#5129)
## Description
Just grouping various actions inside different file to improve
readability
## Tests
2025-02-21 12:52:18 +05:30
Manav Rathi
8379162716 Fix 2025-02-21 12:51:21 +05:30
Manav Rathi
e8d9f4f6cf Conv 2025-02-21 12:45:12 +05:30
Manav Rathi
6724527c27 Another 2025-02-21 12:35:11 +05:30
Neeraj Gupta
6b65a974b5 [server] Refactor 2025-02-21 12:28:11 +05:30
Manav Rathi
1b90fa93ee Use 2025-02-21 12:20:12 +05:30
Neeraj Gupta
f907303c8b [server] move files 2025-02-21 12:08:16 +05:30
Manav Rathi
4317f819d8 mod 2025-02-21 12:07:31 +05:30
Manav Rathi
587da41f53 Dup 2025-02-21 11:56:23 +05:30
Manav Rathi
3214031a0c Reuse 2025-02-21 11:56:23 +05:30
Manav Rathi
0b1eee6c8e select all by date is the culprit
for 100k
t0: 291.615234375 ms
t1: 4.695068359375 ms
t2: 5.75 ms
t3: 5.9921875 ms
t4: 0.014892578125 ms
t5: 0.794921875 ms
t6: 0.0029296875 ms
2025-02-21 10:54:25 +05:30
Neeraj Gupta
898658f0ef [mob] Show memberLimit and usage when familyMember limit is set 2025-02-20 17:39:21 +05:30
Prateek Sunal
7743a4af98 chore: move date related utils to date_time_util.dart 2025-02-20 17:35:07 +05:30
Prateek Sunal
d2764fe7e1 chore: delete redundant files 2025-02-20 17:33:13 +05:30
Prateek Sunal
82df23a3b2 fix: bump build number 2025-02-20 15:24:09 +05:30
Prateek Sunal
00028e3a10 fix: queue addition logic 2025-02-20 15:23:53 +05:30
Prateek Sunal
c5dab37dfa feat: use media kit directly for preview, instead of video_player proxy 2025-02-20 15:23:31 +05:30
Prateek Sunal
c37deecb96 chore: extract out some functions 2025-02-20 13:55:37 +05:30
Prateek Sunal
d2a4634f02 fix: callbacks 2025-02-20 13:55:22 +05:30
Prateek Sunal
2275a47438 chore: bump deps changes 2025-02-20 13:14:47 +05:30
Prateek Sunal
80ab0a308f chore: bump locks 2025-02-20 13:14:32 +05:30
Prateek Sunal
ddb867d21f fix: correct sources 2025-02-20 13:14:21 +05:30
Prateek Sunal
c63cf362b6 Merge remote-tracking branch 'origin/main' into streaming-patched 2025-02-20 13:12:57 +05:30
Prateek Sunal
3f358b9511 fix: run preview creation after sync fd status 2025-02-20 13:08:56 +05:30
Neeraj Gupta
c480dd71f1 [mob] Parse family member storage limit 2025-02-20 12:06:22 +05:30
125 changed files with 3503 additions and 4488 deletions

View File

@@ -40,7 +40,7 @@ jobs:
- name: Build PlayStore AAB
run: |
flutter build appbundle --release --flavor playstore --dart-define=app.flavor=playstore
flutter build appbundle --dart-define=cronetHttpNoPlay=true --release --flavor playstore
env:
SIGNING_KEY_PATH: "/home/runner/work/_temp/keystore/ente_auth_key.jks"
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}

View File

@@ -68,7 +68,7 @@ jobs:
- name: Build independent APK
run: |
flutter build apk --release --flavor independent --dart-define=app.flavor=independent
flutter build apk --dart-define=cronetHttpNoPlay=true --release --flavor independent
mv build/app/outputs/flutter-apk/app-independent-release.apk artifacts/ente-${{ github.ref_name }}.apk
env:
SIGNING_KEY_PATH: "/home/runner/work/_temp/keystore/ente_auth_key.jks"

View File

@@ -8,6 +8,7 @@ import 'package:ente_auth/utils/package_info_util.dart';
import 'package:ente_auth/utils/platform_util.dart';
import 'package:fk_user_agent/fk_user_agent.dart';
import 'package:flutter/foundation.dart';
import 'package:native_dio_adapter/native_dio_adapter.dart';
import 'package:uuid/uuid.dart';
int kConnectTimeout = 15000;
@@ -50,6 +51,10 @@ class Network {
},
),
);
_dio.httpClientAdapter = NativeAdapter();
_enteDio.httpClientAdapter = NativeAdapter();
_setupInterceptors(endpoint);
Bus.instance.on<EndpointUpdatedEvent>().listen((event) {

View File

@@ -147,6 +147,7 @@
"leaveFamily": "Familie verlassen",
"leaveFamilyMessage": "Sind Sie sicher, dass Sie den Familien-Plan verlassen wollen?",
"inFamilyPlanMessage": "Sie haben einen Familien-Plan!",
"hintForDesktop": "Klicken Sie mit der rechten Maustaste auf einen Code zum Bearbeiten oder Entfernen.",
"scan": "Scannen",
"scanACode": "Scan einen Code",
"verify": "Überprüfen Sie",
@@ -156,6 +157,7 @@
"twoFactorAuthTitle": "Zwei-Faktor-Authentifizierung",
"passkeyAuthTitle": "Passkey Authentifizierung",
"verifyPasskey": "Passkey verifizieren",
"loginWithTOTP": "Mit TOTP anmelden",
"recoverAccount": "Konto wiederherstellen",
"enterRecoveryKeyHint": "Geben Sie Ihren Wiederherstellungsschlüssel ein",
"recover": "Wiederherstellen",
@@ -258,6 +260,8 @@
"yesLogout": "Ja ausloggen",
"exit": "Schließen",
"theme": "Theme",
"lightTheme": "Hell",
"darkTheme": "Dunkel",
"systemTheme": "System",
"verifyingRecoveryKey": "Verifiziere Wiederherstellungsschlüssel...",
"recoveryKeyVerified": "Wiederherstellungsschlüssel verifiziert",
@@ -330,6 +334,9 @@
}
},
"manualSort": "Benutzerdefiniert",
"editOrder": "Reihenfolge bearbeiten",
"mostFrequentlyUsed": "Häufig verwendet",
"mostRecentlyUsed": "Zuletzt verwendet",
"activeSessions": "Aktive Sitzungen",
"somethingWentWrongPleaseTryAgain": "Ein Fehler ist aufgetreten, bitte versuche es erneut",
"thisWillLogYouOutOfThisDevice": "Dadurch wirst du von diesem Gerät abgemeldet!",
@@ -449,6 +456,7 @@
"customEndpoint": "Mit {endpoint} verbunden",
"pinText": "Anpinnen",
"unpinText": "Lösen",
"pinned": "Angeheftet",
"tags": "Tags",
"createNewTag": "Neuen Tag erstellen",
"tag": "Tag",
@@ -484,8 +492,16 @@
"importFailureDescNew": "Die ausgewählte Datei konnte nicht verarbeitet werden.",
"appLockNotEnabled": "App-Sperre nicht aktiviert",
"appLockNotEnabledDescription": "Bitte aktivieren Sie die App-Sperre über Security > App-Sperre",
"authToViewPasskey": "Bitte authentifizieren, um deinen Passkey zu sehen",
"duplicateCodes": "Doppelte Codes",
"noDuplicates": "✨ Keine Duplikate",
"youveNoDuplicateCodesThatCanBeCleared": "Sie haben keine doppelten Codes, die gelöscht werden können",
"deselectAll": "Alle abwählen",
"selectAll": "Alles auswählen"
"selectAll": "Alles auswählen",
"deleteDuplicates": "Duplikate löschen",
"plainHTML": "Reines HTML",
"tellUsWhatYouThink": "Sagen Sie uns, was Sie denken",
"dropReview": "Eine Bewertung im App/Play Store ablegen",
"giveUsAStarOnGithub": "Gib uns einen Stern auf Github",
"loginWithAuthAccount": "Mit Ihrem Auth Account anmelden"
}

View File

@@ -505,5 +505,10 @@
"selectAll": "Pasirinkti viską",
"deleteDuplicates": "Ištrinti dublikatus",
"plainHTML": "Grynasis HTML",
"tellUsWhatYouThink": "Pasakykite mums, ką manote",
"giveUsAStarOnGithub": "Suteikite mums žvaigždutę platformoje „Github“",
"free5GB": "5 GB nemokami programai „<bold-green>ente</bold-green>“ nuotraukos",
"loginWithAuthAccount": "Prisijungti su jūsų „Auth“ paskyra",
"freeStorageOffer": "10 % nuolaida programai „<bold-green>ente</bold-green>“ nuotraukos",
"freeStorageOfferDescription": "Naudokite kodą „AUTH“, kad gautumėte 10 % nuolaida pirmiesiems metams. "
}

View File

@@ -113,7 +113,7 @@ class _LoginPasswordVerificationPageState
);
} else {
_logger.severe('API failure during SRP login', e, s);
if (e.type == DioExceptionType.unknown) {
if (e.type == DioExceptionType.connectionError) {
await _showContactSupportDialog(
context,
context.l10n.noInternetConnection,

View File

@@ -113,12 +113,12 @@ String parseErrorForUI(
if (dioError.response?.data["code"] != null) {
errorInfo = "Reason: ${dioError.response!.data["code"]}";
} else {
errorInfo = "Reason: ${dioError.response!.data}";
errorInfo = "Reason: ${dioError.response!.data.toString()}";
}
} else if (dioError.type == DioExceptionType.unknown) {
errorInfo = "Reason: $dioError.error";
} else if (dioError.type == DioExceptionType.badCertificate) {
errorInfo = "Reason: ${dioError.error.toString()}";
} else {
errorInfo = "Reason: $dioError.type";
errorInfo = "Reason: ${dioError.type.toString()}";
}
} else {
if (kDebugMode) {

View File

@@ -250,10 +250,10 @@ packages:
dependency: "direct main"
description:
name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
confetti:
dependency: "direct main"
description:
@@ -286,6 +286,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.1"
cronet_http:
dependency: transitive
description:
name: cronet_http
sha256: "3af9c4d57bf07ef4b307e77b22be4ad61bea19ee6ff65e62184863f3a09f1415"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
cross_file:
dependency: transitive
description:
@@ -310,6 +318,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
cupertino_http:
dependency: transitive
description:
name: cupertino_http
sha256: "6fcf79586ad872ddcd6004d55c8c2aab3cdf0337436e8f99837b1b6c30665d0c"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
dart_style:
dependency: transitive
description:
@@ -346,10 +362,10 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
url: "https://pub.dev"
source: hosted
version: "5.7.0"
version: "5.8.0+1"
dio_web_adapter:
dependency: transitive
description:
@@ -861,6 +877,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.2"
http_profile:
dependency: transitive
description:
name: http_profile
sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
image:
dependency: transitive
description:
@@ -885,6 +909,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
jni:
dependency: transitive
description:
name: jni
sha256: f377c585ea9c08d48b427dc2e03780af2889d1bb094440da853c6883c1acba4b
url: "https://pub.dev"
source: hosted
version: "0.10.1"
js:
dependency: transitive
description:
@@ -1061,6 +1093,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
native_dio_adapter:
dependency: "direct main"
description:
name: native_dio_adapter
sha256: "7420bc9517b2abe09810199a19924617b45690a44ecfb0616ac9babc11875c03"
url: "https://pub.dev"
source: hosted
version: "1.4.0"
nested:
dependency: transitive
description:
@@ -1077,6 +1117,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "62e79ab8c3ed6f6a340ea50dd48d65898f5d70425d404f0d99411f6e56e04584"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
otp:
dependency: "direct main"
description:

View File

@@ -21,7 +21,7 @@ dependencies:
connectivity_plus: ^6.0.5
convert: ^3.1.1
device_info_plus: ^9.1.1
dio: ^5.4.0
dio: ^5.8.0+1
dotted_border: ^2.0.0+2
dropdown_button2: ^2.3.9
email_validator: ^3.0.0
@@ -72,6 +72,7 @@ dependencies:
logging: ^1.0.1
modal_bottom_sheet: ^3.0.0
move_to_background: ^1.0.2
native_dio_adapter: ^1.4.0
otp: ^3.1.1
package_info_plus: ^8.0.2
password_strength: ^0.2.0

View File

@@ -2,6 +2,9 @@
## v1.7.10 (Unreleased)
- Speed up selection for large libraries.
- Support Japanese translations.
- Fix video thumbnail generation on drag and drop.
- .
## v1.7.9

View File

@@ -135,11 +135,11 @@ export const sidebar = [
link: "/photos/faq/hidden-and-archive",
},
{
text: "Machine Learning",
link: "/photos/faq/machine-learning",
text: "Face recognition",
link: "/photos/faq/face-recognition",
},
{
text: "Video Streaming",
text: "Video streaming",
link: "/photos/faq/video-streaming",
},
],

View File

@@ -1,10 +1,10 @@
---
title: Machine Learning FAQ
title: Face recognition
description:
Frequently asked questions about several features of Ente's ML suite
Frequently asked questions about Ente's face recognition
---
# Machine Learning
# Face recognition
## Can I merge or de-merge persons recognized by the app?
@@ -19,7 +19,7 @@ instead of typing the name again, tap on the already given name that should now
be listed.
De-merging a certain grouping can be done by going to the person, pressing
`review suggestions` and then the top right `history icon`. Now press on the
`Review suggestions` and then the top right `History icon`. Now press on the
`minus icon` beside the group you want to de-merge.
### Desktop
@@ -29,6 +29,16 @@ selecting an existing person, and use the "Review suggestions" sheet to de-merge
previously merged persons (click the top right history icon on the suggestion
sheet to see the previous merges, and if necessary, undo them).
## How can I remove an incorrectly grouped face from a person?
On our mobile app, open up the person from the People section, click on the
three dots to open up overflow menu, and click on Edit. Now you will be
presented with the list of all photos that were merged to create this person.
You can click on the merged photos and select the photos you think are
incorrectly grouped (by long-pressing on them) and select "Remove" from the
action bar that pops up to remove any incorrect faces.
## How do I change the cover for a recognized person?
### Mobile

View File

@@ -1,12 +1,11 @@
---
title: Metadata
description: Handling of metadata, in particular creation dates, in Ente Photos
description: Handling of metadata in Ente Photos
---
# Metadata
This document describes Ente's handling of metadata, in particular photo
creation dates.
This document describes Ente's handling of metadata
## Import
@@ -46,7 +45,7 @@ importing that folder into Ente**. This way, we will be able to always correctly
map, for example, `flower.jpeg` and `flower.json` and show the same date for
`flower.jpeg` that you would've seen within Google Photos.
### Screenshots
### File name
In case the photo does not have a date in the Exif data (and it is not a Google
takeout), for example, for screenshots or Whatsapp forwards, Ente will still try
@@ -57,6 +56,28 @@ and deduce the correct date for the file from the name of the file.
> This process works great most of the time, but it is inherently based on
> heuristics and is not exact.
If we are unable to decipher the creation time from these 3 sources, we will set
the upload time as the photo's creation time.
## Modifications
Ente supports modifications to the following metadata:
- File name
- Date & time
- Location
The first two options are available on both mobile and desktop, while the
ability to update location is only available within our mobile apps.
### Bulk modifications
You can bulk-edit creation time of photos from our desktop app, by
multi-selecting items and selecting the "Fix time" option from the action bar.
You can bulk-edit location coordinates of photos from our mobile app, by
multi-selecting items and selecting the "Edit location" option from the action
bar.
## Export
Ente guarantees that you will get back the _exact_ same original photos and

View File

@@ -47,6 +47,9 @@ availability and durability. Our
[reliability document](https://ente.io/reliability) provides in-depth
information about our storage infrastructure and data replication strategies.
In short, we store 3 copies of your data, across 3 different providers, in 3
different countries. One of them is in an underground fall-out shelter in Paris.
### How does Ente's encryption compare to industry standards?
Our encryption model goes beyond industry standards. While many services use

View File

@@ -157,6 +157,21 @@ The same applies to monthly plans.
If you prefer to have this credit refunded to your original payment method,
please contact support@ente.io, and we'll assist you.
## How can I update my payment method?
You can view and manage your payment method by clicking on the green
subscription card within the Ente app, and selecting the "Manage payment method"
button.
You will be able to see all of your previous invoices, with details regarding
their payment status. In case of failed payments, you will also have an option
to retry those charges.
## How can I cancel my subscription?
You can cancel your subscription by clicking on the green subscription card
within the Ente app, and selecting the "Cancel subscription" button.
## Is there an x GB plan?
We have experimented quite a bit and have found it hard to design a single

View File

@@ -1,10 +1,10 @@
---
title: Video Streaming FAQ
title: Video streaming FAQ
description:
Frequently asked questions about Ente's Video Streaming feature
Frequently asked questions about Ente's video streaming feature
---
# Video Streaming
# Video streaming
> [!NOTE]
>
@@ -31,8 +31,8 @@ You can open these videos by tapping on them.
Processed videos will show a `Play stream` button, clicking which will load and
play the stream.
Clicking on the `Info` icon within the original video will show details
about the generated stream.
Clicking on the `Info` icon within the original video will show details about
the generated stream.
### What is a stream?

View File

@@ -50,5 +50,5 @@ end-to-end encrypted security that we use for syncing your photos.
Note that the desktop app does not currently support modifying the face
groupings, that is only supported by the mobile app.
For more information on how to use Machine Learning please check out
[the FAQ](../faq/machine-learning).
For more information on how to use Machine Learning for face recognition please
check out [the FAQ](../faq/face-recognition).

View File

@@ -10,3 +10,9 @@ automatically deleted from Trash after 30 days. You can manaully select photos
to permanently delete or completely empty the trash if you wish.
Items in trash are included in your used storage calculation.
## Recovery
If you have deleted items accidentally, you can recover them from Trash by
selecting these items, and clicking the "Restore" button on the action bar that
pops up.

View File

@@ -1,4 +1,3 @@
import ModeIcon from "@mui/icons-material/Mode";
import {
Button,
CircularProgress,
@@ -15,14 +14,12 @@ import { useEffect, useState } from "react";
import { getEmail, getToken } from "../App";
import { apiOrigin } from "../services/support";
import CloseFamily from "./CloseFamily";
import EditDialog from "./EditStorage";
interface FamilyMember {
id: string;
email: string;
status: string;
usage: number;
storageLimit: number;
}
interface UserData {
@@ -36,11 +33,8 @@ interface UserData {
const FamilyTableComponent: React.FC = () => {
const [familyMembers, setFamilyMembers] = useState<FamilyMember[]>([]);
const [closeFamilyOpen, setCloseFamilyOpen] = useState(false);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [editDialog, setEditDialog] = useState(false);
const [memID, selectedMemID] = useState<string>("");
useEffect(() => {
const fetchData = async () => {
@@ -59,7 +53,9 @@ const FamilyTableComponent: React.FC = () => {
throw new Error("Network response was not ok");
}
const userData = (await response.json()) as UserData; // Typecast to UserData interface
setFamilyMembers(userData.details.familyData.members);
const members: FamilyMember[] =
userData.details.familyData.members;
setFamilyMembers(members);
} catch (error) {
console.error("Error fetching family data:", error);
setError("No family data");
@@ -73,9 +69,9 @@ const FamilyTableComponent: React.FC = () => {
);
}, []);
const formatBytesToGB = (bytesValue: number): string => {
const valueInGB = (bytesValue / (1024 * 1024 * 1024)).toFixed(2);
return `${valueInGB} GB`;
const formatUsageToGB = (usage: number): string => {
const usageInGB = (usage / (1024 * 1024 * 1024)).toFixed(2);
return `${usageInGB} GB`;
};
const handleOpenCloseFamily = () => {
@@ -91,13 +87,6 @@ const FamilyTableComponent: React.FC = () => {
handleOpenCloseFamily();
};
const handleEditDialog = () => {
familyMembers.forEach((member) => {
selectedMemID(member.id);
});
setOpen(true);
setEditDialog(true);
};
if (loading) {
return <CircularProgress />;
}
@@ -122,9 +111,6 @@ const FamilyTableComponent: React.FC = () => {
<Table aria-label="family-table">
<TableHead>
<TableRow>
<TableCell>
<b>ID</b>
</TableCell>
<TableCell>
<b>User</b>
</TableCell>
@@ -135,17 +121,13 @@ const FamilyTableComponent: React.FC = () => {
<b>Usage</b>
</TableCell>
<TableCell>
<b>Storage Limit</b>
</TableCell>
<TableCell>
<b> </b>
<b>ID</b>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{familyMembers.map((member) => (
<TableRow key={member.id}>
<TableCell>{member.id}</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>
<span
@@ -168,29 +150,9 @@ const FamilyTableComponent: React.FC = () => {
</span>
</TableCell>
<TableCell>
{formatBytesToGB(member.usage)}
{formatUsageToGB(member.usage)}
</TableCell>
<TableCell>
{formatBytesToGB(member.storageLimit)}
</TableCell>
{(member.status === "ADMIN" || member.status === "SELF" ) ? (
<span></span>
) : <TableCell>
<div
onClick={handleEditDialog}
style={{
marginLeft: "8px",
cursor: "pointer",
}}
>
<ModeIcon />
<EditDialog
open={editDialog}
setOpen={setEditDialog}
memberID={member.id}
></EditDialog>
</div>
</TableCell>}
<TableCell>{member.id}</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -144,8 +144,6 @@ PODS:
- Flutter
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- media_kit_video (0.0.1):
- Flutter
- motion_sensors (0.0.1):
@@ -189,8 +187,6 @@ PODS:
- PromisesObjC (2.4.0)
- receive_sharing_intent (1.8.0):
- Flutter
- screen_brightness_ios (0.1.0):
- Flutter
- SDWebImage (5.20.0):
- SDWebImage/Core (= 5.20.0)
- SDWebImage/Core (5.20.0)
@@ -278,7 +274,6 @@ DEPENDENCIES:
- maps_launcher (from `.symlinks/plugins/maps_launcher/ios`)
- media_extension (from `.symlinks/plugins/media_extension/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- motion_sensors (from `.symlinks/plugins/motion_sensors/ios`)
- motionphoto (from `.symlinks/plugins/motionphoto/ios`)
@@ -293,7 +288,6 @@ DEPENDENCIES:
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- privacy_screen (from `.symlinks/plugins/privacy_screen/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
@@ -390,8 +384,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/media_extension/ios"
media_kit_libs_ios_video:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_native_event_loop:
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
media_kit_video:
:path: ".symlinks/plugins/media_kit_video/ios"
motion_sensors:
@@ -420,8 +412,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/privacy_screen/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
@@ -450,84 +440,82 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
background_fetch: 94b36ee293e82972852dba8ede1fbcd3bd3d9d57
battery_info: a06b00c06a39bc94c92beebf600f1810cb6c8c87
connectivity_plus: 3f6c9057f4cd64198dc826edfb0542892f825343
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1
device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89
background_fetch: 39f11371c0dce04b001c4bfd5e782bcccb0a85e2
battery_info: 09f5c9ee65394f2291c8c6227bedff345b8a730c
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
ffmpeg-kit-ios-full-gpl: 80adc341962e55ef709e36baa8ed9a70cf4ea62b
ffmpeg_kit_flutter_full_gpl: ce18b888487c05c46ed252cd2e7956812f2e3bd1
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
ffmpeg_kit_flutter_full_gpl: 8d15c14c0c3aba616fac04fe44b3d27d02e3c330
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c
firebase_core: 085320ddfaacb80d1a96eac3a87857afcc150db1
firebase_messaging: d398edc15fe825f832836e74f6ac61e8cd2f3ad3
firebase_core: 2bedc3136ec7c7b8561c6123ed0239387b53f2af
firebase_messaging: 15d114e1a41fc31e4fbabcd48d765a19eec94a38
FirebaseCore: a282032ae9295c795714ded2ec9c522fc237f8da
FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2
FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
FirebaseMessaging: c9ec7b90c399c7a6100297e9d16f8a27fc7f7152
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_email_sender: cd533cdc7ea5eda6fabb2c7f78521c71207778a4
flutter_image_compress: 4b058288a81f76e5e80340af37c709abafff34c4
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: 35ddbc7228eafcb3969dcc5f1fbbe27c1145a4f0
flutter_secure_storage: 2c2ff13db9e0a5647389bff88b0ecac56e3f3418
flutter_sodium: 152647449ba89a157fd48d7e293dcd6d29c6ab0e
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_image_compress: 5a5e9aee05b6553048b8df1c3bc456d0afaac433
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_sodium: c84426b4de738514b5b66cfdeb8a06634e72fe0b
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
image_editor_common: 3de87e7c4804f4ae24c8f8a998362b98c105cac1
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
in_app_purchase_storekit: e126ef1b89e4a9fdf07e28f005f82632b4609437
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
in_app_purchase_storekit: 8c3b0b3eb1b0f04efbff401c3de6266d4258d433
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45
media_extension: a1fec16ee9c8241a6aef9613578ebf097d6c5e64
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
media_kit_native_event_loop: 5fba1a849a6c87a34985f1e178a0de5bd444a0cf
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1
motionphoto: 584b43031ead3060225cdff08fa49818879801d2
move_to_background: 155f7bfbd34d43ad847cb630d2d2d87c17199710
maps_launcher: 2e5b6a2d664ec6c27f82ffa81b74228d770ab203
media_extension: 6d30dc1431ebaa63f43c397c37917b1a0a597a4c
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91
motionphoto: d4a432b8c8f22fb3ad966258597c0103c9c5ff16
move_to_background: 39a5b79b26d577b0372cbe8a8c55e7aa9fcd3a2d
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997
onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c
onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b
open_mail_app: 06d5a4162866388a92b1df3deb96e56be20cf45c
open_mail_app: 794172f6a22cd16319d3ddaf45e945b2f74952b0
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
receive_sharing_intent: f6a12b7e8f7ed745f61c982de8a65de88db44a44
screen_brightness_ios: 5ed898fa50fa82a26171c086ca5e28228f932576
receive_sharing_intent: df9c334dc9feadcbd3266e5cb49c8443405e1c9f
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
Sentry: f8374b5415bc38dfb5645941b3ae31230fbeae57
sentry_flutter: 0a211008f52553ba5dd81ceb71f48d78f0f1f6ab
share_plus: 011d6fb4f9d2576b83179a3a5c5e323202cdabcf
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 44bb54cc302bff1fbe5752293aba1820b157cf1c
sentry_flutter: 0eb93e5279eb41e2392212afe1ccd2fecb4f8cbe
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: a553b1fd6fe66f53bbb0fe5b4f5bab93f08d7a13
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
sqlite3_flutter_libs: 9379996d65aa23dcda7585a5b58766cebe0aa042
system_info_plus: 555ce7047fbbf29154726db942ae785c29211740
sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e
ua_client_hints: 0b48eae1134283f5b131ee0871fa878377f07a01
uni_links: ed8c961e47ed9ce42b6d91e1de8049e38a4b3152
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
volume_controller: ca1cde542ee70fad77d388f82e9616488110942b
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
ua_client_hints: 46bb5817a868f9e397c0ba7e3f2f5c5d90c35156
uni_links: d97da20c7701486ba192624d99bffaaffcfc298a
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1
volume_controller: 2e3de73d6e7e81a0067310d17fb70f2f86d71ac7
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
PODFILE CHECKSUM: 20e086e6008977d43a3d40260f3f9bffcac748dd

View File

@@ -315,7 +315,6 @@
"${BUILT_PRODUCTS_DIR}/maps_launcher/maps_launcher.framework",
"${BUILT_PRODUCTS_DIR}/media_extension/media_extension.framework",
"${BUILT_PRODUCTS_DIR}/media_kit_libs_ios_video/media_kit_libs_ios_video.framework",
"${BUILT_PRODUCTS_DIR}/media_kit_native_event_loop/media_kit_native_event_loop.framework",
"${BUILT_PRODUCTS_DIR}/media_kit_video/media_kit_video.framework",
"${BUILT_PRODUCTS_DIR}/motion_sensors/motion_sensors.framework",
"${BUILT_PRODUCTS_DIR}/motionphoto/motionphoto.framework",
@@ -329,7 +328,6 @@
"${BUILT_PRODUCTS_DIR}/photo_manager/photo_manager.framework",
"${BUILT_PRODUCTS_DIR}/privacy_screen/privacy_screen.framework",
"${BUILT_PRODUCTS_DIR}/receive_sharing_intent/receive_sharing_intent.framework",
"${BUILT_PRODUCTS_DIR}/screen_brightness_ios/screen_brightness_ios.framework",
"${BUILT_PRODUCTS_DIR}/sentry_flutter/sentry_flutter.framework",
"${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework",
"${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework",
@@ -412,7 +410,6 @@
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/maps_launcher.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_extension.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_kit_libs_ios_video.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_kit_native_event_loop.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_kit_video.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/motion_sensors.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/motionphoto.framework",
@@ -426,7 +423,6 @@
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/photo_manager.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/privacy_screen.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/receive_sharing_intent.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/screen_brightness_ios.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sentry_flutter.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework",

View File

@@ -52,7 +52,6 @@ import "package:photos/utils/email_util.dart";
import 'package:photos/utils/file_uploader.dart';
import "package:photos/utils/lock_screen_settings.dart";
import 'package:shared_preferences/shared_preferences.dart';
import "package:video_player_media_kit/video_player_media_kit.dart";
final _logger = Logger("main");
@@ -238,10 +237,6 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
ServiceLocator.instance
.init(preferences, NetworkClient.instance.enteDio, packageInfo);
if (!isBackground) {
VideoPlayerMediaKit.ensureInitialized(iOS: true, android: true);
}
_logger.info("UserService init $tlog");
await UserService.instance.init();
_logger.info("UserService init done $tlog");

View File

@@ -51,6 +51,10 @@ class UserDetails {
}
int getFreeStorage() {
final int? memberLimit = familyMemberStorageLimit();
if (memberLimit != null) {
return max(memberLimit - usage, 0);
}
return max(getTotalStorage() - getFamilyOrPersonalUsage(), 0);
}
@@ -61,6 +65,17 @@ class UserDetails {
storageBonus;
}
// return the member storage limit if user is part of family and the admin
// has set the storage limit for the user.
int? familyMemberStorageLimit() {
if (isPartOfFamily()) {
final FamilyMember? currentUserMember = familyData!.members!
.firstWhereOrNull((element) => element.email.trim() == email.trim());
return currentUserMember?.storageLimit;
}
return null;
}
// This is the total storage for which user has paid for.
int getPlanPlusAddonStorage() {
return (isPartOfFamily() ? familyData!.storage : subscription.storage) +
@@ -106,12 +121,14 @@ class FamilyMember {
final int usage;
final String id;
final bool isAdmin;
final int? storageLimit;
FamilyMember(
this.email,
this.usage,
this.id,
this.isAdmin,
this.storageLimit,
);
factory FamilyMember.fromMap(Map<String, dynamic> map) {
@@ -120,6 +137,7 @@ class FamilyMember {
map['usage'] as int,
map['id'] as String,
map['isAdmin'] as bool,
map['storageLimit'] as int?,
);
}
@@ -129,6 +147,7 @@ class FamilyMember {
'usage': usage,
'id': id,
'isAdmin': isAdmin,
'storageLimit': storageLimit,
};
}
@@ -188,6 +207,10 @@ class FamilyData {
return members!.map((e) => e.usage).toList().sum;
}
FamilyMember? getMemberByID(String id) {
return members!.firstWhereOrNull((element) => element.id == id);
}
static fromMap(Map<String, dynamic>? map) {
if (map == null) return null;
assert(map['members'] != null && map['members'].length >= 0);

View File

@@ -58,10 +58,9 @@ class PreviewVideoStore {
void init(SharedPreferences prefs) {
_prefs = prefs;
Future.delayed(
const Duration(seconds: 10),
_putFilesForPreviewCreation,
);
FileDataService.instance.syncFDStatus().then(
(_) => _putFilesForPreviewCreation(),
);
}
late final SharedPreferences _prefs;
@@ -74,11 +73,13 @@ class PreviewVideoStore {
Future<void> setIsVideoStreamingEnabled(bool value) async {
final oneMonthBack = DateTime.now().subtract(const Duration(days: 30));
await _prefs.setBool(_videoStreamingEnabled, value);
await _prefs.setInt(
_videoStreamingCutoff,
oneMonthBack.millisecondsSinceEpoch,
);
_prefs.setBool(_videoStreamingEnabled, value).ignore();
_prefs
.setInt(
_videoStreamingCutoff,
oneMonthBack.millisecondsSinceEpoch,
)
.ignore();
Bus.instance.fire(VideoStreamingChanged());
if (isVideoStreamingEnabled) {
@@ -664,23 +665,24 @@ class PreviewVideoStore {
}).toList();
// set all video status to in queue
for (final enteFile in allFiles) {
final n = allFiles.length;
for (int i = 0; i < n; i++) {
final enteFile = allFiles[i];
// elimination case for <=10 MB with H.264
final (_, result, _) = await _checkFileForPreviewCreation(enteFile);
if (result) {
allFiles.remove(enteFile);
continue;
allFiles.removeAt(i);
} else {
_items[enteFile.uploadedFileID!] = PreviewItem(
status: PreviewItemStatus.inQueue,
file: enteFile,
collectionID: enteFile.collectionID ?? 0,
);
}
_items[enteFile.uploadedFileID!] = PreviewItem(
status: PreviewItemStatus.inQueue,
file: enteFile,
collectionID: enteFile.collectionID ?? 0,
);
}
Bus.instance.fire(PreviewUpdatedEvent(_items));
_logger.info("[init] Processing ${_items.length} items for streaming");
_logger.info("[init] Processing ${allFiles.length} items for streaming");
// take first file and put it for stream generation
final file = allFiles.removeAt(0);

View File

@@ -24,6 +24,8 @@ class _StorageCardWidgetState extends State<StorageCardWidget> {
late Image _background;
final _logger = Logger((_StorageCardWidgetState).toString());
final ValueNotifier<bool> _isStorageCardPressed = ValueNotifier(false);
int? familyMemberStorageLimit;
bool showFamilyBreakup = false;
@override
void initState() {
@@ -119,9 +121,18 @@ class _StorageCardWidgetState extends State<StorageCardWidget> {
Widget _userDetails(UserDetails userDetails) {
const hundredMBinBytes = 107374182;
const oneTBinBytes = 1073741824000;
showFamilyBreakup = userDetails.isPartOfFamily();
if (showFamilyBreakup) {
familyMemberStorageLimit = userDetails.familyMemberStorageLimit();
showFamilyBreakup = familyMemberStorageLimit == null;
}
final usedStorageInBytes = userDetails.getFamilyOrPersonalUsage();
final totalStorageInBytes = userDetails.getTotalStorage();
final usedStorageInBytes = familyMemberStorageLimit != null
? userDetails.usage
: userDetails.getFamilyOrPersonalUsage();
final totalStorageInBytes = familyMemberStorageLimit != null
? familyMemberStorageLimit!
: userDetails.getTotalStorage();
final freeStorageInBytes = totalStorageInBytes - usedStorageInBytes;
final isMobileScreenSmall =
@@ -212,20 +223,19 @@ class _StorageCardWidgetState extends State<StorageCardWidget> {
Color.fromRGBO(255, 255, 255, 0.2), //hardcoded in figma
fractionOfStorage: 1,
),
userDetails.isPartOfFamily()
showFamilyBreakup
? StorageProgressWidget(
color: strokeBaseDark,
fractionOfStorage:
((userDetails.getFamilyOrPersonalUsage()) /
userDetails.getTotalStorage()),
((usedStorageInBytes) / totalStorageInBytes),
)
: const SizedBox.shrink(),
StorageProgressWidget(
color: userDetails.isPartOfFamily()
color: showFamilyBreakup
? getEnteColorScheme(context).primary300
: strokeBaseDark,
fractionOfStorage:
(userDetails.usage / userDetails.getTotalStorage()),
(userDetails.usage / totalStorageInBytes),
),
],
),
@@ -234,7 +244,7 @@ class _StorageCardWidgetState extends State<StorageCardWidget> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
userDetails.isPartOfFamily()
showFamilyBreakup
? Row(
children: [
Container(
@@ -272,7 +282,9 @@ class _StorageCardWidgetState extends State<StorageCardWidget> {
)
: const SizedBox.shrink(),
Text(
S.of(context).availableStorageSpace(freeSpace, freeSpaceUnit),
S
.of(context)
.availableStorageSpace(freeSpace, freeSpaceUnit),
style: getEnteTextTheme(context)
.mini
.copyWith(color: textFaintDark),

View File

@@ -1,266 +0,0 @@
import 'dart:async';
import "dart:io";
import 'package:chewie/chewie.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import "package:fluttertoast/fluttertoast.dart";
import "package:logging/logging.dart";
import 'package:photos/core/constants.dart';
import "package:photos/core/event_bus.dart";
import "package:photos/events/guest_view_event.dart";
import 'package:photos/models/file/file.dart';
import "package:photos/service_locator.dart";
import "package:photos/services/filedata/filedata_service.dart";
import "package:photos/services/preview_video_store.dart";
import "package:photos/ui/actions/file/file_actions.dart";
import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
import "package:photos/ui/viewer/file/video_control.dart";
import "package:photos/utils/data_util.dart";
// import 'package:photos/ui/viewer/file/video_controls.dart';
import "package:photos/utils/dialog_util.dart";
import 'package:photos/utils/file_util.dart';
import 'package:photos/utils/toast_util.dart';
import "package:photos/utils/wakelock_util.dart";
import 'package:video_player/video_player.dart';
import 'package:visibility_detector/visibility_detector.dart';
class PreviewVideoWidget extends StatefulWidget {
final EnteFile file;
final bool? autoPlay;
final String? tagPrefix;
final Function(bool)? playbackCallback;
final void Function()? onStreamChange;
const PreviewVideoWidget(
this.file, {
this.autoPlay = true,
this.tagPrefix,
this.playbackCallback,
this.onStreamChange,
super.key,
});
@override
State<PreviewVideoWidget> createState() => _PreviewVideoWidgetState();
}
class _PreviewVideoWidgetState extends State<PreviewVideoWidget> {
final _logger = Logger("PreviewVideoWidget");
VideoPlayerController? _videoPlayerController;
ChewieController? _chewieController;
final _progressNotifier = ValueNotifier<double?>(null);
bool _isPlaying = false;
final EnteWakeLock _wakeLock = EnteWakeLock();
bool _isFileSwipeLocked = false;
late final StreamSubscription<GuestViewEvent> _fileSwipeLockEventSubscription;
File? previewFile;
@override
void initState() {
super.initState();
_checkForPreview();
_fileSwipeLockEventSubscription =
Bus.instance.on<GuestViewEvent>().listen((event) {
setState(() {
_isFileSwipeLocked = event.swipeLocked;
});
});
}
@override
void dispose() {
_fileSwipeLockEventSubscription.cancel();
removeCallBack(widget.file);
_videoPlayerController?.dispose();
_chewieController?.dispose();
_progressNotifier.dispose();
_wakeLock.dispose();
super.dispose();
}
Future<void> _checkForPreview() async {
final data = await PreviewVideoStore.instance
.getPlaylist(widget.file)
.onError((error, stackTrace) {
if (!mounted) return;
_logger.warning("Failed to download preview video", error, stackTrace);
Fluttertoast.showToast(msg: "Failed to download preview!");
return null;
});
if (!mounted) return;
if (data != null) {
if (flagService.internalUser) {
final d =
FileDataService.instance.previewIds?[widget.file.uploadedFileID!];
if (d != null && widget.file.fileSize != null) {
// show toast with human readable size
final size = formatBytes(widget.file.fileSize!);
showToast(
context,
"Preview OG Size ($size), previewSize: ${formatBytes(d.objectSize)}",
);
} else {
showShortToast(context, "Playing preview");
}
}
previewFile = data.preview;
_setVideoPlayerController();
}
}
void _setVideoPlayerController() {
if (!mounted) {
// Note: Do not initiale video player if widget is not mounted.
// On Android, if multiple instance of ExoPlayer is created, it will start
// resulting in playback errors for videos. See https://github.com/google/ExoPlayer/issues/6168
return;
}
VideoPlayerController videoPlayerController;
videoPlayerController = VideoPlayerController.file(previewFile!);
debugPrint("videoPlayerController: $videoPlayerController");
_videoPlayerController = videoPlayerController
..initialize().whenComplete(() {
if (mounted) {
setState(() {});
}
}).onError(
(error, stackTrace) {
if (mounted && flagService.internalUser) {
if (error is Exception) {
showErrorDialogForException(
context: context,
exception: error,
message: "Failed to play video\n ${error.toString()}",
);
} else {
showToast(context, "Failed to play video");
}
}
},
);
}
@override
Widget build(BuildContext context) {
final content = _videoPlayerController != null &&
_videoPlayerController!.value.isInitialized
? _getVideoPlayer()
: _getLoadingWidget();
final contentWithDetector = GestureDetector(
onVerticalDragUpdate: _isFileSwipeLocked
? null
: (d) => {
if (d.delta.dy > dragSensitivity)
{
Navigator.of(context).pop(),
}
else if (d.delta.dy < (dragSensitivity * -1))
{
showDetailsSheet(context, widget.file),
},
},
child: content,
);
return VisibilityDetector(
key: Key(widget.file.tag),
onVisibilityChanged: (info) {
if (info.visibleFraction < 1) {
if (mounted && _chewieController != null) {
_chewieController!.pause();
}
}
},
child: Hero(
tag: widget.tagPrefix! + widget.file.tag,
child: contentWithDetector,
),
);
}
Widget _getLoadingWidget() {
return Stack(
children: [
_getThumbnail(),
Container(
color: Colors.black12,
constraints: const BoxConstraints.expand(),
),
Center(
child: SizedBox.fromSize(
size: const Size.square(20),
child: ValueListenableBuilder(
valueListenable: _progressNotifier,
builder: (BuildContext context, double? progress, _) {
return progress == null || progress == 1
? const CupertinoActivityIndicator(
color: Colors.white,
)
: CircularProgressIndicator(
backgroundColor: Colors.black,
value: progress,
valueColor: const AlwaysStoppedAnimation<Color>(
Color.fromRGBO(45, 194, 98, 1.0),
),
);
},
),
),
),
],
);
}
Widget _getThumbnail() {
return Container(
color: Colors.black,
constraints: const BoxConstraints.expand(),
child: ThumbnailWidget(
widget.file,
fit: BoxFit.contain,
),
);
}
Future<void> _keepScreenAliveOnPlaying(bool isPlaying) async {
if (isPlaying) {
_wakeLock.enable();
}
if (!isPlaying) {
_wakeLock.disable();
}
}
Widget _getVideoPlayer() {
_videoPlayerController!.addListener(() {
if (_isPlaying != _videoPlayerController!.value.isPlaying) {
_isPlaying = _videoPlayerController!.value.isPlaying;
if (widget.playbackCallback != null) {
widget.playbackCallback!(_isPlaying);
}
unawaited(_keepScreenAliveOnPlaying(_isPlaying));
}
});
_chewieController = ChewieController(
progressIndicatorDelay: const Duration(milliseconds: 200),
videoPlayerController: _videoPlayerController!,
aspectRatio: _videoPlayerController!.value.aspectRatio,
autoPlay: widget.autoPlay!,
autoInitialize: true,
looping: true,
allowMuting: true,
allowFullScreen: false,
customControls: VideoControls(
file: widget.file,
onStreamChange: widget.onStreamChange,
playbackCallback: widget.playbackCallback,
),
);
return Container(
color: Colors.black,
child: Chewie(controller: _chewieController!),
);
}
}

View File

@@ -1,454 +0,0 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import "package:chewie/chewie.dart";
import "package:chewie/src/helpers/utils.dart";
import "package:chewie/src/notifiers/index.dart";
import 'package:flutter/material.dart';
import "package:photos/models/file/file.dart";
import "package:photos/theme/colors.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/viewer/file/preview_status_widget.dart";
import "package:photos/ui/viewer/file/video_control/custom_progress_bar.dart";
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
class VideoControls extends StatefulWidget {
const VideoControls({
super.key,
required this.file,
required this.onStreamChange,
required this.playbackCallback,
});
final EnteFile file;
final void Function()? onStreamChange;
final void Function(bool)? playbackCallback;
@override
State<StatefulWidget> createState() {
return _VideoControlsState();
}
}
class _VideoControlsState extends State<VideoControls>
with SingleTickerProviderStateMixin {
late PlayerNotifier notifier;
late VideoPlayerValue _latestValue;
Timer? _hideTimer;
Timer? _initTimer;
Timer? _showAfterExpandCollapseTimer;
bool _dragging = false;
bool _displayTapped = false;
Timer? _bufferingDisplayTimer;
bool _displayBufferingIndicator = false;
final barHeight = 48.0 * 1.5;
final marginSize = 5.0;
late VideoPlayerController controller;
ChewieController? _chewieController;
// We know that _chewieController is set in didChangeDependencies
ChewieController get chewieController => _chewieController!;
@override
void initState() {
super.initState();
notifier = Provider.of<PlayerNotifier>(context, listen: false);
}
@override
Widget build(BuildContext context) {
if (_latestValue.hasError) {
return chewieController.errorBuilder?.call(
context,
chewieController.videoPlayerController.value.errorDescription!,
) ??
Center(
child: Icon(
Icons.error,
color: Theme.of(context).colorScheme.onSurface,
size: 42,
),
);
}
return MouseRegion(
onHover: (_) {
_cancelAndRestartTimer();
},
child: GestureDetector(
onTap: () => _cancelAndRestartTimer(),
child: AbsorbPointer(
absorbing: notifier.hideStuff,
child: Stack(
children: [
if (_displayBufferingIndicator)
_chewieController?.bufferingBuilder?.call(context) ??
const Center(
child: EnteLoadingWidget(
size: 32,
color: fillBaseDark,
padding: 0,
),
)
else
_buildHitArea(),
SafeArea(
top: false,
left: false,
right: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
PreviewStatusWidget(
showControls: !notifier.hideStuff,
file: widget.file,
isPreviewPlayer: true,
onStreamChange: widget.onStreamChange,
),
if (!chewieController.isLive) _buildBottomBar(context),
],
),
),
],
),
),
),
);
}
@override
void dispose() {
_dispose();
super.dispose();
}
void _dispose() {
controller.removeListener(_updateState);
_hideTimer?.cancel();
_initTimer?.cancel();
_showAfterExpandCollapseTimer?.cancel();
}
@override
void didChangeDependencies() {
final oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
if (oldController != chewieController) {
_dispose();
_initialize();
}
super.didChangeDependencies();
}
AnimatedOpacity _buildBottomBar(
BuildContext context,
) {
return AnimatedOpacity(
opacity: notifier.hideStuff ? 0.0 : 1.0,
duration: const Duration(milliseconds: 300),
child: Container(
height: 40,
margin: const EdgeInsets.only(bottom: 60),
child: Container(
padding: const EdgeInsets.fromLTRB(
16,
4,
16,
4,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
border: Border.all(
color: strokeFaintDark,
width: 1,
),
),
child: Row(
children: [
Text(
formatDuration(_latestValue.position),
style: getEnteTextTheme(
context,
).mini.copyWith(
color: textBaseDark,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildProgressBar(),
),
const SizedBox(width: 16),
Text(
formatDuration(
_latestValue.duration,
),
style: getEnteTextTheme(
context,
).mini.copyWith(
color: textBaseDark,
),
),
],
),
),
),
);
}
Widget _buildHitArea() {
final bool isFinished = (_latestValue.position >= _latestValue.duration) &&
_latestValue.duration.inSeconds > 0;
final bool showPlayButton = true && !_dragging && !notifier.hideStuff;
return GestureDetector(
onTap: () {
if (_latestValue.isPlaying) {
if (_displayTapped) {
setState(() {
notifier.hideStuff = true;
});
} else {
_cancelAndRestartTimer();
}
} else {
_playPause();
setState(() {
notifier.hideStuff = true;
});
}
widget.playbackCallback?.call(notifier.hideStuff);
},
child: Container(
alignment: Alignment.center,
color: Colors
.transparent, // The Gesture Detector doesn't expand to the full size of the container without this; Not sure why!
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
margin: EdgeInsets.symmetric(
horizontal: marginSize,
),
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: isFinished,
isPlaying: controller.value.isPlaying,
show: showPlayButton,
onPressed: _playPause,
),
),
],
),
),
);
}
void _cancelAndRestartTimer() {
_hideTimer?.cancel();
_startHideTimer();
setState(() {
notifier.hideStuff = false;
_displayTapped = true;
});
widget.playbackCallback?.call(notifier.hideStuff);
}
Future<void> _initialize() async {
controller.addListener(_updateState);
_updateState();
if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer();
}
if (chewieController.showControlsOnInitialize) {
_initTimer = Timer(const Duration(milliseconds: 200), () {
setState(() {
notifier.hideStuff = false;
});
widget.playbackCallback?.call(notifier.hideStuff);
});
}
}
void _playPause() {
final bool isFinished = (_latestValue.position >= _latestValue.duration) &&
_latestValue.duration.inSeconds > 0;
setState(() {
if (controller.value.isPlaying) {
notifier.hideStuff = false;
widget.playbackCallback?.call(notifier.hideStuff);
_hideTimer?.cancel();
controller.pause();
} else {
_cancelAndRestartTimer();
if (!controller.value.isInitialized) {
controller.initialize().then((_) {
controller.play();
});
} else {
if (isFinished) {
controller.seekTo(Duration.zero);
}
controller.play();
}
}
});
}
void _startHideTimer() {
final hideControlsTimer = chewieController.hideControlsTimer.isNegative
? ChewieController.defaultHideControlsTimer
: chewieController.hideControlsTimer;
_hideTimer = Timer(hideControlsTimer, () {
setState(() {
notifier.hideStuff = true;
widget.playbackCallback?.call(notifier.hideStuff);
});
});
}
void _bufferingTimerTimeout() {
_displayBufferingIndicator = true;
if (mounted) {
setState(() {});
}
}
void _updateState() {
if (!mounted) return;
// display the progress bar indicator only after the buffering delay if it has been set
if (chewieController.progressIndicatorDelay != null) {
if (controller.value.isBuffering) {
_bufferingDisplayTimer ??= Timer(
chewieController.progressIndicatorDelay!,
_bufferingTimerTimeout,
);
} else {
_bufferingDisplayTimer?.cancel();
_bufferingDisplayTimer = null;
_displayBufferingIndicator = false;
}
} else {
_displayBufferingIndicator = controller.value.isBuffering;
}
setState(() {
_latestValue = controller.value;
});
}
Widget _buildProgressBar() {
final colorScheme = getEnteColorScheme(context);
return Expanded(
child: CustomProgressBar(
controller,
onDragStart: () {
setState(() {
_dragging = true;
});
_hideTimer?.cancel();
},
onDragUpdate: () {
_hideTimer?.cancel();
},
onDragEnd: () {
setState(() {
_dragging = false;
});
_startHideTimer();
},
colors: ChewieProgressColors(
playedColor: colorScheme.primary300,
handleColor: backgroundElevatedLight,
bufferedColor: backgroundElevatedLight.withOpacity(0.5),
backgroundColor: fillMutedDark,
),
draggableProgressBar: chewieController.draggableProgressBar,
),
);
}
}
class CenterPlayButton extends StatelessWidget {
const CenterPlayButton({
super.key,
required this.backgroundColor,
this.iconColor,
required this.show,
required this.isPlaying,
required this.isFinished,
this.onPressed,
});
final Color backgroundColor;
final Color? iconColor;
final bool show;
final bool isPlaying;
final bool isFinished;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
shape: BoxShape.circle,
border: Border.all(
color: strokeFaintDark,
width: 1,
),
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
switchInCurve: Curves.easeInOutQuart,
switchOutCurve: Curves.easeInOutQuart,
child: isPlaying
? const Icon(
Icons.pause,
size: 32,
key: ValueKey("pause"),
color: Colors.white,
)
: const Icon(
Icons.play_arrow,
size: 36,
key: ValueKey("play"),
color: Colors.white,
),
),
),
),
);
}
}

View File

@@ -7,8 +7,8 @@ import "package:photos/events/use_media_kit_for_video.dart";
import "package:photos/models/file/file.dart";
import "package:photos/services/filedata/filedata_service.dart";
import "package:photos/services/preview_video_store.dart";
import "package:photos/ui/viewer/file/preview_video_widget.dart";
import "package:photos/ui/viewer/file/video_widget_media_kit_new.dart";
import "package:photos/ui/viewer/file/video_widget_media_kit_preview.dart";
import "package:photos/ui/viewer/file/video_widget_native.dart";
class VideoWidget extends StatefulWidget {
@@ -60,7 +60,7 @@ class _VideoWidgetState extends State<VideoWidget> {
?.containsKey(widget.file.uploadedFileID!) ??
false);
if (isPreviewVideoPlayable && selectPreviewForPlay) {
return PreviewVideoWidget(
return VideoWidgetMediaKitPreview(
widget.file,
tagPrefix: widget.tagPrefix,
playbackCallback: widget.playbackCallback,

View File

@@ -0,0 +1,458 @@
import "dart:async";
import "package:flutter/material.dart";
import "package:media_kit_video/media_kit_video.dart";
import "package:photos/models/file/file.dart";
import "package:photos/theme/colors.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/viewer/file/preview_status_widget.dart";
import "package:photos/utils/date_time_util.dart";
import "package:photos/utils/debouncer.dart";
class VideoWidget extends StatefulWidget {
final EnteFile file;
final VideoController controller;
final Function(bool)? playbackCallback;
final bool isFromMemories;
final void Function() onStreamChange;
const VideoWidget(
this.file,
this.controller,
this.playbackCallback, {
super.key,
required this.isFromMemories,
// ignore: unused_element
required this.onStreamChange,
});
@override
State<VideoWidget> createState() => _VideoWidgetState();
}
class _VideoWidgetState extends State<VideoWidget> {
final showControlsNotifier = ValueNotifier<bool>(true);
static const verticalMargin = 72.0;
final _hideControlsDebouncer = Debouncer(
const Duration(milliseconds: 2000),
);
final _isSeekingNotifier = ValueNotifier<bool>(false);
late final StreamSubscription<bool> _isPlayingStreamSubscription;
@override
void initState() {
_isPlayingStreamSubscription =
widget.controller.player.stream.playing.listen((isPlaying) {
if (isPlaying && !_isSeekingNotifier.value) {
_hideControlsDebouncer.run(() async {
showControlsNotifier.value = false;
widget.playbackCallback?.call(true);
});
}
});
_isSeekingNotifier.addListener(isSeekingListener);
super.initState();
}
@override
void dispose() {
showControlsNotifier.dispose();
_isPlayingStreamSubscription.cancel();
_hideControlsDebouncer.cancelDebounceTimer();
_isSeekingNotifier.removeListener(isSeekingListener);
_isSeekingNotifier.dispose();
super.dispose();
}
void isSeekingListener() {
if (_isSeekingNotifier.value) {
_hideControlsDebouncer.cancelDebounceTimer();
} else {
if (widget.controller.player.state.playing) {
_hideControlsDebouncer.run(() async {
showControlsNotifier.value = false;
widget.playbackCallback?.call(false);
});
}
}
}
@override
Widget build(BuildContext context) {
return Video(
controller: widget.controller,
controls: (state) {
return ValueListenableBuilder(
valueListenable: showControlsNotifier,
builder: (context, value, _) {
return AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: value ? 1 : 0,
curve: Curves.easeInOutQuad,
child: Stack(
alignment: Alignment.center,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
showControlsNotifier.value = !showControlsNotifier.value;
if (widget.playbackCallback != null) {
widget.playbackCallback!(
!showControlsNotifier.value,
);
}
},
child: Container(
constraints: const BoxConstraints.expand(),
),
),
IgnorePointer(
ignoring: !value,
child: PlayPauseButtonMediaKit(widget.controller),
),
Positioned(
bottom: verticalMargin,
right: 0,
left: 0,
child: IgnorePointer(
ignoring: !value,
child: SafeArea(
top: false,
left: false,
right: false,
child: Padding(
padding: EdgeInsets.only(
bottom: widget.isFromMemories ? 32 : 0,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
widget.file.caption != null
? Padding(
padding: const EdgeInsets.fromLTRB(
16,
12,
16,
8,
),
child: Text(
widget.file.caption!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: getEnteTextTheme(context)
.mini
.copyWith(
color: textBaseDark,
),
textAlign: TextAlign.center,
),
)
: const SizedBox.shrink(),
PreviewStatusWidget(
showControls: value,
file: widget.file,
isPreviewPlayer: true,
onStreamChange: widget.onStreamChange,
),
SeekBarAndDuration(
controller: widget.controller,
isSeekingNotifier: _isSeekingNotifier,
),
],
),
),
),
),
),
],
),
);
},
);
},
);
}
}
class PlayPauseButtonMediaKit extends StatefulWidget {
final VideoController? controller;
const PlayPauseButtonMediaKit(
this.controller, {
super.key,
});
@override
State<PlayPauseButtonMediaKit> createState() => _PlayPauseButtonState();
}
class _PlayPauseButtonState extends State<PlayPauseButtonMediaKit> {
bool _isPlaying = true;
late final StreamSubscription<bool>? isPlayingStreamSubscription;
@override
void initState() {
super.initState();
isPlayingStreamSubscription =
widget.controller?.player.stream.playing.listen((isPlaying) {
setState(() {
_isPlaying = isPlaying;
});
});
}
@override
void dispose() {
isPlayingStreamSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (widget.controller?.player.state.playing ?? false) {
widget.controller?.player.pause();
} else {
widget.controller?.player.play();
}
},
child: Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
shape: BoxShape.circle,
border: Border.all(
color: strokeFaintDark,
width: 1,
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
switchInCurve: Curves.easeInOutQuart,
switchOutCurve: Curves.easeInOutQuart,
child: _isPlaying
? const Icon(
Icons.pause,
size: 32,
key: ValueKey("pause"),
color: Colors.white,
)
: const Icon(
Icons.play_arrow,
size: 36,
key: ValueKey("play"),
color: Colors.white,
),
),
),
);
}
}
class SeekBarAndDuration extends StatelessWidget {
final VideoController? controller;
final ValueNotifier<bool> isSeekingNotifier;
const SeekBarAndDuration({
super.key,
required this.controller,
required this.isSeekingNotifier,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: Container(
padding: const EdgeInsets.fromLTRB(
16,
4,
16,
4,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
border: Border.all(
color: strokeFaintDark,
width: 1,
),
),
child: Row(
children: [
StreamBuilder(
stream: controller?.player.stream.position,
builder: (context, snapshot) {
if (snapshot.data == null) {
return Text(
"0:00",
style: getEnteTextTheme(
context,
).mini.copyWith(
color: textBaseDark,
),
);
}
return Text(
secondsToDuration(snapshot.data!.inSeconds),
style: getEnteTextTheme(
context,
).mini.copyWith(
color: textBaseDark,
),
);
},
),
Expanded(
child: SeekBar(
controller!,
isSeekingNotifier,
),
),
Text(
_secondsToDuration(
controller!.player.state.duration.inSeconds,
),
style: getEnteTextTheme(
context,
).mini.copyWith(
color: textBaseDark,
),
),
],
),
),
);
}
/// Returns the duration in the format "h:mm:ss" or "m:ss".
String _secondsToDuration(int totalSeconds) {
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
}
class SeekBar extends StatefulWidget {
final VideoController controller;
final ValueNotifier<bool> isSeekingNotifier;
const SeekBar(
this.controller,
this.isSeekingNotifier, {
super.key,
});
@override
State<SeekBar> createState() => _SeekBarState();
}
class _SeekBarState extends State<SeekBar> {
double _sliderValue = 0.0;
late final StreamSubscription<Duration> _positionStreamSubscription;
final _debouncer = Debouncer(
const Duration(milliseconds: 300),
executionInterval: const Duration(milliseconds: 300),
);
@override
void initState() {
super.initState();
_positionStreamSubscription =
widget.controller.player.stream.position.listen((event) {
if (widget.isSeekingNotifier.value) return;
if (mounted) {
setState(() {
_sliderValue = event.inMilliseconds /
widget.controller.player.state.duration.inMilliseconds;
if (_sliderValue.isNaN) {
_sliderValue = 0.0;
}
});
}
});
}
@override
void dispose() {
_positionStreamSubscription.cancel();
_debouncer.cancelDebounceTimer();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 1.0,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0),
activeTrackColor: colorScheme.primary300,
inactiveTrackColor: fillMutedDark,
thumbColor: backgroundElevatedLight,
overlayColor: fillMutedDark,
),
child: Slider(
min: 0.0,
max: 1.0,
value: _sliderValue,
onChangeStart: (value) {
if (mounted) {
setState(() {
widget.isSeekingNotifier.value = true;
});
}
},
onChanged: (value) {
if (mounted) {
setState(() {
_sliderValue = value;
});
}
_debouncer.run(() async {
await widget.controller.player.seek(
Duration(
milliseconds: (value *
widget.controller.player.state.duration.inMilliseconds)
.round(),
),
);
});
},
divisions: 4500,
onChangeEnd: (value) async {
await widget.controller.player.seek(
Duration(
milliseconds: (value *
widget.controller.player.state.duration.inMilliseconds)
.round(),
),
);
if (mounted) {
setState(() {
widget.isSeekingNotifier.value = false;
});
}
},
allowedInteraction: SliderInteraction.tapAndSlide,
),
);
}
}

View File

@@ -14,10 +14,10 @@ import "package:photos/models/file/extensions/file_props.dart";
import "package:photos/models/file/file.dart";
import "package:photos/services/files_service.dart";
import "package:photos/theme/colors.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/actions/file/file_actions.dart";
import "package:photos/ui/viewer/file/preview_status_widget.dart";
import "package:photos/utils/debouncer.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/viewer/file/video_widget_media_kit_common.dart"
as common;
import "package:photos/utils/dialog_util.dart";
import "package:photos/utils/file_util.dart";
import "package:photos/utils/toast_util.dart";
@@ -27,14 +27,14 @@ class VideoWidgetMediaKitNew extends StatefulWidget {
final String? tagPrefix;
final Function(bool)? playbackCallback;
final bool isFromMemories;
final void Function()? onStreamChange;
final void Function() onStreamChange;
const VideoWidgetMediaKitNew(
this.file, {
this.tagPrefix,
this.playbackCallback,
this.isFromMemories = false,
this.onStreamChange,
required this.onStreamChange,
super.key,
});
@@ -137,44 +137,20 @@ class _VideoWidgetMediaKitNewState extends State<VideoWidgetMediaKitNew>
},
child: Center(
child: controller != null
? _VideoWidget(
? common.VideoWidget(
widget.file,
controller!,
widget.playbackCallback,
isFromMemories: widget.isFromMemories,
onStreamChange: widget.onStreamChange,
)
// : Stack(
// children: [
// _getThumbnail(),
// Container(
// color: Colors.black12,
// constraints: const BoxConstraints.expand(),
// ),
// Center(
// child: SizedBox.fromSize(
// size: const Size.square(20),
// child: ValueListenableBuilder(
// valueListenable: _progressNotifier,
// builder: (BuildContext context, double? progress, _) {
// return progress == null || progress == 1
// ? const CupertinoActivityIndicator(
// color: Colors.white,
// )
// : CircularProgressIndicator(
// backgroundColor: Colors.black,
// value: progress,
// valueColor:
// const AlwaysStoppedAnimation<Color>(
// Color.fromRGBO(45, 194, 98, 1.0),
// ),
// );
// },
// ),
// ),
// ),
// ],
// ),
: const SizedBox.shrink(),
: const Center(
child: EnteLoadingWidget(
size: 32,
color: fillBaseDark,
padding: 0,
),
),
),
);
}
@@ -229,452 +205,3 @@ class _VideoWidgetMediaKitNewState extends State<VideoWidgetMediaKitNew>
}
}
}
class _VideoWidget extends StatefulWidget {
final EnteFile file;
final VideoController controller;
final Function(bool)? playbackCallback;
final bool isFromMemories;
final void Function()? onStreamChange;
const _VideoWidget(
this.file,
this.controller,
this.playbackCallback, {
required this.isFromMemories,
// ignore: unused_element
this.onStreamChange,
});
@override
State<_VideoWidget> createState() => __VideoWidgetState();
}
class __VideoWidgetState extends State<_VideoWidget> {
final showControlsNotifier = ValueNotifier<bool>(true);
static const verticalMargin = 72.0;
final _hideControlsDebouncer = Debouncer(
const Duration(milliseconds: 2000),
);
final _isSeekingNotifier = ValueNotifier<bool>(false);
late final StreamSubscription<bool> _isPlayingStreamSubscription;
@override
void initState() {
_isPlayingStreamSubscription =
widget.controller.player.stream.playing.listen((isPlaying) {
if (isPlaying && !_isSeekingNotifier.value) {
_hideControlsDebouncer.run(() async {
showControlsNotifier.value = false;
widget.playbackCallback?.call(true);
});
}
});
_isSeekingNotifier.addListener(isSeekingListener);
super.initState();
}
@override
void dispose() {
showControlsNotifier.dispose();
_isPlayingStreamSubscription.cancel();
_hideControlsDebouncer.cancelDebounceTimer();
_isSeekingNotifier.removeListener(isSeekingListener);
_isSeekingNotifier.dispose();
super.dispose();
}
void isSeekingListener() {
if (_isSeekingNotifier.value) {
_hideControlsDebouncer.cancelDebounceTimer();
} else {
if (widget.controller.player.state.playing) {
_hideControlsDebouncer.run(() async {
showControlsNotifier.value = false;
widget.playbackCallback?.call(false);
});
}
}
}
@override
Widget build(BuildContext context) {
return Video(
controller: widget.controller,
controls: (state) {
return ValueListenableBuilder(
valueListenable: showControlsNotifier,
builder: (context, value, _) {
return AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: value ? 1 : 0,
curve: Curves.easeInOutQuad,
child: Stack(
alignment: Alignment.center,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
showControlsNotifier.value = !showControlsNotifier.value;
if (widget.playbackCallback != null) {
widget.playbackCallback!(
!showControlsNotifier.value,
);
}
},
child: Container(
constraints: const BoxConstraints.expand(),
),
),
IgnorePointer(
ignoring: !value,
child: PlayPauseButtonMediaKit(widget.controller),
),
Positioned(
bottom: verticalMargin,
right: 0,
left: 0,
child: IgnorePointer(
ignoring: !value,
child: SafeArea(
top: false,
left: false,
right: false,
child: Padding(
padding: EdgeInsets.only(
bottom: widget.isFromMemories ? 32 : 0,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
widget.file.caption != null
? Padding(
padding: const EdgeInsets.fromLTRB(
16,
12,
16,
8,
),
child: Text(
widget.file.caption!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: getEnteTextTheme(context)
.mini
.copyWith(
color: textBaseDark,
),
textAlign: TextAlign.center,
),
)
: const SizedBox.shrink(),
ValueListenableBuilder(
valueListenable: showControlsNotifier,
builder: (context, value, _) {
return PreviewStatusWidget(
showControls: value,
file: widget.file,
onStreamChange: widget.onStreamChange,
);
},
),
_SeekBarAndDuration(
controller: widget.controller,
isSeekingNotifier: _isSeekingNotifier,
),
],
),
),
),
),
),
],
),
);
},
);
},
);
}
}
class PlayPauseButtonMediaKit extends StatefulWidget {
final VideoController? controller;
const PlayPauseButtonMediaKit(
this.controller, {
super.key,
});
@override
State<PlayPauseButtonMediaKit> createState() => _PlayPauseButtonState();
}
class _PlayPauseButtonState extends State<PlayPauseButtonMediaKit> {
bool _isPlaying = true;
late final StreamSubscription<bool>? isPlayingStreamSubscription;
@override
void initState() {
super.initState();
isPlayingStreamSubscription =
widget.controller?.player.stream.playing.listen((isPlaying) {
setState(() {
_isPlaying = isPlaying;
});
});
}
@override
void dispose() {
isPlayingStreamSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (widget.controller?.player.state.playing ?? false) {
widget.controller?.player.pause();
} else {
widget.controller?.player.play();
}
},
child: Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
shape: BoxShape.circle,
border: Border.all(
color: strokeFaintDark,
width: 1,
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
switchInCurve: Curves.easeInOutQuart,
switchOutCurve: Curves.easeInOutQuart,
child: _isPlaying
? const Icon(
Icons.pause,
size: 32,
key: ValueKey("pause"),
color: Colors.white,
)
: const Icon(
Icons.play_arrow,
size: 36,
key: ValueKey("play"),
color: Colors.white,
),
),
),
);
}
}
class _SeekBarAndDuration extends StatelessWidget {
final VideoController? controller;
final ValueNotifier<bool> isSeekingNotifier;
const _SeekBarAndDuration({
required this.controller,
required this.isSeekingNotifier,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: Container(
padding: const EdgeInsets.fromLTRB(
16,
4,
16,
4,
),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.3),
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
border: Border.all(
color: strokeFaintDark,
width: 1,
),
),
child: Row(
children: [
StreamBuilder(
stream: controller?.player.stream.position,
builder: (context, snapshot) {
if (snapshot.data == null) {
return Text(
"0:00",
style: getEnteTextTheme(
context,
).mini.copyWith(
color: textBaseDark,
),
);
}
return Text(
_secondsToDuration(snapshot.data!.inSeconds),
style: getEnteTextTheme(
context,
).mini.copyWith(
color: textBaseDark,
),
);
},
),
Expanded(
child: _SeekBar(
controller!,
isSeekingNotifier,
),
),
Text(
_secondsToDuration(
controller!.player.state.duration.inSeconds,
),
style: getEnteTextTheme(
context,
).mini.copyWith(
color: textBaseDark,
),
),
],
),
),
);
}
/// Returns the duration in the format "h:mm:ss" or "m:ss".
String _secondsToDuration(int totalSeconds) {
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
}
class _SeekBar extends StatefulWidget {
final VideoController controller;
final ValueNotifier<bool> isSeekingNotifier;
const _SeekBar(
this.controller,
this.isSeekingNotifier,
);
@override
State<_SeekBar> createState() => _SeekBarState();
}
class _SeekBarState extends State<_SeekBar> {
double _sliderValue = 0.0;
late final StreamSubscription<Duration> _positionStreamSubscription;
final _debouncer = Debouncer(
const Duration(milliseconds: 300),
executionInterval: const Duration(milliseconds: 300),
);
@override
void initState() {
super.initState();
_positionStreamSubscription =
widget.controller.player.stream.position.listen((event) {
if (widget.isSeekingNotifier.value) return;
if (mounted) {
setState(() {
_sliderValue = event.inMilliseconds /
widget.controller.player.state.duration.inMilliseconds;
if (_sliderValue.isNaN) {
_sliderValue = 0.0;
}
});
}
});
}
@override
void dispose() {
_positionStreamSubscription.cancel();
_debouncer.cancelDebounceTimer();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
return SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 1.0,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0),
activeTrackColor: colorScheme.primary300,
inactiveTrackColor: fillMutedDark,
thumbColor: backgroundElevatedLight,
overlayColor: fillMutedDark,
),
child: Slider(
min: 0.0,
max: 1.0,
value: _sliderValue,
onChangeStart: (value) {
if (mounted) {
setState(() {
widget.isSeekingNotifier.value = true;
});
}
},
onChanged: (value) {
if (mounted) {
setState(() {
_sliderValue = value;
});
}
_debouncer.run(() async {
await widget.controller.player.seek(
Duration(
milliseconds: (value *
widget.controller.player.state.duration.inMilliseconds)
.round(),
),
);
});
},
divisions: 4500,
onChangeEnd: (value) async {
await widget.controller.player.seek(
Duration(
milliseconds: (value *
widget.controller.player.state.duration.inMilliseconds)
.round(),
),
);
if (mounted) {
setState(() {
widget.isSeekingNotifier.value = false;
});
}
},
allowedInteraction: SliderInteraction.tapAndSlide,
),
);
}
}

View File

@@ -0,0 +1,172 @@
import "dart:async";
import "package:flutter/material.dart";
import "package:fluttertoast/fluttertoast.dart";
import "package:logging/logging.dart";
import "package:media_kit/media_kit.dart";
import "package:media_kit_video/media_kit_video.dart";
import "package:photos/core/constants.dart";
import "package:photos/core/event_bus.dart";
import "package:photos/events/guest_view_event.dart";
import "package:photos/events/pause_video_event.dart";
import "package:photos/models/file/file.dart";
import "package:photos/service_locator.dart";
import "package:photos/services/filedata/filedata_service.dart";
import "package:photos/services/preview_video_store.dart";
import "package:photos/theme/colors.dart";
import "package:photos/ui/actions/file/file_actions.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/viewer/file/video_widget_media_kit_common.dart"
as common;
import "package:photos/utils/data_util.dart";
import "package:photos/utils/file_util.dart";
import "package:photos/utils/toast_util.dart";
class VideoWidgetMediaKitPreview extends StatefulWidget {
final EnteFile file;
final String? tagPrefix;
final Function(bool)? playbackCallback;
final bool isFromMemories;
final void Function() onStreamChange;
const VideoWidgetMediaKitPreview(
this.file, {
this.tagPrefix,
this.playbackCallback,
this.isFromMemories = false,
required this.onStreamChange,
super.key,
});
@override
State<VideoWidgetMediaKitPreview> createState() =>
_VideoWidgetMediaKitPreviewState();
}
class _VideoWidgetMediaKitPreviewState extends State<VideoWidgetMediaKitPreview>
with WidgetsBindingObserver {
final Logger _logger = Logger("VideoWidgetMediaKitNew");
late final player = Player();
VideoController? controller;
final _progressNotifier = ValueNotifier<double?>(null);
bool _isAppInFG = true;
late StreamSubscription<PauseVideoEvent> pauseVideoSubscription;
bool isGuestView = false;
late final StreamSubscription<GuestViewEvent> _guestViewEventSubscription;
bool _isGuestView = false;
@override
void initState() {
_logger.info(
'initState for ${widget.file.generatedID} with tag ${widget.file.tag} and name ${widget.file.displayName}',
);
super.initState();
WidgetsBinding.instance.addObserver(this);
_checkForPreview();
pauseVideoSubscription = Bus.instance.on<PauseVideoEvent>().listen((event) {
player.pause();
});
_guestViewEventSubscription =
Bus.instance.on<GuestViewEvent>().listen((event) {
setState(() {
_isGuestView = event.isGuestView;
});
});
}
Future<void> _checkForPreview() async {
widget.playbackCallback?.call(false);
final data = await PreviewVideoStore.instance
.getPlaylist(widget.file)
.onError((error, stackTrace) {
if (!mounted) return;
_logger.warning("Failed to download preview video", error, stackTrace);
Fluttertoast.showToast(msg: "Failed to download preview!");
return null;
});
if (!mounted) return;
if (data != null) {
if (flagService.internalUser) {
final d =
FileDataService.instance.previewIds?[widget.file.uploadedFileID!];
if (d != null && widget.file.fileSize != null) {
// show toast with human readable size
final size = formatBytes(widget.file.fileSize!);
showToast(
context,
"[i] Preview OG Size ($size), previewSize: ${formatBytes(d.objectSize)}",
);
} else {
showShortToast(context, "Playing preview");
}
}
_setVideoController(data.preview.path);
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_isAppInFG = true;
} else {
_isAppInFG = false;
}
}
@override
void dispose() {
_guestViewEventSubscription.cancel();
pauseVideoSubscription.cancel();
removeCallBack(widget.file);
_progressNotifier.dispose();
WidgetsBinding.instance.removeObserver(this);
player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onVerticalDragUpdate: _isGuestView
? null
: (d) => {
if (d.delta.dy > dragSensitivity)
{
Navigator.of(context).pop(),
}
else if (d.delta.dy < (dragSensitivity * -1))
{
showDetailsSheet(context, widget.file),
},
},
child: Center(
child: controller != null
? common.VideoWidget(
widget.file,
controller!,
widget.playbackCallback,
isFromMemories: widget.isFromMemories,
onStreamChange: widget.onStreamChange,
)
: const Center(
child: EnteLoadingWidget(
size: 32,
color: fillBaseDark,
padding: 0,
),
),
),
);
}
void _setVideoController(String url) {
if (mounted) {
setState(() {
player.setPlaylistMode(PlaylistMode.single);
controller = VideoController(player);
player.open(Media(url), play: _isAppInFG);
});
}
}
}

View File

@@ -24,6 +24,7 @@ import "package:photos/ui/viewer/file/native_video_player_controls/play_pause_bu
import "package:photos/ui/viewer/file/native_video_player_controls/seek_bar.dart";
import "package:photos/ui/viewer/file/preview_status_widget.dart";
import "package:photos/ui/viewer/file/thumbnail_widget.dart";
import "package:photos/utils/date_time_util.dart";
import "package:photos/utils/debouncer.dart";
import "package:photos/utils/dialog_util.dart";
import "package:photos/utils/exif_util.dart";
@@ -615,9 +616,7 @@ class _SeekBarAndDuration extends StatelessWidget {
_,
) {
return Text(
_secondsToDuration(
value,
),
secondsToDuration(value),
style: getEnteTextTheme(
context,
).mini.copyWith(
@@ -630,17 +629,13 @@ class _SeekBarAndDuration extends StatelessWidget {
Expanded(
child: SeekBar(
controller!,
_durationToSeconds(
duration,
),
durationToSeconds(duration),
isSeeking,
),
),
Text(
duration ?? "0:00",
style: getEnteTextTheme(
context,
).mini.copyWith(
style: getEnteTextTheme(context).mini.copyWith(
color: textBaseDark,
),
),
@@ -653,43 +648,6 @@ class _SeekBarAndDuration extends StatelessWidget {
},
);
}
/// Returns the duration in the format "h:mm:ss" or "m:ss".
String _secondsToDuration(int totalSeconds) {
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
/// Returns the duration in seconds from the format "h:mm:ss" or "m:ss".
int? _durationToSeconds(String? duration) {
if (duration == null) {
return null;
}
final parts = duration.split(':');
int seconds = 0;
if (parts.length == 3) {
// Format: "h:mm:ss"
seconds += int.parse(parts[0]) * 3600; // Hours to seconds
seconds += int.parse(parts[1]) * 60; // Minutes to seconds
seconds += int.parse(parts[2]); // Seconds
} else if (parts.length == 2) {
// Format: "m:ss"
seconds += int.parse(parts[0]) * 60; // Minutes to seconds
seconds += int.parse(parts[1]); // Seconds
} else {
throw FormatException('Invalid duration format: $duration');
}
return seconds;
}
}
class _VideoDescriptionAndSwitchToMediaKitButton extends StatelessWidget {

View File

@@ -216,3 +216,40 @@ bool isNumeric(String? s) {
}
return double.tryParse(s) != null;
}
/// Returns the duration in seconds from the format "h:mm:ss" or "m:ss".
int? durationToSeconds(String? duration) {
if (duration == null) {
return null;
}
final parts = duration.split(':');
int seconds = 0;
if (parts.length == 3) {
// Format: "h:mm:ss"
seconds += int.parse(parts[0]) * 3600; // Hours to seconds
seconds += int.parse(parts[1]) * 60; // Minutes to seconds
seconds += int.parse(parts[2]); // Seconds
} else if (parts.length == 2) {
// Format: "m:ss"
seconds += int.parse(parts[0]) * 60; // Minutes to seconds
seconds += int.parse(parts[1]); // Seconds
} else {
throw FormatException('Invalid duration format: $duration');
}
return seconds;
}
/// Returns the duration in the format "h:mm:ss" or "m:ss".
String secondsToDuration(int totalSeconds) {
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
if (hours > 0) {
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}

View File

@@ -977,26 +977,26 @@ packages:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "22dbf16f23a4bcf9d35e51be1c84ad5bb6f627750565edd70dab70f3ff5fff8f"
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b"
sha256: bf7404619d7ab5c0a1151d7c4e802edad8f33535abfbeff2f9e1fe1274e2d705
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81"
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
@@ -1017,10 +1017,10 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "38f9501c7cb6f38961ef0e1eacacee2b2d4715c63cc83fe56449c4d3d0b47255"
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "3.1.2"
flutter_shaders:
dependency: transitive
description:
@@ -1386,7 +1386,7 @@ packages:
source: hosted
version: "0.10.1"
js:
dependency: transitive
dependency: "direct overridden"
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
@@ -1589,10 +1589,11 @@ packages:
media_kit:
dependency: "direct main"
description:
name: media_kit
sha256: "1f1deee148533d75129a6f38251ff8388e33ee05fc2d20a6a80e57d6051b7b62"
url: "https://pub.dev"
source: hosted
path: media_kit
ref: HEAD
resolved-ref: "3c4ff28c43d20e68f8d587956b2f525292c25a80"
url: "https://github.com/media-kit/media-kit"
source: git
version: "1.1.11"
media_kit_libs_android_video:
dependency: transitive
@@ -1605,10 +1606,11 @@ packages:
media_kit_libs_ios_video:
dependency: "direct main"
description:
name: media_kit_libs_ios_video
sha256: b5382994eb37a4564c368386c154ad70ba0cc78dacdd3fb0cd9f30db6d837991
url: "https://pub.dev"
source: hosted
path: "libs/ios/media_kit_libs_ios_video"
ref: HEAD
resolved-ref: "3c4ff28c43d20e68f8d587956b2f525292c25a80"
url: "https://github.com/media-kit/media-kit"
source: git
version: "1.1.4"
media_kit_libs_linux:
dependency: transitive
@@ -1629,10 +1631,11 @@ packages:
media_kit_libs_video:
dependency: "direct main"
description:
name: media_kit_libs_video
sha256: "20bb4aefa8fece282b59580e1cd8528117297083a6640c98c2e98cfc96b93288"
url: "https://pub.dev"
source: hosted
path: "libs/universal/media_kit_libs_video"
ref: HEAD
resolved-ref: "3c4ff28c43d20e68f8d587956b2f525292c25a80"
url: "https://github.com/media-kit/media-kit"
source: git
version: "1.0.5"
media_kit_libs_windows_video:
dependency: transitive
@@ -1642,21 +1645,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.10"
media_kit_native_event_loop:
dependency: transitive
description:
name: media_kit_native_event_loop
sha256: "7d82e3b3e9ded5c35c3146c5ba1da3118d1dd8ac3435bac7f29f458181471b40"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
media_kit_video:
dependency: "direct main"
description:
name: media_kit_video
sha256: "2cc3b966679963ba25a4ce5b771e532a521ebde7c6aa20e9802bec95d9916c8f"
url: "https://pub.dev"
source: hosted
path: media_kit_video
ref: HEAD
resolved-ref: "3c4ff28c43d20e68f8d587956b2f525292c25a80"
url: "https://github.com/media-kit/media-kit"
source: git
version: "1.2.5"
meta:
dependency: transitive
@@ -2171,58 +2167,26 @@ packages:
dependency: transitive
description:
name: safe_local_storage
sha256: ede4eb6cb7d88a116b3d3bf1df70790b9e2038bc37cb19112e381217c74d9440
sha256: e9a21b6fec7a8aa62cc2585ff4c1b127df42f3185adbd2aca66b47abe2e80236
url: "https://pub.dev"
source: hosted
version: "1.0.2"
screen_brightness:
dependency: transitive
description:
name: screen_brightness
sha256: ed8da4a4511e79422fc1aa88138e920e4008cd312b72cdaa15ccb426c0faaedd
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
version: "2.0.1"
screen_brightness_android:
dependency: transitive
description:
name: screen_brightness_android
sha256: "3df10961e3a9e968a5e076fe27e7f4741fa8a1d3950bdeb48cf121ed529d0caf"
sha256: ff9141bed547db02233e7dd88f990ab01973a0c8a8c04ddb855c7b072f33409a
url: "https://pub.dev"
source: hosted
version: "0.1.0+2"
screen_brightness_ios:
dependency: transitive
description:
name: screen_brightness_ios
sha256: "99adc3ca5490b8294284aad5fcc87f061ad685050e03cf45d3d018fe398fd9a2"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
screen_brightness_macos:
dependency: transitive
description:
name: screen_brightness_macos
sha256: "64b34e7e3f4900d7687c8e8fb514246845a73ecec05ab53483ed025bd4a899fd"
url: "https://pub.dev"
source: hosted
version: "0.1.0+1"
version: "2.1.0"
screen_brightness_platform_interface:
dependency: transitive
description:
name: screen_brightness_platform_interface
sha256: b211d07f0c96637a15fb06f6168617e18030d5d74ad03795dd8547a52717c171
sha256: "737bd47b57746bc4291cab1b8a5843ee881af499514881b0247ec77447ee769c"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
screen_brightness_windows:
dependency: transitive
description:
name: screen_brightness_windows
sha256: "9261bf33d0fc2707d8cf16339ce25768100a65e70af0fcabaf032fc12408ba86"
url: "https://pub.dev"
source: hosted
version: "0.1.3"
version: "2.1.0"
screenshot:
dependency: "direct main"
description:
@@ -2712,10 +2676,10 @@ packages:
dependency: transitive
description:
name: uri_parser
sha256: "6543c9fd86d2862fac55d800a43e67c0dcd1a41677cb69c2f8edfe73bbcf1835"
sha256: ff4d2c720aca3f4f7d5445e23b11b2d15ef8af5ddce5164643f38ff962dcb270
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "3.0.0"
url_launcher:
dependency: "direct main"
description:
@@ -2862,14 +2826,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.2"
video_player_media_kit:
dependency: "direct main"
description:
name: video_player_media_kit
sha256: eadf78b85d0ecc6f65bb5ca84c5ad9546a8609c6c0ee207e81673f7969461f3b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
video_player_platform_interface:
dependency: transitive
description:
@@ -2914,10 +2870,10 @@ packages:
dependency: transitive
description:
name: volume_controller
sha256: c71d4c62631305df63b72da79089e078af2659649301807fa746088f365cb48e
sha256: "68975792fbd2ac36cd2aa387273669772a452785d94b6db5808bf2dc3e44a0f1"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
version: "3.3.0"
wakelock_plus:
dependency: "direct main"
description:

View File

@@ -12,7 +12,7 @@ description: ente photos application
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.9.99+1002
version: 0.9.99+1003
publish_to: none
environment:
@@ -90,7 +90,7 @@ dependencies:
flutter_map_marker_cluster: ^1.3.6
flutter_native_splash: ^2.2.0+1
flutter_password_strength: ^0.1.6
flutter_secure_storage: ^8.0.0
flutter_secure_storage: ^9.2.4
flutter_sodium: ^0.2.0
flutter_staggered_grid_view: ^0.6.2
flutter_svg: ^2.0.10+1
@@ -120,10 +120,22 @@ dependencies:
git:
url: "https://github.com/ente-io/media_extension.git"
ref: deeplink_fixes
media_kit: ^1.1.10+1
media_kit_libs_ios_video: ^1.1.4
media_kit_libs_video: ^1.0.4
media_kit_video: ^1.2.4
media_kit:
git:
url: https://github.com/media-kit/media-kit
path: media_kit
media_kit_libs_ios_video:
git:
url: https://github.com/media-kit/media-kit
path: libs/ios/media_kit_libs_ios_video
media_kit_libs_video:
git:
url: https://github.com/media-kit/media-kit
path: libs/universal/media_kit_libs_video
media_kit_video:
git:
url: https://github.com/media-kit/media-kit
path: media_kit_video
ml_linalg: ^13.11.31
modal_bottom_sheet: ^3.0.0-pre
motion_photos:
@@ -195,7 +207,6 @@ dependencies:
url: https://github.com/ente-io/packages.git
ref: android_video_roation_fix
path: packages/video_player/video_player/
video_player_media_kit: ^1.0.5
video_thumbnail: ^0.5.3
visibility_detector: ^0.3.3
wakelock_plus: ^1.1.1
@@ -210,6 +221,23 @@ dependency_overrides:
# Newer flutter packages depends on ffi > 2.0.0 while flutter_sodium depends on ffi < 2.0.0
ffi: 2.1.0
intl: 0.18.1
js: ^0.6.7
media_kit:
git:
url: https://github.com/media-kit/media-kit
path: media_kit
media_kit_libs_ios_video:
git:
url: https://github.com/media-kit/media-kit
path: libs/ios/media_kit_libs_ios_video
media_kit_libs_video:
git:
url: https://github.com/media-kit/media-kit
path: libs/universal/media_kit_libs_video
media_kit_video:
git:
url: https://github.com/media-kit/media-kit
path: media_kit_video
video_player:
git:
url: https://github.com/ente-io/packages.git

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
b64 "encoding/base64"
"fmt"
"github.com/ente-io/museum/pkg/controller/collections"
"net/http"
"os"
"os/signal"
@@ -305,7 +306,7 @@ func main() {
JwtSecret: jwtSecretBytes,
}
collectionController := &controller.CollectionController{
collectionController := &collections.CollectionController{
CollectionRepo: collectionRepo,
EmailCtrl: emailNotificationCtrl,
AccessCtrl: accessCtrl,
@@ -537,6 +538,7 @@ func main() {
//lint:ignore SA1019 Deprecated API will be removed in the future
privateAPI.GET("/collections", collectionHandler.Get)
privateAPI.GET("/collections/v2", collectionHandler.GetV2)
privateAPI.GET("/collections/v3", collectionHandler.GetWithLimit)
privateAPI.POST("/collections/share", collectionHandler.Share)
privateAPI.POST("/collections/join-link", collectionHandler.JoinLink)
privateAPI.POST("/collections/share-url", collectionHandler.ShareURL)
@@ -752,14 +754,7 @@ func main() {
pushHandler := &api.PushHandler{PushController: pushController}
privateAPI.POST("/push/token", pushHandler.AddToken)
embeddingController := embeddingCtrl.New(embeddingRepo, accessCtrl, objectCleanupController, s3Config, queueRepo, taskLockingRepo, fileRepo, collectionRepo, hostName)
embeddingHandler := &api.EmbeddingHandler{Controller: embeddingController}
privateAPI.PUT("/embeddings", embeddingHandler.InsertOrUpdate)
privateAPI.GET("/embeddings/diff", embeddingHandler.GetDiff)
privateAPI.GET("/embeddings/indexed-files", embeddingHandler.GetIndexedFiles)
privateAPI.POST("/embeddings/files", embeddingHandler.GetFilesEmbedding)
privateAPI.DELETE("/embeddings", embeddingHandler.DeleteAll)
embeddingController := embeddingCtrl.New(embeddingRepo, objectCleanupController, queueRepo, taskLockingRepo, fileRepo, hostName)
offerHandler := &api.OfferHandler{Controller: offerController}
publicAPI.GET("/offers/black-friday", offerHandler.GetBlackFridayOffers)

View File

@@ -1,2 +1,2 @@
ALTER TABLE families
ADD COLUMN storage_limit BIGINT;
ADD COLUMN storage_limit BIGINT NULL;

View File

@@ -5,6 +5,7 @@ import (
entity "github.com/ente-io/museum/ente/cast"
"github.com/ente-io/museum/pkg/controller"
"github.com/ente-io/museum/pkg/controller/cast"
"github.com/ente-io/museum/pkg/controller/collections"
"github.com/ente-io/museum/pkg/utils/handler"
"github.com/ente-io/stacktrace"
"github.com/gin-gonic/gin"
@@ -16,7 +17,7 @@ import (
// CastHandler exposes request handlers for publicly accessible collections
type CastHandler struct {
FileCtrl *controller.FileController
CollectionCtrl *controller.CollectionController
CollectionCtrl *collections.CollectionController
Ctrl *cast.Controller
}

View File

@@ -2,6 +2,7 @@ package api
import (
"fmt"
"github.com/ente-io/museum/pkg/controller/collections"
"net/http"
"strconv"
@@ -18,7 +19,7 @@ import (
// CollectionHandler exposes request handlers for all collection related requests
type CollectionHandler struct {
Controller *controller.CollectionController
Controller *collections.CollectionController
}
// Create creates a collection
@@ -64,18 +65,20 @@ func (h *CollectionHandler) GetCollectionByID(c *gin.Context) {
// Deprecated: Remove once rps goes to 0.
// Get returns the list of collections accessible to a user.
func (h *CollectionHandler) Get(c *gin.Context) {
h.GetV2(c)
}
// GetV2 returns the list of collections accessible to a user
func (h *CollectionHandler) GetV2(c *gin.Context) {
userID := auth.GetUserID(c.Request.Header)
sinceTime, _ := strconv.ParseInt(c.Query("sinceTime"), 10, 64)
app := auth.GetApp(c)
// TODO: Compute both with a single query
ownedCollections, err := h.Controller.GetOwned(userID, sinceTime, app)
ownedCollections, err := h.Controller.GetOwnedV2(userID, sinceTime, app, nil)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, "Failed to get owned collections"))
return
}
sharedCollections, err := h.Controller.GetSharedWith(userID, sinceTime, app)
sharedCollections, err := h.Controller.GetSharedWith(userID, sinceTime, app, nil)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, "Failed to get shared collections"))
return
@@ -85,23 +88,32 @@ func (h *CollectionHandler) Get(c *gin.Context) {
})
}
// GetV2 returns the list of collections accessible to a user
func (h *CollectionHandler) GetV2(c *gin.Context) {
// GetWithLimit returns owned and shared collections accessible to a user
func (h *CollectionHandler) GetWithLimit(c *gin.Context) {
userID := auth.GetUserID(c.Request.Header)
sinceTime, _ := strconv.ParseInt(c.Query("sinceTime"), 10, 64)
sharedSinceTime, _ := strconv.ParseInt(c.Query("sharedSinceTime"), 10, 64)
limit := int64(1000)
if c.Query("limit") != "" {
limit, _ = strconv.ParseInt(c.Query("limit"), 10, 64)
if limit > 1000 {
limit = 1000
}
}
app := auth.GetApp(c)
ownedCollections, err := h.Controller.GetOwnedV2(userID, sinceTime, app)
ownedCollections, err := h.Controller.GetOwnedV2(userID, sinceTime, app, &limit)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, "Failed to get owned collections"))
return
}
sharedCollections, err := h.Controller.GetSharedWith(userID, sinceTime, app)
sharedCollections, err := h.Controller.GetSharedWith(userID, sharedSinceTime, app, &limit)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, "Failed to get shared collections"))
return
}
c.JSON(http.StatusOK, gin.H{
"collections": append(ownedCollections, sharedCollections...),
"owned": ownedCollections,
"shared": sharedCollections,
})
}

View File

@@ -1,95 +0,0 @@
package api
import (
"fmt"
"net/http"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/controller/embedding"
"github.com/ente-io/museum/pkg/utils/handler"
"github.com/ente-io/stacktrace"
"github.com/gin-gonic/gin"
)
type EmbeddingHandler struct {
Controller *embedding.Controller
}
// InsertOrUpdate handler for inserting or updating embedding
func (h *EmbeddingHandler) InsertOrUpdate(c *gin.Context) {
var request ente.InsertOrUpdateEmbeddingRequest
if err := c.ShouldBindJSON(&request); err != nil {
handler.Error(c,
stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("Request binding failed %s", err)))
return
}
embedding, err := h.Controller.InsertOrUpdate(c, request)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
c.JSON(http.StatusOK, embedding)
}
// GetDiff handler for getting diff of embedding
func (h *EmbeddingHandler) GetDiff(c *gin.Context) {
var request ente.GetEmbeddingDiffRequest
if err := c.ShouldBindQuery(&request); err != nil {
handler.Error(c,
stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("Request binding failed %s", err)))
return
}
embeddings, err := h.Controller.GetDiff(c, request)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
c.JSON(http.StatusOK, gin.H{
"diff": embeddings,
})
}
// GetIndexedFiles returns the fileIDs that has been indexed or updated for given user
func (h *EmbeddingHandler) GetIndexedFiles(c *gin.Context) {
var request ente.GetIndexedFiles
if err := c.ShouldBindQuery(&request); err != nil {
handler.Error(c,
stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("Request binding failed %s", err)))
return
}
embeddings, err := h.Controller.GetIndexedFiles(c, request)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
c.JSON(http.StatusOK, gin.H{
"diff": embeddings,
})
}
// GetFilesEmbedding returns the embeddings for the files
func (h *EmbeddingHandler) GetFilesEmbedding(c *gin.Context) {
var request ente.GetFilesEmbeddingRequest
if err := c.ShouldBindJSON(&request); err != nil {
handler.Error(c,
stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("Request binding failed %s", err)))
return
}
resp, err := h.Controller.GetFilesEmbedding(c, request)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
c.JSON(http.StatusOK, resp)
}
// DeleteAll handler for deleting all embeddings for the user
func (h *EmbeddingHandler) DeleteAll(c *gin.Context) {
err := h.Controller.DeleteAll(c)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return
}
c.Status(http.StatusOK)
}

View File

@@ -42,7 +42,7 @@ func (h *FamilyHandler) InviteMember(c *gin.Context) {
return
}
err := h.Controller.InviteMember(c, auth.GetUserID(c.Request.Header), request.Email, request.StorageLimit)
err := h.Controller.InviteMember(c, auth.GetUserID(c.Request.Header), request.Email)
if err != nil {
handler.Error(c, stacktrace.Propagate(err, ""))
return

View File

@@ -2,6 +2,7 @@ package api
import (
"fmt"
"github.com/ente-io/museum/pkg/controller/collections"
"net/http"
"strconv"
@@ -19,7 +20,7 @@ import (
type PublicCollectionHandler struct {
Controller *controller.PublicCollectionController
FileCtrl *controller.FileController
CollectionCtrl *controller.CollectionController
CollectionCtrl *collections.CollectionController
StorageBonusController *storagebonus.Controller
}

View File

@@ -1,829 +0,0 @@
package controller
import (
"context"
"encoding/json"
"fmt"
"runtime/debug"
"strings"
"github.com/ente-io/museum/pkg/repo/cast"
"github.com/ente-io/museum/pkg/controller/access"
"github.com/ente-io/museum/pkg/controller/email"
"github.com/gin-contrib/requestid"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/ente-io/museum/pkg/utils/array"
"github.com/ente-io/museum/pkg/utils/auth"
"github.com/gin-gonic/gin"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/repo"
"github.com/ente-io/museum/pkg/utils/time"
"github.com/ente-io/stacktrace"
log "github.com/sirupsen/logrus"
)
const (
CollectionDiffLimit = 2500
)
// CollectionController encapsulates logic that deals with collections
type CollectionController struct {
PublicCollectionCtrl *PublicCollectionController
EmailCtrl *email.EmailNotificationController
AccessCtrl access.Controller
BillingCtrl *BillingController
CollectionRepo *repo.CollectionRepository
UserRepo *repo.UserRepository
FileRepo *repo.FileRepository
QueueRepo *repo.QueueRepository
CastRepo *cast.Repository
TaskRepo *repo.TaskLockRepository
}
// Create creates a collection
func (c *CollectionController) Create(collection ente.Collection, ownerID int64) (ente.Collection, error) {
// The key attribute check is to ensure that user does not end up uploading any files before actually setting the key attributes.
if _, keyErr := c.UserRepo.GetKeyAttributes(ownerID); keyErr != nil {
return ente.Collection{}, stacktrace.Propagate(keyErr, "Unable to get keyAttributes")
}
collectionType := collection.Type
collection.Owner.ID = ownerID
collection.UpdationTime = time.Microseconds()
// [20th Dec 2022] Patch on server side untill majority of the existing mobile clients upgrade to a version higher > 0.7.0
// https://github.com/ente-io/photos-app/pull/725
if collection.Type == "CollectionType.album" {
collection.Type = "album"
}
if !array.StringInList(collection.Type, ente.ValidCollectionTypes) {
return ente.Collection{}, stacktrace.Propagate(fmt.Errorf("unexpected collection type %s", collection.Type), "")
}
collection, err := c.CollectionRepo.Create(collection)
if err != nil {
if err == ente.ErrUncategorizeCollectionAlreadyExists || err == ente.ErrFavoriteCollectionAlreadyExist {
dbCollection, err := c.CollectionRepo.GetCollectionByType(ownerID, collectionType)
if err != nil {
return ente.Collection{}, stacktrace.Propagate(err, "")
}
if dbCollection.IsDeleted {
return ente.Collection{}, stacktrace.Propagate(fmt.Errorf("special collection of type : %s is deleted", collectionType), "")
}
return dbCollection, nil
}
return ente.Collection{}, stacktrace.Propagate(err, "")
}
return collection, nil
}
// GetOwned returns the list of collections owned by a user
func (c *CollectionController) GetOwned(userID int64, sinceTime int64, app ente.App) ([]ente.Collection, error) {
collections, err := c.CollectionRepo.GetCollectionsOwnedByUser(userID, sinceTime, app)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
go func() {
defer func() {
if r := recover(); r != nil {
log.Errorf("Panic caught: %s, stack: %s", r, string(debug.Stack()))
}
}()
collectionsV2, errV2 := c.CollectionRepo.GetCollectionsOwnedByUserV2(userID, sinceTime, app)
if errV2 != nil {
log.WithError(errV2).Error("failed to fetch collections using v2")
}
isEqual := cmp.Equal(collections, collectionsV2, cmpopts.SortSlices(func(a, b ente.Collection) bool { return a.ID < b.ID }))
if !isEqual {
jsonV1, _ := json.Marshal(collections)
jsonV2, _ := json.Marshal(collectionsV2)
log.WithFields(log.Fields{
"v1": string(jsonV1),
"v2": string(jsonV2),
}).Error("collections diff didn't match")
} else {
log.Info("collections diff matched")
}
}()
return collections, nil
}
// GetOwnedV2 returns the list of collections owned by a user using optimized query
func (c *CollectionController) GetOwnedV2(userID int64, sinceTime int64, app ente.App) ([]ente.Collection, error) {
collections, err := c.CollectionRepo.GetCollectionsOwnedByUserV2(userID, sinceTime, app)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return collections, nil
}
// GetCollection returns the collection for given collectionID
func (c *CollectionController) GetCollection(ctx *gin.Context, userID int64, cID int64) (ente.Collection, error) {
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
IncludeDeleted: true,
})
if err != nil {
return ente.Collection{}, stacktrace.Propagate(err, "")
}
return resp.Collection, nil
}
// GetSharedWith returns the list of collections that are shared with a user
func (c *CollectionController) GetSharedWith(userID int64, sinceTime int64, app ente.App) ([]ente.Collection, error) {
collections, err := c.CollectionRepo.GetCollectionsSharedWithUser(userID, sinceTime, app)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return collections, nil
}
// Share shares a collection with a user
func (c *CollectionController) Share(ctx *gin.Context, req ente.AlterShareRequest) ([]ente.CollectionUser, error) {
fromUserID := auth.GetUserID(ctx.Request.Header)
cID := req.CollectionID
encryptedKey := req.EncryptedKey
toUserEmail := strings.ToLower(strings.TrimSpace(req.Email))
// default role type
role := ente.VIEWER
if req.Role != nil {
role = *req.Role
}
toUserID, err := c.UserRepo.GetUserIDWithEmail(toUserEmail)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if toUserID == fromUserID {
return nil, stacktrace.Propagate(ente.ErrBadRequest, "Can not share collection with self")
}
collection, err := c.CollectionRepo.Get(cID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if !collection.AllowSharing() {
return nil, stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("sharing %s is not allowed", collection.Type))
}
if fromUserID != collection.Owner.ID {
return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
err = c.BillingCtrl.HasActiveSelfOrFamilySubscription(fromUserID, true)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
err = c.CollectionRepo.Share(cID, fromUserID, toUserID, encryptedKey, role, time.Microseconds())
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
sharees, err := c.GetSharees(ctx, cID, fromUserID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return sharees, nil
}
func (c *CollectionController) JoinViaLink(ctx *gin.Context, req ente.JoinCollectionViaLinkRequest) error {
userID := auth.GetUserID(ctx.Request.Header)
collection, err := c.CollectionRepo.Get(req.CollectionID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if collection.Owner.ID == userID {
return stacktrace.Propagate(ente.ErrBadRequest, "owner can not join via link")
}
if !collection.AllowSharing() {
return stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("joining %s is not allowed", collection.Type))
}
publicCollectionToken, err := c.PublicCollectionCtrl.GetActivePublicCollectionToken(ctx, req.CollectionID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if canJoin := publicCollectionToken.CanJoin(); canJoin != nil {
return stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("can not join collection: %s", canJoin.Error()))
}
accessToken := auth.GetAccessToken(ctx)
if publicCollectionToken.Token != accessToken {
return stacktrace.Propagate(ente.ErrPermissionDenied, "token doesn't match collection")
}
if publicCollectionToken.PassHash != nil && *publicCollectionToken.PassHash != "" {
accessTokenJWT := auth.GetAccessTokenJWT(ctx)
if passCheckErr := c.PublicCollectionCtrl.ValidateJWTToken(ctx, accessTokenJWT, *publicCollectionToken.PassHash); passCheckErr != nil {
return stacktrace.Propagate(passCheckErr, "")
}
}
err = c.BillingCtrl.HasActiveSelfOrFamilySubscription(collection.Owner.ID, true)
if err != nil {
return stacktrace.Propagate(err, "")
}
role := ente.VIEWER
if publicCollectionToken.EnableCollect {
role = ente.COLLABORATOR
}
joinErr := c.CollectionRepo.Share(req.CollectionID, collection.Owner.ID, userID, req.EncryptedKey, role, time.Microseconds())
if joinErr != nil {
return stacktrace.Propagate(joinErr, "")
}
go c.EmailCtrl.OnLinkJoined(collection.Owner.ID, userID, role)
return nil
}
// UnShare unshares a collection with a user
func (c *CollectionController) UnShare(ctx *gin.Context, cID int64, fromUserID int64, toUserEmail string) ([]ente.CollectionUser, error) {
toUserID, err := c.UserRepo.GetUserIDWithEmail(toUserEmail)
if err != nil {
return nil, stacktrace.Propagate(ente.ErrNotFound, "")
}
collection, err := c.CollectionRepo.Get(cID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
isLeavingCollection := toUserID == fromUserID
if fromUserID != collection.Owner.ID || isLeavingCollection {
return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
err = c.CollectionRepo.UnShare(cID, toUserID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
err = c.CastRepo.RevokeForGivenUserAndCollection(ctx, cID, toUserID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
sharees, err := c.GetSharees(ctx, cID, fromUserID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return sharees, nil
}
// Leave leaves the collection owned by someone else,
func (c *CollectionController) Leave(ctx *gin.Context, cID int64) error {
userID := auth.GetUserID(ctx.Request.Header)
collection, err := c.CollectionRepo.Get(cID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if userID == collection.Owner.ID {
return stacktrace.Propagate(ente.ErrPermissionDenied, "can not leave collection owned by self")
}
sharedCollectionIDs, err := c.CollectionRepo.GetCollectionIDsSharedWithUser(userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if !array.Int64InList(cID, sharedCollectionIDs) {
return nil
}
err = c.CastRepo.RevokeForGivenUserAndCollection(ctx, cID, userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
err = c.CollectionRepo.UnShare(cID, userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
func (c *CollectionController) UpdateShareeMagicMetadata(ctx *gin.Context, req ente.UpdateCollectionMagicMetadata) error {
actorUserId := auth.GetUserID(ctx.Request.Header)
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.ID,
ActorUserID: actorUserId,
})
if err != nil {
return stacktrace.Propagate(err, "")
}
if resp.Collection.Owner.ID == actorUserId {
return stacktrace.Propagate(ente.NewBadRequestWithMessage("owner can not update sharee magic metadata"), "")
}
err = c.CollectionRepo.UpdateShareeMetadata(req.ID, resp.Collection.Owner.ID, actorUserId, req.MagicMetadata, time.Microseconds())
if err != nil {
return stacktrace.Propagate(err, "failed to update sharee magic metadata")
}
return nil
}
// ShareURL generates a public auth-token for the given collectionID
func (c *CollectionController) ShareURL(ctx context.Context, userID int64, req ente.CreatePublicAccessTokenRequest) (
ente.PublicURL, error) {
collection, err := c.CollectionRepo.Get(req.CollectionID)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
if !collection.AllowSharing() {
return ente.PublicURL{}, stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("sharing %s is not allowed", collection.Type))
}
if userID != collection.Owner.ID {
return ente.PublicURL{}, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
err = c.BillingCtrl.HasActiveSelfOrFamilySubscription(userID, true)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
response, err := c.PublicCollectionCtrl.CreateAccessToken(ctx, req)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
return response, nil
}
// UpdateShareURL updates the shared url configuration
func (c *CollectionController) UpdateShareURL(ctx context.Context, userID int64, req ente.UpdatePublicAccessTokenRequest) (
ente.PublicURL, error) {
if err := c.verifyOwnership(req.CollectionID, userID); err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
err := c.BillingCtrl.HasActiveSelfOrFamilySubscription(userID, true)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
response, err := c.PublicCollectionCtrl.UpdateSharedUrl(ctx, req)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
return response, nil
}
// DisableSharedURL disable a public auth-token for the given collectionID
func (c *CollectionController) DisableSharedURL(ctx context.Context, userID int64, cID int64) error {
if err := c.verifyOwnership(cID, userID); err != nil {
return stacktrace.Propagate(err, "")
}
err := c.PublicCollectionCtrl.Disable(ctx, cID)
return stacktrace.Propagate(err, "")
}
// AddFiles adds files to a collection
func (c *CollectionController) AddFiles(ctx *gin.Context, userID int64, files []ente.CollectionFileItem, cID int64) error {
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
IncludeDeleted: false,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify collection access")
}
if !resp.Role.CanAdd() {
return stacktrace.Propagate(ente.ErrPermissionDenied, fmt.Sprintf("user %d with role %s can not add files", userID, *resp.Role))
}
collectionOwnerID := resp.Collection.Owner.ID
filesOwnerID := userID
// Verify that the user owns each file
fileIDs := make([]int64, 0)
for _, file := range files {
fileIDs = append(fileIDs, file.ID)
}
err = c.AccessCtrl.VerifyFileOwnership(ctx, &access.VerifyFileOwnershipParams{
ActorUserId: userID,
FileIDs: fileIDs,
})
if err != nil {
return stacktrace.Propagate(err, "Failed to verify fileOwnership")
}
err = c.CollectionRepo.AddFiles(cID, collectionOwnerID, files, filesOwnerID)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
// RestoreFiles restore files from trash and add to the collection
func (c *CollectionController) RestoreFiles(ctx *gin.Context, userID int64, cID int64, files []ente.CollectionFileItem) error {
_, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
IncludeDeleted: false,
VerifyOwner: true,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify collection access")
}
// Verify that the user owns each file
for _, file := range files {
// todo #perf find owners of all files
ownerID, err := c.FileRepo.GetOwnerID(file.ID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if ownerID != userID {
log.WithFields(log.Fields{
"file_id": file.ID,
"owner_id": ownerID,
"user_id": userID,
}).Error("invalid ops: can't add file which isn't owned by user")
return stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
}
err = c.CollectionRepo.RestoreFiles(ctx, userID, cID, files)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
// MoveFiles from one collection to another collection. Both the collections and files should belong to
// single user
func (c *CollectionController) MoveFiles(ctx *gin.Context, req ente.MoveFilesRequest) error {
userID := auth.GetUserID(ctx.Request.Header)
_, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.FromCollectionID,
ActorUserID: userID,
IncludeDeleted: false,
VerifyOwner: true,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify if actor owns fromCollection")
}
_, err = c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.ToCollectionID,
ActorUserID: userID,
IncludeDeleted: false,
VerifyOwner: true,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify if actor owns toCollection")
}
// Verify that the user owns each file
fileIDs := make([]int64, 0)
for _, file := range req.Files {
fileIDs = append(fileIDs, file.ID)
}
err = c.AccessCtrl.VerifyFileOwnership(ctx, &access.VerifyFileOwnershipParams{
ActorUserId: userID,
FileIDs: fileIDs,
})
if err != nil {
stacktrace.Propagate(err, "Failed to verify fileOwnership")
}
err = c.CollectionRepo.MoveFiles(ctx, req.ToCollectionID, req.FromCollectionID, req.Files, userID, userID)
return stacktrace.Propagate(err, "") // return nil if err is nil
}
// RemoveFilesV3 removes files from a collection as long as owner(s) of the file is different from collection owner
func (c *CollectionController) RemoveFilesV3(ctx *gin.Context, req ente.RemoveFilesV3Request) error {
actorUserID := auth.GetUserID(ctx.Request.Header)
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.CollectionID,
ActorUserID: actorUserID,
VerifyOwner: false,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify collection access")
}
err = c.isRemoveAllowed(ctx, actorUserID, resp.Collection.Owner.ID, req.FileIDs)
if err != nil {
return stacktrace.Propagate(err, "file removal check failed")
}
err = c.CollectionRepo.RemoveFilesV3(ctx, req.CollectionID, req.FileIDs)
if err != nil {
return stacktrace.Propagate(err, "failed to remove files")
}
return nil
}
// isRemoveAllowed verifies that given set of files can be removed from the collection or not
func (c *CollectionController) isRemoveAllowed(ctx *gin.Context, actorUserID int64, collectionOwnerID int64, fileIDs []int64) error {
ownerToFilesMap, err := c.FileRepo.GetOwnerToFileIDsMap(ctx, fileIDs)
if err != nil {
return stacktrace.Propagate(err, "failed to get owner to fileIDs map")
}
// verify that none of the file belongs to the collection owner
if _, ok := ownerToFilesMap[collectionOwnerID]; ok {
return ente.NewBadRequestWithMessage("can not remove files owned by album owner")
}
if collectionOwnerID != actorUserID {
// verify that user is only trying to remove files owned by them
if len(ownerToFilesMap) > 1 {
return stacktrace.Propagate(ente.ErrPermissionDenied, "can not remove files owned by others")
}
// verify that user is only trying to remove files owned by them
if _, ok := ownerToFilesMap[actorUserID]; !ok {
return stacktrace.Propagate(ente.ErrPermissionDenied, "can not remove files owned by others")
}
}
return nil
}
func (c *CollectionController) IsCopyAllowed(ctx *gin.Context, actorUserID int64, req ente.CopyFileSyncRequest) error {
// verify that srcCollectionID is accessible by actorUserID
if _, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.SrcCollectionID,
ActorUserID: actorUserID,
}); err != nil {
return stacktrace.Propagate(err, "failed to verify srcCollection access")
}
// verify that dstCollectionID is owned by actorUserID
if _, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.DstCollection,
ActorUserID: actorUserID,
VerifyOwner: true,
}); err != nil {
return stacktrace.Propagate(err, "failed to ownership of the dstCollection access")
}
// verify that all FileIDs exists in the srcCollection
fileIDs := make([]int64, len(req.CollectionFileItems))
for idx, file := range req.CollectionFileItems {
fileIDs[idx] = file.ID
}
if err := c.CollectionRepo.VerifyAllFileIDsExistsInCollection(ctx, req.SrcCollectionID, fileIDs); err != nil {
return stacktrace.Propagate(err, "failed to verify fileIDs in srcCollection")
}
dsMap, err := c.FileRepo.GetOwnerToFileIDsMap(ctx, fileIDs)
if err != nil {
return err
}
// verify that none of the file belongs to actorUserID
if _, ok := dsMap[actorUserID]; ok {
return ente.NewBadRequestWithMessage("can not copy files owned by actor")
}
return nil
}
// GetDiffV2 returns the changes in user's collections since a timestamp, along with hasMore bool flag.
func (c *CollectionController) GetDiffV2(ctx *gin.Context, cID int64, userID int64, sinceTime int64) ([]ente.File, bool, error) {
reqContextLogger := log.WithFields(log.Fields{
"user_id": userID,
"collection_id": cID,
"since_time": sinceTime,
"req_id": requestid.Get(ctx),
})
_, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
})
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to verify access")
}
diff, hasMore, err := c.getDiff(cID, sinceTime, CollectionDiffLimit, reqContextLogger)
if err != nil {
return nil, false, stacktrace.Propagate(err, "")
}
// hide private metadata before returning files info in diff
for idx := range diff {
if diff[idx].OwnerID != userID {
diff[idx].MagicMetadata = nil
}
if diff[idx].Metadata.EncryptedData == "-" && !diff[idx].IsDeleted {
// This indicates that the file is deleted, but we still have a stale entry in the collection
log.WithFields(log.Fields{
"file_id": diff[idx].ID,
"collection_id": cID,
"updated_at": diff[idx].UpdationTime,
}).Warning("stale collection_file found")
diff[idx].IsDeleted = true
}
}
return diff, hasMore, nil
}
func (c *CollectionController) GetFile(ctx *gin.Context, collectionID int64, fileID int64) (*ente.File, error) {
userID := auth.GetUserID(ctx.Request.Header)
files, err := c.CollectionRepo.GetFile(collectionID, fileID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if len(files) == 0 {
return nil, stacktrace.Propagate(&ente.ErrFileNotFoundInAlbum, "")
}
file := files[0]
if file.OwnerID != userID {
cIDs, err := c.CollectionRepo.GetCollectionIDsSharedWithUser(userID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if !array.Int64InList(collectionID, cIDs) {
return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
}
if file.IsDeleted {
return nil, stacktrace.Propagate(&ente.ErrFileNotFoundInAlbum, "")
}
return &file, nil
}
// GetPublicDiff returns the changes in the collections since a timestamp, along with hasMore bool flag.
func (c *CollectionController) GetPublicDiff(ctx *gin.Context, sinceTime int64) ([]ente.File, bool, error) {
accessContext := auth.MustGetPublicAccessContext(ctx)
reqContextLogger := log.WithFields(log.Fields{
"public_id": accessContext.ID,
"collection_id": accessContext.CollectionID,
"since_time": sinceTime,
"req_id": requestid.Get(ctx),
})
diff, hasMore, err := c.getDiff(accessContext.CollectionID, sinceTime, CollectionDiffLimit, reqContextLogger)
if err != nil {
return nil, false, stacktrace.Propagate(err, "")
}
// hide private metadata before returning files info in diff
for idx := range diff {
if diff[idx].MagicMetadata != nil {
diff[idx].MagicMetadata = nil
}
}
return diff, hasMore, nil
}
// getDiff returns the diff in user's collection since a timestamp, along with hasMore bool flag.
// The function will never return partial result for a version. To maintain this promise, it will not be able to honor
// the limit parameter. Based on the db state, compared to the limit, the diff length can be
// less (case 1), more (case 2), or same (case 3, 4)
// Example: Assume we have 11 files with following versions: v0, v1, v1, v1, v1, v1, v1, v1, v2, v2, v2 (count = 7 v1, 3 v2)
// client has synced up till version v0.
// case 1: ( sinceTime: v0, limit = 8):
// The method will discard the entries with version v2 and return only 7 entries with version v1.
// case 2: (sinceTime: v0, limit 5):
// Instead of returning 5 entries with version V1, method will return all 7 entries with version v1.
// case 3: (sinceTime: v0, limit 7):
// The method will return all 7 entries with version V1.
// case 4: (sinceTime: v0, limit >=10):
// The method will all 10 entries in the diff
func (c *CollectionController) getDiff(cID int64, sinceTime int64, limit int, logger *log.Entry) ([]ente.File, bool, error) {
// request for limit +1 files
diffLimitPlusOne, err := c.CollectionRepo.GetDiff(cID, sinceTime, limit+1)
if err != nil {
return nil, false, stacktrace.Propagate(err, "")
}
if len(diffLimitPlusOne) <= limit {
// case 4: all files changed after sinceTime are included.
return diffLimitPlusOne, false, nil
}
lastFileVersion := diffLimitPlusOne[limit].UpdationTime
filteredDiffs := c.removeFilesWithVersion(diffLimitPlusOne, lastFileVersion)
filteredDiffLen := len(filteredDiffs)
if filteredDiffLen > 0 { // case 1 or case 3
if filteredDiffLen < limit {
// logging case 1
logger.
WithField("last_file_version", lastFileVersion).
WithField("filtered_diff_len", filteredDiffLen).
Info(fmt.Sprintf("less than limit (%d) files in diff", limit))
}
return filteredDiffs, true, nil
}
// case 2
diff, err := c.CollectionRepo.GetFilesWithVersion(cID, lastFileVersion)
logger.
WithField("last_file_version", lastFileVersion).
WithField("count", len(diff)).
Info(fmt.Sprintf("more than limit (%d) files with same version", limit))
if err != nil {
return nil, false, stacktrace.Propagate(err, "")
}
return diff, true, nil
}
// removeFilesWithVersion returns filtered list of files are removing all files with given version.
// Important: The method assumes that files are sorted by increasing order of File.UpdationTime
func (c *CollectionController) removeFilesWithVersion(files []ente.File, version int64) []ente.File {
var i = len(files) - 1
for ; i >= 0; i-- {
if files[i].UpdationTime != version {
// found index (from end) where file's version is different from given version
break
}
}
return files[0 : i+1]
}
// GetSharees returns the list of users a collection has been shared with
func (c *CollectionController) GetSharees(ctx *gin.Context, cID int64, userID int64) ([]ente.CollectionUser, error) {
_, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
})
if err != nil {
return nil, stacktrace.Propagate(err, "Access check failed")
}
sharees, err := c.CollectionRepo.GetSharees(cID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return sharees, nil
}
// TrashV3 deletes a given collection and based on user input (TrashCollectionV3Request.KeepFiles as FALSE) , it will move all files present in the underlying collection
// to trash.
func (c *CollectionController) TrashV3(ctx *gin.Context, req ente.TrashCollectionV3Request) error {
if req.KeepFiles == nil {
return ente.ErrBadRequest
}
userID := auth.GetUserID(ctx.Request.Header)
cID := req.CollectionID
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
IncludeDeleted: true,
VerifyOwner: true,
})
if err != nil {
return stacktrace.Propagate(err, "")
}
if !resp.Collection.AllowDelete() {
return stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("deleting albums of type %s is not allowed", resp.Collection.Type))
}
if resp.Collection.IsDeleted {
log.WithFields(log.Fields{
"c_id": cID,
"user_id": userID,
}).Warning("Collection is already deleted")
return nil
}
if *req.KeepFiles {
// Verify that all files from this particular collections have been removed.
count, err := c.CollectionRepo.GetCollectionsFilesCount(cID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if count != 0 {
return stacktrace.Propagate(&ente.ErrCollectionNotEmpty, fmt.Sprintf("Collection file count %d", count))
}
}
err = c.PublicCollectionCtrl.Disable(ctx, cID)
if err != nil {
return stacktrace.Propagate(err, "failed to disabled public share url")
}
err = c.CastRepo.RevokeTokenForCollection(ctx, cID)
if err != nil {
return stacktrace.Propagate(err, "failed to revoke cast token")
}
// Continue with current delete flow till. This disables sharing for this collection and then queue it up for deletion
err = c.CollectionRepo.ScheduleDelete(cID)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
// Rename updates the collection's name
func (c *CollectionController) Rename(userID int64, cID int64, encryptedName string, nameDecryptionNonce string) error {
if err := c.verifyOwnership(cID, userID); err != nil {
return stacktrace.Propagate(err, "")
}
err := c.CollectionRepo.Rename(cID, encryptedName, nameDecryptionNonce)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
// UpdateMagicMetadata updates the magic metadata for given collection
func (c *CollectionController) UpdateMagicMetadata(ctx *gin.Context, request ente.UpdateCollectionMagicMetadata, isPublicMetadata bool) error {
userID := auth.GetUserID(ctx.Request.Header)
if err := c.verifyOwnership(request.ID, userID); err != nil {
return stacktrace.Propagate(err, "")
}
// todo: verify version mismatch later. We are not planning to resync collection on clients,
// so ignore that check until then. Ideally, after file size info sync, we should enable
err := c.CollectionRepo.UpdateMagicMetadata(ctx, request.ID, request.MagicMetadata, isPublicMetadata)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
func (c *CollectionController) HandleAccountDeletion(ctx context.Context, userID int64, logger *log.Entry) error {
logger.Info("disabling shared collections with or by the user")
sharedCollections, err := c.CollectionRepo.GetAllSharedCollections(ctx, userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
logger.Info(fmt.Sprintf("shared collections count: %d", len(sharedCollections)))
for _, shareCollection := range sharedCollections {
logger.WithField("shared_collection", shareCollection).Info("disable shared collection")
err = c.CollectionRepo.UnShare(shareCollection.CollectionID, shareCollection.ToUserID)
if err != nil {
return stacktrace.Propagate(err, "")
}
}
err = c.CastRepo.RevokeTokenForUser(ctx, userID)
if err != nil {
return stacktrace.Propagate(err, "failed to revoke cast token for user")
}
err = c.PublicCollectionCtrl.HandleAccountDeletion(ctx, userID, logger)
return stacktrace.Propagate(err, "")
}
// Verify that user owns the collection
func (c *CollectionController) verifyOwnership(cID int64, userID int64) error {
collection, err := c.CollectionRepo.Get(cID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if userID != collection.Owner.ID {
return stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
return nil
}

View File

@@ -1,4 +1,4 @@
package controller
package collections
import (
"github.com/ente-io/museum/ente"

View File

@@ -0,0 +1,226 @@
package collections
import (
"context"
"fmt"
"github.com/ente-io/museum/pkg/controller"
"github.com/ente-io/museum/pkg/controller/access"
"github.com/ente-io/museum/pkg/controller/email"
"github.com/ente-io/museum/pkg/repo/cast"
"github.com/ente-io/museum/pkg/utils/array"
"github.com/ente-io/museum/pkg/utils/auth"
"github.com/gin-gonic/gin"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/repo"
"github.com/ente-io/museum/pkg/utils/time"
"github.com/ente-io/stacktrace"
log "github.com/sirupsen/logrus"
)
const (
CollectionDiffLimit = 2500
)
// CollectionController encapsulates logic that deals with collections
type CollectionController struct {
PublicCollectionCtrl *controller.PublicCollectionController
EmailCtrl *email.EmailNotificationController
AccessCtrl access.Controller
BillingCtrl *controller.BillingController
CollectionRepo *repo.CollectionRepository
UserRepo *repo.UserRepository
FileRepo *repo.FileRepository
QueueRepo *repo.QueueRepository
CastRepo *cast.Repository
TaskRepo *repo.TaskLockRepository
}
// Create creates a collection
func (c *CollectionController) Create(collection ente.Collection, ownerID int64) (ente.Collection, error) {
// The key attribute check is to ensure that user does not end up uploading any files before actually setting the key attributes.
if _, keyErr := c.UserRepo.GetKeyAttributes(ownerID); keyErr != nil {
return ente.Collection{}, stacktrace.Propagate(keyErr, "Unable to get keyAttributes")
}
collectionType := collection.Type
collection.Owner.ID = ownerID
collection.UpdationTime = time.Microseconds()
// [20th Dec 2022] Patch on server side untill majority of the existing mobile clients upgrade to a version higher > 0.7.0
// https://github.com/ente-io/photos-app/pull/725
if collection.Type == "CollectionType.album" {
collection.Type = "album"
}
if !array.StringInList(collection.Type, ente.ValidCollectionTypes) {
return ente.Collection{}, stacktrace.Propagate(fmt.Errorf("unexpected collection type %s", collection.Type), "")
}
collection, err := c.CollectionRepo.Create(collection)
if err != nil {
if err == ente.ErrUncategorizeCollectionAlreadyExists || err == ente.ErrFavoriteCollectionAlreadyExist {
dbCollection, err := c.CollectionRepo.GetCollectionByType(ownerID, collectionType)
if err != nil {
return ente.Collection{}, stacktrace.Propagate(err, "")
}
if dbCollection.IsDeleted {
return ente.Collection{}, stacktrace.Propagate(fmt.Errorf("special collection of type : %s is deleted", collectionType), "")
}
return dbCollection, nil
}
return ente.Collection{}, stacktrace.Propagate(err, "")
}
return collection, nil
}
// GetCollection returns the collection for given collectionID
func (c *CollectionController) GetCollection(ctx *gin.Context, userID int64, cID int64) (ente.Collection, error) {
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
IncludeDeleted: true,
})
if err != nil {
return ente.Collection{}, stacktrace.Propagate(err, "")
}
return resp.Collection, nil
}
func (c *CollectionController) GetFile(ctx *gin.Context, collectionID int64, fileID int64) (*ente.File, error) {
userID := auth.GetUserID(ctx.Request.Header)
files, err := c.CollectionRepo.GetFile(collectionID, fileID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if len(files) == 0 {
return nil, stacktrace.Propagate(&ente.ErrFileNotFoundInAlbum, "")
}
file := files[0]
if file.OwnerID != userID {
cIDs, err := c.CollectionRepo.GetCollectionIDsSharedWithUser(userID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if !array.Int64InList(collectionID, cIDs) {
return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
}
if file.IsDeleted {
return nil, stacktrace.Propagate(&ente.ErrFileNotFoundInAlbum, "")
}
return &file, nil
}
// TrashV3 deletes a given collection and based on user input (TrashCollectionV3Request.KeepFiles as FALSE) , it will move all files present in the underlying collection
// to trash.
func (c *CollectionController) TrashV3(ctx *gin.Context, req ente.TrashCollectionV3Request) error {
if req.KeepFiles == nil {
return ente.ErrBadRequest
}
userID := auth.GetUserID(ctx.Request.Header)
cID := req.CollectionID
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
IncludeDeleted: true,
VerifyOwner: true,
})
if err != nil {
return stacktrace.Propagate(err, "")
}
if !resp.Collection.AllowDelete() {
return stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("deleting albums of type %s is not allowed", resp.Collection.Type))
}
if resp.Collection.IsDeleted {
log.WithFields(log.Fields{
"c_id": cID,
"user_id": userID,
}).Warning("Collection is already deleted")
return nil
}
if *req.KeepFiles {
// Verify that all files from this particular collections have been removed.
count, err := c.CollectionRepo.GetCollectionsFilesCount(cID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if count != 0 {
return stacktrace.Propagate(&ente.ErrCollectionNotEmpty, fmt.Sprintf("Collection file count %d", count))
}
}
err = c.PublicCollectionCtrl.Disable(ctx, cID)
if err != nil {
return stacktrace.Propagate(err, "failed to disabled public share url")
}
err = c.CastRepo.RevokeTokenForCollection(ctx, cID)
if err != nil {
return stacktrace.Propagate(err, "failed to revoke cast token")
}
// Continue with current delete flow till. This disables sharing for this collection and then queue it up for deletion
err = c.CollectionRepo.ScheduleDelete(cID)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
// Rename updates the collection's name
func (c *CollectionController) Rename(userID int64, cID int64, encryptedName string, nameDecryptionNonce string) error {
if err := c.verifyOwnership(cID, userID); err != nil {
return stacktrace.Propagate(err, "")
}
err := c.CollectionRepo.Rename(cID, encryptedName, nameDecryptionNonce)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
// UpdateMagicMetadata updates the magic metadata for given collection
func (c *CollectionController) UpdateMagicMetadata(ctx *gin.Context, request ente.UpdateCollectionMagicMetadata, isPublicMetadata bool) error {
userID := auth.GetUserID(ctx.Request.Header)
if err := c.verifyOwnership(request.ID, userID); err != nil {
return stacktrace.Propagate(err, "")
}
// todo: verify version mismatch later. We are not planning to resync collection on clients,
// so ignore that check until then. Ideally, after file size info sync, we should enable
err := c.CollectionRepo.UpdateMagicMetadata(ctx, request.ID, request.MagicMetadata, isPublicMetadata)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
func (c *CollectionController) HandleAccountDeletion(ctx context.Context, userID int64, logger *log.Entry) error {
logger.Info("disabling shared collections with or by the user")
sharedCollections, err := c.CollectionRepo.GetAllSharedCollections(ctx, userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
logger.Info(fmt.Sprintf("shared collections count: %d", len(sharedCollections)))
for _, shareCollection := range sharedCollections {
logger.WithField("shared_collection", shareCollection).Info("disable shared collection")
err = c.CollectionRepo.UnShare(shareCollection.CollectionID, shareCollection.ToUserID)
if err != nil {
return stacktrace.Propagate(err, "")
}
}
err = c.CastRepo.RevokeTokenForUser(ctx, userID)
if err != nil {
return stacktrace.Propagate(err, "failed to revoke cast token for user")
}
err = c.PublicCollectionCtrl.HandleAccountDeletion(ctx, userID, logger)
return stacktrace.Propagate(err, "")
}
// Verify that user owns the collection
func (c *CollectionController) verifyOwnership(cID int64, userID int64) error {
collection, err := c.CollectionRepo.Get(cID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if userID != collection.Owner.ID {
return stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
return nil
}

View File

@@ -0,0 +1,24 @@
package collections
import (
"github.com/ente-io/museum/ente"
"github.com/ente-io/stacktrace"
)
// GetOwnedV2 returns the list of collections owned by a user using optimized query
func (c *CollectionController) GetOwnedV2(userID int64, sinceTime int64, app ente.App, limit *int64) ([]ente.Collection, error) {
collections, err := c.CollectionRepo.GetCollectionsOwnedByUserV2(userID, sinceTime, app, limit)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return collections, nil
}
// GetSharedWith returns the list of collections that are shared with a user
func (c *CollectionController) GetSharedWith(userID int64, sinceTime int64, app ente.App, limit *int64) ([]ente.Collection, error) {
collections, err := c.CollectionRepo.GetCollectionsSharedWithUser(userID, sinceTime, app, limit)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return collections, nil
}

View File

@@ -0,0 +1,203 @@
package collections
import (
"fmt"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/controller/access"
"github.com/ente-io/museum/pkg/utils/auth"
"github.com/ente-io/stacktrace"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
// AddFiles adds files to a collection
func (c *CollectionController) AddFiles(ctx *gin.Context, userID int64, files []ente.CollectionFileItem, cID int64) error {
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
IncludeDeleted: false,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify collection access")
}
if !resp.Role.CanAdd() {
return stacktrace.Propagate(ente.ErrPermissionDenied, fmt.Sprintf("user %d with role %s can not add files", userID, *resp.Role))
}
collectionOwnerID := resp.Collection.Owner.ID
filesOwnerID := userID
// Verify that the user owns each file
fileIDs := make([]int64, 0)
for _, file := range files {
fileIDs = append(fileIDs, file.ID)
}
err = c.AccessCtrl.VerifyFileOwnership(ctx, &access.VerifyFileOwnershipParams{
ActorUserId: userID,
FileIDs: fileIDs,
})
if err != nil {
return stacktrace.Propagate(err, "Failed to verify fileOwnership")
}
err = c.CollectionRepo.AddFiles(cID, collectionOwnerID, files, filesOwnerID)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
// RestoreFiles restore files from trash and add to the collection
func (c *CollectionController) RestoreFiles(ctx *gin.Context, userID int64, cID int64, files []ente.CollectionFileItem) error {
_, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
IncludeDeleted: false,
VerifyOwner: true,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify collection access")
}
// Verify that the user owns each file
for _, file := range files {
// todo #perf find owners of all files
ownerID, err := c.FileRepo.GetOwnerID(file.ID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if ownerID != userID {
log.WithFields(log.Fields{
"file_id": file.ID,
"owner_id": ownerID,
"user_id": userID,
}).Error("invalid ops: can't add file which isn't owned by user")
return stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
}
err = c.CollectionRepo.RestoreFiles(ctx, userID, cID, files)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
// MoveFiles from one collection to another collection. Both the collections and files should belong to
// single user
func (c *CollectionController) MoveFiles(ctx *gin.Context, req ente.MoveFilesRequest) error {
userID := auth.GetUserID(ctx.Request.Header)
_, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.FromCollectionID,
ActorUserID: userID,
IncludeDeleted: false,
VerifyOwner: true,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify if actor owns fromCollection")
}
_, err = c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.ToCollectionID,
ActorUserID: userID,
IncludeDeleted: false,
VerifyOwner: true,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify if actor owns toCollection")
}
// Verify that the user owns each file
fileIDs := make([]int64, 0)
for _, file := range req.Files {
fileIDs = append(fileIDs, file.ID)
}
err = c.AccessCtrl.VerifyFileOwnership(ctx, &access.VerifyFileOwnershipParams{
ActorUserId: userID,
FileIDs: fileIDs,
})
if err != nil {
stacktrace.Propagate(err, "Failed to verify fileOwnership")
}
err = c.CollectionRepo.MoveFiles(ctx, req.ToCollectionID, req.FromCollectionID, req.Files, userID, userID)
return stacktrace.Propagate(err, "") // return nil if err is nil
}
// RemoveFilesV3 removes files from a collection as long as owner(s) of the file is different from collection owner
func (c *CollectionController) RemoveFilesV3(ctx *gin.Context, req ente.RemoveFilesV3Request) error {
actorUserID := auth.GetUserID(ctx.Request.Header)
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.CollectionID,
ActorUserID: actorUserID,
VerifyOwner: false,
})
if err != nil {
return stacktrace.Propagate(err, "failed to verify collection access")
}
err = c.isRemoveAllowed(ctx, actorUserID, resp.Collection.Owner.ID, req.FileIDs)
if err != nil {
return stacktrace.Propagate(err, "file removal check failed")
}
err = c.CollectionRepo.RemoveFilesV3(ctx, req.CollectionID, req.FileIDs)
if err != nil {
return stacktrace.Propagate(err, "failed to remove files")
}
return nil
}
// isRemoveAllowed verifies that given set of files can be removed from the collection or not
func (c *CollectionController) isRemoveAllowed(ctx *gin.Context, actorUserID int64, collectionOwnerID int64, fileIDs []int64) error {
ownerToFilesMap, err := c.FileRepo.GetOwnerToFileIDsMap(ctx, fileIDs)
if err != nil {
return stacktrace.Propagate(err, "failed to get owner to fileIDs map")
}
// verify that none of the file belongs to the collection owner
if _, ok := ownerToFilesMap[collectionOwnerID]; ok {
return ente.NewBadRequestWithMessage("can not remove files owned by album owner")
}
if collectionOwnerID != actorUserID {
// verify that user is only trying to remove files owned by them
if len(ownerToFilesMap) > 1 {
return stacktrace.Propagate(ente.ErrPermissionDenied, "can not remove files owned by others")
}
// verify that user is only trying to remove files owned by them
if _, ok := ownerToFilesMap[actorUserID]; !ok {
return stacktrace.Propagate(ente.ErrPermissionDenied, "can not remove files owned by others")
}
}
return nil
}
func (c *CollectionController) IsCopyAllowed(ctx *gin.Context, actorUserID int64, req ente.CopyFileSyncRequest) error {
// verify that srcCollectionID is accessible by actorUserID
if _, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.SrcCollectionID,
ActorUserID: actorUserID,
}); err != nil {
return stacktrace.Propagate(err, "failed to verify srcCollection access")
}
// verify that dstCollectionID is owned by actorUserID
if _, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.DstCollection,
ActorUserID: actorUserID,
VerifyOwner: true,
}); err != nil {
return stacktrace.Propagate(err, "failed to ownership of the dstCollection access")
}
// verify that all FileIDs exists in the srcCollection
fileIDs := make([]int64, len(req.CollectionFileItems))
for idx, file := range req.CollectionFileItems {
fileIDs[idx] = file.ID
}
if err := c.CollectionRepo.VerifyAllFileIDsExistsInCollection(ctx, req.SrcCollectionID, fileIDs); err != nil {
return stacktrace.Propagate(err, "failed to verify fileIDs in srcCollection")
}
dsMap, err := c.FileRepo.GetOwnerToFileIDsMap(ctx, fileIDs)
if err != nil {
return err
}
// verify that none of the file belongs to actorUserID
if _, ok := dsMap[actorUserID]; ok {
return ente.NewBadRequestWithMessage("can not copy files owned by actor")
}
return nil
}

View File

@@ -0,0 +1,111 @@
package collections
import (
"fmt"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/controller/access"
"github.com/ente-io/stacktrace"
"github.com/gin-contrib/requestid"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
// GetDiffV2 returns the changes in user's collections since a timestamp, along with hasMore bool flag.
func (c *CollectionController) GetDiffV2(ctx *gin.Context, cID int64, userID int64, sinceTime int64) ([]ente.File, bool, error) {
reqContextLogger := log.WithFields(log.Fields{
"user_id": userID,
"collection_id": cID,
"since_time": sinceTime,
"req_id": requestid.Get(ctx),
})
_, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
})
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to verify access")
}
diff, hasMore, err := c.getDiff(cID, sinceTime, CollectionDiffLimit, reqContextLogger)
if err != nil {
return nil, false, stacktrace.Propagate(err, "")
}
// hide private metadata before returning files info in diff
for idx := range diff {
if diff[idx].OwnerID != userID {
diff[idx].MagicMetadata = nil
}
if diff[idx].Metadata.EncryptedData == "-" && !diff[idx].IsDeleted {
// This indicates that the file is deleted, but we still have a stale entry in the collection
log.WithFields(log.Fields{
"file_id": diff[idx].ID,
"collection_id": cID,
"updated_at": diff[idx].UpdationTime,
}).Warning("stale collection_file found")
diff[idx].IsDeleted = true
}
}
return diff, hasMore, nil
}
// getDiff returns the diff in user's collection since a timestamp, along with hasMore bool flag.
// The function will never return partial result for a version. To maintain this promise, it will not be able to honor
// the limit parameter. Based on the db state, compared to the limit, the diff length can be
// less (case 1), more (case 2), or same (case 3, 4)
// Example: Assume we have 11 files with following versions: v0, v1, v1, v1, v1, v1, v1, v1, v2, v2, v2 (count = 7 v1, 3 v2)
// client has synced up till version v0.
// case 1: ( sinceTime: v0, limit = 8):
// The method will discard the entries with version v2 and return only 7 entries with version v1.
// case 2: (sinceTime: v0, limit 5):
// Instead of returning 5 entries with version V1, method will return all 7 entries with version v1.
// case 3: (sinceTime: v0, limit 7):
// The method will return all 7 entries with version V1.
// case 4: (sinceTime: v0, limit >=10):
// The method will all 10 entries in the diff
func (c *CollectionController) getDiff(cID int64, sinceTime int64, limit int, logger *log.Entry) ([]ente.File, bool, error) {
// request for limit +1 files
diffLimitPlusOne, err := c.CollectionRepo.GetDiff(cID, sinceTime, limit+1)
if err != nil {
return nil, false, stacktrace.Propagate(err, "")
}
if len(diffLimitPlusOne) <= limit {
// case 4: all files changed after sinceTime are included.
return diffLimitPlusOne, false, nil
}
lastFileVersion := diffLimitPlusOne[limit].UpdationTime
filteredDiffs := c.removeFilesWithVersion(diffLimitPlusOne, lastFileVersion)
filteredDiffLen := len(filteredDiffs)
if filteredDiffLen > 0 { // case 1 or case 3
if filteredDiffLen < limit {
// logging case 1
logger.
WithField("last_file_version", lastFileVersion).
WithField("filtered_diff_len", filteredDiffLen).
Info(fmt.Sprintf("less than limit (%d) files in diff", limit))
}
return filteredDiffs, true, nil
}
// case 2
diff, err := c.CollectionRepo.GetFilesWithVersion(cID, lastFileVersion)
logger.
WithField("last_file_version", lastFileVersion).
WithField("count", len(diff)).
Info(fmt.Sprintf("more than limit (%d) files with same version", limit))
if err != nil {
return nil, false, stacktrace.Propagate(err, "")
}
return diff, true, nil
}
// removeFilesWithVersion returns filtered list of files are removing all files with given version.
// Important: The method assumes that files are sorted by increasing order of File.UpdationTime
func (c *CollectionController) removeFilesWithVersion(files []ente.File, version int64) []ente.File {
var i = len(files) - 1
for ; i >= 0; i-- {
if files[i].UpdationTime != version {
// found index (from end) where file's version is different from given version
break
}
}
return files[0 : i+1]
}

View File

@@ -0,0 +1,269 @@
package collections
import (
"context"
"fmt"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/controller/access"
"github.com/ente-io/museum/pkg/utils/array"
"github.com/ente-io/museum/pkg/utils/auth"
"github.com/ente-io/museum/pkg/utils/time"
"github.com/ente-io/stacktrace"
"github.com/gin-contrib/requestid"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"strings"
)
func (c *CollectionController) Share(ctx *gin.Context, req ente.AlterShareRequest) ([]ente.CollectionUser, error) {
fromUserID := auth.GetUserID(ctx.Request.Header)
cID := req.CollectionID
encryptedKey := req.EncryptedKey
toUserEmail := strings.ToLower(strings.TrimSpace(req.Email))
// default role type
role := ente.VIEWER
if req.Role != nil {
role = *req.Role
}
toUserID, err := c.UserRepo.GetUserIDWithEmail(toUserEmail)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if toUserID == fromUserID {
return nil, stacktrace.Propagate(ente.ErrBadRequest, "Can not share collection with self")
}
collection, err := c.CollectionRepo.Get(cID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if !collection.AllowSharing() {
return nil, stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("sharing %s is not allowed", collection.Type))
}
if fromUserID != collection.Owner.ID {
return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
err = c.BillingCtrl.HasActiveSelfOrFamilySubscription(fromUserID, true)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
err = c.CollectionRepo.Share(cID, fromUserID, toUserID, encryptedKey, role, time.Microseconds())
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
sharees, err := c.GetSharees(ctx, cID, fromUserID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return sharees, nil
}
func (c *CollectionController) JoinViaLink(ctx *gin.Context, req ente.JoinCollectionViaLinkRequest) error {
userID := auth.GetUserID(ctx.Request.Header)
collection, err := c.CollectionRepo.Get(req.CollectionID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if collection.Owner.ID == userID {
return stacktrace.Propagate(ente.ErrBadRequest, "owner can not join via link")
}
if !collection.AllowSharing() {
return stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("joining %s is not allowed", collection.Type))
}
publicCollectionToken, err := c.PublicCollectionCtrl.GetActivePublicCollectionToken(ctx, req.CollectionID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if canJoin := publicCollectionToken.CanJoin(); canJoin != nil {
return stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("can not join collection: %s", canJoin.Error()))
}
accessToken := auth.GetAccessToken(ctx)
if publicCollectionToken.Token != accessToken {
return stacktrace.Propagate(ente.ErrPermissionDenied, "token doesn't match collection")
}
if publicCollectionToken.PassHash != nil && *publicCollectionToken.PassHash != "" {
accessTokenJWT := auth.GetAccessTokenJWT(ctx)
if passCheckErr := c.PublicCollectionCtrl.ValidateJWTToken(ctx, accessTokenJWT, *publicCollectionToken.PassHash); passCheckErr != nil {
return stacktrace.Propagate(passCheckErr, "")
}
}
err = c.BillingCtrl.HasActiveSelfOrFamilySubscription(collection.Owner.ID, true)
if err != nil {
return stacktrace.Propagate(err, "")
}
role := ente.VIEWER
if publicCollectionToken.EnableCollect {
role = ente.COLLABORATOR
}
joinErr := c.CollectionRepo.Share(req.CollectionID, collection.Owner.ID, userID, req.EncryptedKey, role, time.Microseconds())
if joinErr != nil {
return stacktrace.Propagate(joinErr, "")
}
go c.EmailCtrl.OnLinkJoined(collection.Owner.ID, userID, role)
return nil
}
// UnShare unshares a collection with a user
func (c *CollectionController) UnShare(ctx *gin.Context, cID int64, fromUserID int64, toUserEmail string) ([]ente.CollectionUser, error) {
toUserID, err := c.UserRepo.GetUserIDWithEmail(toUserEmail)
if err != nil {
return nil, stacktrace.Propagate(ente.ErrNotFound, "")
}
collection, err := c.CollectionRepo.Get(cID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
isLeavingCollection := toUserID == fromUserID
if fromUserID != collection.Owner.ID || isLeavingCollection {
return nil, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
err = c.CollectionRepo.UnShare(cID, toUserID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
err = c.CastRepo.RevokeForGivenUserAndCollection(ctx, cID, toUserID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
sharees, err := c.GetSharees(ctx, cID, fromUserID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return sharees, nil
}
// Leave leaves the collection owned by someone else,
func (c *CollectionController) Leave(ctx *gin.Context, cID int64) error {
userID := auth.GetUserID(ctx.Request.Header)
collection, err := c.CollectionRepo.Get(cID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if userID == collection.Owner.ID {
return stacktrace.Propagate(ente.ErrPermissionDenied, "can not leave collection owned by self")
}
sharedCollectionIDs, err := c.CollectionRepo.GetCollectionIDsSharedWithUser(userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
if !array.Int64InList(cID, sharedCollectionIDs) {
return nil
}
err = c.CastRepo.RevokeForGivenUserAndCollection(ctx, cID, userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
err = c.CollectionRepo.UnShare(cID, userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
func (c *CollectionController) UpdateShareeMagicMetadata(ctx *gin.Context, req ente.UpdateCollectionMagicMetadata) error {
actorUserId := auth.GetUserID(ctx.Request.Header)
resp, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: req.ID,
ActorUserID: actorUserId,
})
if err != nil {
return stacktrace.Propagate(err, "")
}
if resp.Collection.Owner.ID == actorUserId {
return stacktrace.Propagate(ente.NewBadRequestWithMessage("owner can not update sharee magic metadata"), "")
}
err = c.CollectionRepo.UpdateShareeMetadata(req.ID, resp.Collection.Owner.ID, actorUserId, req.MagicMetadata, time.Microseconds())
if err != nil {
return stacktrace.Propagate(err, "failed to update sharee magic metadata")
}
return nil
}
// ShareURL generates a public auth-token for the given collectionID
func (c *CollectionController) ShareURL(ctx context.Context, userID int64, req ente.CreatePublicAccessTokenRequest) (
ente.PublicURL, error) {
collection, err := c.CollectionRepo.Get(req.CollectionID)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
if !collection.AllowSharing() {
return ente.PublicURL{}, stacktrace.Propagate(ente.ErrBadRequest, fmt.Sprintf("sharing %s is not allowed", collection.Type))
}
if userID != collection.Owner.ID {
return ente.PublicURL{}, stacktrace.Propagate(ente.ErrPermissionDenied, "")
}
err = c.BillingCtrl.HasActiveSelfOrFamilySubscription(userID, true)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
response, err := c.PublicCollectionCtrl.CreateAccessToken(ctx, req)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
return response, nil
}
// UpdateShareURL updates the shared url configuration
func (c *CollectionController) UpdateShareURL(ctx context.Context, userID int64, req ente.UpdatePublicAccessTokenRequest) (
ente.PublicURL, error) {
if err := c.verifyOwnership(req.CollectionID, userID); err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
err := c.BillingCtrl.HasActiveSelfOrFamilySubscription(userID, true)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
response, err := c.PublicCollectionCtrl.UpdateSharedUrl(ctx, req)
if err != nil {
return ente.PublicURL{}, stacktrace.Propagate(err, "")
}
return response, nil
}
// DisableSharedURL disable a public auth-token for the given collectionID
func (c *CollectionController) DisableSharedURL(ctx context.Context, userID int64, cID int64) error {
if err := c.verifyOwnership(cID, userID); err != nil {
return stacktrace.Propagate(err, "")
}
err := c.PublicCollectionCtrl.Disable(ctx, cID)
return stacktrace.Propagate(err, "")
}
// GetSharees returns the list of users a collection has been shared with
func (c *CollectionController) GetSharees(ctx *gin.Context, cID int64, userID int64) ([]ente.CollectionUser, error) {
_, err := c.AccessCtrl.GetCollection(ctx, &access.GetCollectionParams{
CollectionID: cID,
ActorUserID: userID,
})
if err != nil {
return nil, stacktrace.Propagate(err, "Access check failed")
}
sharees, err := c.CollectionRepo.GetSharees(cID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return sharees, nil
}
// GetPublicDiff returns the changes in the collections since a timestamp, along with hasMore bool flag.
func (c *CollectionController) GetPublicDiff(ctx *gin.Context, sinceTime int64) ([]ente.File, bool, error) {
accessContext := auth.MustGetPublicAccessContext(ctx)
reqContextLogger := log.WithFields(log.Fields{
"public_id": accessContext.ID,
"collection_id": accessContext.CollectionID,
"since_time": sinceTime,
"req_id": requestid.Get(ctx),
})
diff, hasMore, err := c.getDiff(accessContext.CollectionID, sinceTime, CollectionDiffLimit, reqContextLogger)
if err != nil {
return nil, false, stacktrace.Propagate(err, "")
}
// hide private metadata before returning files info in diff
for idx := range diff {
if diff[idx].MagicMetadata != nil {
diff[idx].MagicMetadata = nil
}
}
return diff, hasMore, nil
}

View File

@@ -1,485 +1,33 @@
package embedding
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/ente-io/museum/pkg/utils/array"
"strconv"
"strings"
"sync"
gTime "time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/controller"
"github.com/ente-io/museum/pkg/controller/access"
"github.com/ente-io/museum/pkg/repo"
"github.com/ente-io/museum/pkg/repo/embedding"
"github.com/ente-io/museum/pkg/utils/auth"
"github.com/ente-io/museum/pkg/utils/network"
"github.com/ente-io/museum/pkg/utils/s3config"
"github.com/ente-io/stacktrace"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"strconv"
)
const (
// maxEmbeddingDataSize is the min size of an embedding object in bytes
minEmbeddingDataSize = 2048
embeddingFetchTimeout = 10 * gTime.Second
)
// _fetchConfig is the configuration for the fetching objects from S3
type _fetchConfig struct {
RetryCount int
InitialTimeout gTime.Duration
MaxTimeout gTime.Duration
}
var _defaultFetchConfig = _fetchConfig{RetryCount: 3, InitialTimeout: 10 * gTime.Second, MaxTimeout: 30 * gTime.Second}
var _b2FetchConfig = _fetchConfig{RetryCount: 3, InitialTimeout: 15 * gTime.Second, MaxTimeout: 30 * gTime.Second}
type Controller struct {
Repo *embedding.Repository
AccessCtrl access.Controller
ObjectCleanupController *controller.ObjectCleanupController
S3Config *s3config.S3Config
QueueRepo *repo.QueueRepository
TaskLockingRepo *repo.TaskLockRepository
FileRepo *repo.FileRepository
CollectionRepo *repo.CollectionRepository
HostName string
cleanupCronRunning bool
derivedStorageDataCenter string
downloadManagerCache map[string]*s3manager.Downloader
Repo *embedding.Repository
ObjectCleanupController *controller.ObjectCleanupController
QueueRepo *repo.QueueRepository
TaskLockingRepo *repo.TaskLockRepository
FileRepo *repo.FileRepository
HostName string
cleanupCronRunning bool
}
func New(repo *embedding.Repository, accessCtrl access.Controller, objectCleanupController *controller.ObjectCleanupController, s3Config *s3config.S3Config, queueRepo *repo.QueueRepository, taskLockingRepo *repo.TaskLockRepository, fileRepo *repo.FileRepository, collectionRepo *repo.CollectionRepository, hostName string) *Controller {
embeddingDcs := []string{s3Config.GetHotBackblazeDC(), s3Config.GetHotWasabiDC(), s3Config.GetWasabiDerivedDC(), s3Config.GetDerivedStorageDataCenter()}
cache := make(map[string]*s3manager.Downloader, len(embeddingDcs))
for i := range embeddingDcs {
s3Client := s3Config.GetS3Client(embeddingDcs[i])
cache[embeddingDcs[i]] = s3manager.NewDownloaderWithClient(&s3Client)
}
func New(repo *embedding.Repository, objectCleanupController *controller.ObjectCleanupController, queueRepo *repo.QueueRepository, taskLockingRepo *repo.TaskLockRepository, fileRepo *repo.FileRepository, hostName string) *Controller {
return &Controller{
Repo: repo,
AccessCtrl: accessCtrl,
ObjectCleanupController: objectCleanupController,
S3Config: s3Config,
QueueRepo: queueRepo,
TaskLockingRepo: taskLockingRepo,
FileRepo: fileRepo,
CollectionRepo: collectionRepo,
HostName: hostName,
derivedStorageDataCenter: s3Config.GetDerivedStorageDataCenter(),
downloadManagerCache: cache,
Repo: repo,
ObjectCleanupController: objectCleanupController,
QueueRepo: queueRepo,
TaskLockingRepo: taskLockingRepo,
FileRepo: fileRepo,
HostName: hostName,
}
}
func (c *Controller) InsertOrUpdate(ctx *gin.Context, req ente.InsertOrUpdateEmbeddingRequest) (*ente.Embedding, error) {
userID := auth.GetUserID(ctx.Request.Header)
err := c.AccessCtrl.VerifyFileOwnership(ctx, &access.VerifyFileOwnershipParams{
ActorUserId: userID,
FileIDs: []int64{req.FileID},
})
if err != nil {
return nil, stacktrace.Propagate(err, "User does not own file")
}
count, err := c.CollectionRepo.GetCollectionCount(req.FileID)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
if count < 1 {
return nil, stacktrace.Propagate(ente.ErrNotFound, "")
}
version := 1
if req.Version != nil {
version = *req.Version
}
obj := ente.EmbeddingObject{
Version: version,
EncryptedEmbedding: req.EncryptedEmbedding,
DecryptionHeader: req.DecryptionHeader,
Client: network.GetClientInfo(ctx),
}
size, uploadErr := c.uploadObject(obj, c.getObjectKey(userID, req.FileID, req.Model), c.derivedStorageDataCenter)
if uploadErr != nil {
log.Error(uploadErr)
return nil, stacktrace.Propagate(uploadErr, "")
}
embedding, err := c.Repo.InsertOrUpdate(ctx, userID, req, size, version, c.derivedStorageDataCenter)
embedding.Version = &version
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return &embedding, nil
}
func (c *Controller) GetIndexedFiles(ctx *gin.Context, req ente.GetIndexedFiles) ([]ente.IndexedFile, error) {
userID := auth.GetUserID(ctx.Request.Header)
updateSince := int64(0)
if req.SinceTime != nil {
updateSince = *req.SinceTime
}
indexedFiles, err := c.Repo.GetIndexedFiles(ctx, userID, req.Model, updateSince, req.Limit)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return indexedFiles, nil
}
func (c *Controller) GetDiff(ctx *gin.Context, req ente.GetEmbeddingDiffRequest) ([]ente.Embedding, error) {
userID := auth.GetUserID(ctx.Request.Header)
if req.Model == "" {
req.Model = ente.GgmlClip
}
embeddings, err := c.Repo.GetDiff(ctx, userID, req.Model, *req.SinceTime, req.Limit)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
// Collect object keys for embeddings with missing data
var objectKeys []string
for i := range embeddings {
if embeddings[i].EncryptedEmbedding == "" {
objectKey := c.getObjectKey(userID, embeddings[i].FileID, embeddings[i].Model)
objectKeys = append(objectKeys, objectKey)
}
}
// Fetch missing embeddings in parallel
if len(objectKeys) > 0 {
embeddingObjects, err := c.getEmbeddingObjectsParallel(objectKeys, c.derivedStorageDataCenter)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
// Populate missing data in embeddings from fetched objects
for i, obj := range embeddingObjects {
for j := range embeddings {
if embeddings[j].EncryptedEmbedding == "" && c.getObjectKey(userID, embeddings[j].FileID, embeddings[j].Model) == objectKeys[i] {
embeddings[j].EncryptedEmbedding = obj.EncryptedEmbedding
embeddings[j].DecryptionHeader = obj.DecryptionHeader
}
}
}
}
return embeddings, nil
}
func (c *Controller) GetFilesEmbedding(ctx *gin.Context, req ente.GetFilesEmbeddingRequest) (*ente.GetFilesEmbeddingResponse, error) {
userID := auth.GetUserID(ctx.Request.Header)
if err := c._validateGetFileEmbeddingsRequest(ctx, userID, req); err != nil {
return nil, stacktrace.Propagate(err, "")
}
userFileEmbeddings, err := c.Repo.GetFilesEmbedding(ctx, userID, req.Model, req.FileIDs)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
embeddingsWithData := make([]ente.Embedding, 0)
noEmbeddingFileIds := make([]int64, 0)
dbFileIds := make([]int64, 0)
// fileIDs that were indexed, but they don't contain any embedding information
for i := range userFileEmbeddings {
dbFileIds = append(dbFileIds, userFileEmbeddings[i].FileID)
if userFileEmbeddings[i].Size != nil && *userFileEmbeddings[i].Size < minEmbeddingDataSize {
noEmbeddingFileIds = append(noEmbeddingFileIds, userFileEmbeddings[i].FileID)
} else {
embeddingsWithData = append(embeddingsWithData, userFileEmbeddings[i])
}
}
pendingIndexFileIds := array.FindMissingElementsInSecondList(req.FileIDs, dbFileIds)
errFileIds := make([]int64, 0)
// Fetch missing userFileEmbeddings in parallel
embeddingObjects, err := c.getEmbeddingObjectsParallelV2(userID, embeddingsWithData, c.derivedStorageDataCenter)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
fetchedEmbeddings := make([]ente.Embedding, 0)
// Populate missing data in userFileEmbeddings from fetched objects
for _, obj := range embeddingObjects {
if obj.err != nil {
errFileIds = append(errFileIds, obj.dbEmbeddingRow.FileID)
} else {
fetchedEmbeddings = append(fetchedEmbeddings, ente.Embedding{
FileID: obj.dbEmbeddingRow.FileID,
Model: obj.dbEmbeddingRow.Model,
EncryptedEmbedding: obj.embeddingObject.EncryptedEmbedding,
DecryptionHeader: obj.embeddingObject.DecryptionHeader,
UpdatedAt: obj.dbEmbeddingRow.UpdatedAt,
Version: obj.dbEmbeddingRow.Version,
})
}
}
return &ente.GetFilesEmbeddingResponse{
Embeddings: fetchedEmbeddings,
PendingIndexFileIDs: pendingIndexFileIds,
ErrFileIDs: errFileIds,
NoEmbeddingFileIDs: noEmbeddingFileIds,
}, nil
}
func (c *Controller) getObjectKey(userID int64, fileID int64, model string) string {
return c.getEmbeddingObjectPrefix(userID, fileID) + model + ".json"
}
func (c *Controller) getEmbeddingObjectPrefix(userID int64, fileID int64) string {
return strconv.FormatInt(userID, 10) + "/ml-data/" + strconv.FormatInt(fileID, 10) + "/"
}
// Get userId, model and fileID from the object key
func (c *Controller) getEmbeddingObjectDetails(objectKey string) (userID int64, model string, fileID int64) {
split := strings.Split(objectKey, "/")
userID, _ = strconv.ParseInt(split[0], 10, 64)
fileID, _ = strconv.ParseInt(split[2], 10, 64)
model = strings.Split(split[3], ".")[0]
return userID, model, fileID
}
// uploadObject uploads the embedding object to the object store and returns the object size
func (c *Controller) uploadObject(obj ente.EmbeddingObject, key string, dc string) (int, error) {
embeddingObj, _ := json.Marshal(obj)
s3Client := c.S3Config.GetS3Client(dc)
s3Bucket := c.S3Config.GetBucket(dc)
uploader := s3manager.NewUploaderWithClient(&s3Client)
up := s3manager.UploadInput{
Bucket: s3Bucket,
Key: &key,
Body: bytes.NewReader(embeddingObj),
}
result, err := uploader.Upload(&up)
if err != nil {
log.Error(err)
return -1, stacktrace.Propagate(err, "")
}
log.Infof("Uploaded to bucket %s", result.Location)
return len(embeddingObj), nil
}
var globalDiffFetchSemaphore = make(chan struct{}, 300)
var globalFileFetchSemaphore = make(chan struct{}, 400)
func (c *Controller) getEmbeddingObjectsParallel(objectKeys []string, dc string) ([]ente.EmbeddingObject, error) {
var wg sync.WaitGroup
var errs []error
embeddingObjects := make([]ente.EmbeddingObject, len(objectKeys))
for i, objectKey := range objectKeys {
wg.Add(1)
globalDiffFetchSemaphore <- struct{}{} // Acquire from global semaphore
go func(i int, objectKey string) {
defer wg.Done()
defer func() { <-globalDiffFetchSemaphore }() // Release back to global semaphore
obj, err := c.getEmbeddingObject(context.Background(), objectKey, dc)
if err != nil {
errs = append(errs, err)
log.Error("error fetching embedding object: "+objectKey, err)
} else {
embeddingObjects[i] = obj
}
}(i, objectKey)
}
wg.Wait()
if len(errs) > 0 {
return nil, stacktrace.Propagate(errors.New("failed to fetch some objects"), "")
}
return embeddingObjects, nil
}
type embeddingObjectResult struct {
embeddingObject ente.EmbeddingObject
dbEmbeddingRow ente.Embedding
err error
}
func (c *Controller) getEmbeddingObjectsParallelV2(userID int64, dbEmbeddingRows []ente.Embedding, dc string) ([]embeddingObjectResult, error) {
var wg sync.WaitGroup
embeddingObjects := make([]embeddingObjectResult, len(dbEmbeddingRows))
for i, dbEmbeddingRow := range dbEmbeddingRows {
wg.Add(1)
globalFileFetchSemaphore <- struct{}{} // Acquire from global semaphore
go func(i int, dbEmbeddingRow ente.Embedding) {
defer wg.Done()
defer func() { <-globalFileFetchSemaphore }() // Release back to global semaphore
objectKey := c.getObjectKey(userID, dbEmbeddingRow.FileID, dbEmbeddingRow.Model)
obj, err := c.getEmbeddingObject(context.Background(), objectKey, dc)
if err != nil {
log.Error("error fetching embedding object: "+objectKey, err)
embeddingObjects[i] = embeddingObjectResult{
err: err,
dbEmbeddingRow: dbEmbeddingRow,
}
} else {
embeddingObjects[i] = embeddingObjectResult{
embeddingObject: obj,
dbEmbeddingRow: dbEmbeddingRow,
}
}
}(i, dbEmbeddingRow)
}
wg.Wait()
return embeddingObjects, nil
}
func (c *Controller) getEmbeddingObject(ctx context.Context, objectKey string, dc string) (ente.EmbeddingObject, error) {
opt := _defaultFetchConfig
if dc == c.S3Config.GetHotBackblazeDC() {
opt = _b2FetchConfig
}
ctxLogger := log.WithField("objectKey", objectKey).WithField("dc", dc)
totalAttempts := opt.RetryCount + 1
timeout := opt.InitialTimeout
for i := 0; i < totalAttempts; i++ {
if i > 0 {
timeout = timeout * 2
if timeout > opt.MaxTimeout {
timeout = opt.MaxTimeout
}
}
fetchCtx, cancel := context.WithTimeout(ctx, timeout)
select {
case <-ctx.Done():
cancel()
return ente.EmbeddingObject{}, stacktrace.Propagate(ctx.Err(), "")
default:
obj, err := c.downloadObject(fetchCtx, objectKey, dc)
cancel() // Ensure cancel is called to release resources
if err == nil {
if i > 0 {
ctxLogger.Infof("Fetched object after %d attempts", i)
}
return obj, nil
}
// Check if the error is due to context timeout or cancellation
if err == nil && fetchCtx.Err() != nil {
ctxLogger.Error("Fetch timed out or cancelled: ", fetchCtx.Err())
} else {
// check if the error is due to object not found
if s3Err, ok := err.(awserr.RequestFailure); ok {
if s3Err.Code() == s3.ErrCodeNoSuchKey {
var srcDc, destDc string
destDc = c.S3Config.GetDerivedStorageDataCenter()
// todo:(neeraj) Refactor this later to get available the DC from the DB instead of
// querying the DB. This will help in case of multiple DCs and avoid querying the DB
// for each object.
// For initial migration, as we know that original DC was b2, and if the embedding is not found
// in the new derived DC, we can try to fetch it from the B2 DC.
if c.derivedStorageDataCenter != c.S3Config.GetHotBackblazeDC() {
// embeddings ideally should ideally be in the default hot bucket b2
srcDc = c.S3Config.GetHotBackblazeDC()
} else {
_, modelName, fileID := c.getEmbeddingObjectDetails(objectKey)
activeDcs, err := c.Repo.GetOtherDCsForFileAndModel(context.Background(), fileID, modelName, c.derivedStorageDataCenter)
if err != nil {
return ente.EmbeddingObject{}, stacktrace.Propagate(err, "failed to get other dc")
}
if len(activeDcs) > 0 {
srcDc = activeDcs[0]
} else {
ctxLogger.Error("Object not found in any dc ", s3Err)
return ente.EmbeddingObject{}, stacktrace.Propagate(errors.New("object not found"), "")
}
}
copyEmbeddingObject, err := c.copyEmbeddingObject(ctx, objectKey, srcDc, destDc)
if err == nil {
ctxLogger.Infof("Got object from dc %s", srcDc)
return *copyEmbeddingObject, nil
} else {
ctxLogger.WithError(err).Errorf("Failed to get object from fallback dc %s", srcDc)
}
return ente.EmbeddingObject{}, stacktrace.Propagate(errors.New("object not found"), "")
}
}
ctxLogger.Error("Failed to fetch object: ", err)
}
}
}
return ente.EmbeddingObject{}, stacktrace.Propagate(errors.New("failed to fetch object"), "")
}
func (c *Controller) downloadObject(ctx context.Context, objectKey string, dc string) (ente.EmbeddingObject, error) {
var obj ente.EmbeddingObject
buff := &aws.WriteAtBuffer{}
bucket := c.S3Config.GetBucket(dc)
downloader := c.downloadManagerCache[dc]
_, err := downloader.DownloadWithContext(ctx, buff, &s3.GetObjectInput{
Bucket: bucket,
Key: &objectKey,
})
if err != nil {
return obj, err
}
err = json.Unmarshal(buff.Bytes(), &obj)
if err != nil {
return obj, stacktrace.Propagate(err, "unmarshal failed")
}
return obj, nil
}
// download the embedding object from hot bucket and upload to embeddings bucket
func (c *Controller) copyEmbeddingObject(ctx context.Context, objectKey string, srcDC, destDC string) (*ente.EmbeddingObject, error) {
if srcDC == destDC {
return nil, stacktrace.Propagate(errors.New("src and dest dc can not be same"), "")
}
obj, err := c.downloadObject(ctx, objectKey, srcDC)
if err != nil {
return nil, stacktrace.Propagate(err, fmt.Sprintf("failed to download object from %s", srcDC))
}
go func() {
userID, modelName, fileID := c.getEmbeddingObjectDetails(objectKey)
size, uploadErr := c.uploadObject(obj, objectKey, c.derivedStorageDataCenter)
if uploadErr != nil {
log.WithField("object", objectKey).Error("Failed to copy to embeddings bucket: ", uploadErr)
}
updateDcErr := c.Repo.AddNewDC(context.Background(), fileID, ente.Model(modelName), userID, size, destDC)
if updateDcErr != nil {
log.WithField("object", objectKey).Error("Failed to update dc in db: ", updateDcErr)
return
}
}()
return &obj, nil
}
func (c *Controller) _validateGetFileEmbeddingsRequest(ctx *gin.Context, userID int64, req ente.GetFilesEmbeddingRequest) error {
if req.Model == "" {
return ente.NewBadRequestWithMessage("model is required")
}
if len(req.FileIDs) == 0 {
return ente.NewBadRequestWithMessage("fileIDs are required")
}
if len(req.FileIDs) > 200 {
return ente.NewBadRequestWithMessage("fileIDs should be less than or equal to 200")
}
if err := c.AccessCtrl.VerifyFileOwnership(ctx, &access.VerifyFileOwnershipParams{
ActorUserId: userID,
FileIDs: req.FileIDs,
}); err != nil {
return stacktrace.Propagate(err, "User does not own some file(s)")
}
return nil
}

View File

@@ -4,24 +4,11 @@ import (
"context"
"fmt"
"github.com/ente-io/museum/pkg/repo"
"github.com/ente-io/museum/pkg/utils/auth"
"github.com/ente-io/museum/pkg/utils/time"
"github.com/ente-io/stacktrace"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"strconv"
)
func (c *Controller) DeleteAll(ctx *gin.Context) error {
userID := auth.GetUserID(ctx.Request.Header)
err := c.Repo.DeleteAll(ctx, userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
// CleanupDeletedEmbeddings clears all embeddings for deleted files from the object store
func (c *Controller) CleanupDeletedEmbeddings() {
log.Info("Cleaning up deleted embeddings")

View File

@@ -57,7 +57,7 @@ func (c *Controller) CreateFamily(ctx context.Context, adminUserID int64) error
}
// InviteMember invites a user to join the family plan of admin User
func (c *Controller) InviteMember(ctx *gin.Context, adminUserID int64, email string, storageLimit *int64) error {
func (c *Controller) InviteMember(ctx *gin.Context, adminUserID int64, email string) error {
err := c.BillingCtrl.IsActivePayingSubscriber(adminUserID)
if err != nil {
return stacktrace.Propagate(ente.ErrNoActiveSubscription, "you must be on a paid plan")
@@ -114,7 +114,7 @@ func (c *Controller) InviteMember(ctx *gin.Context, adminUserID int64, email str
return stacktrace.Propagate(err, "")
}
activeInviteToken, err := c.FamilyRepo.AddMemberInvite(ctx, adminUserID, potentialMemberUser.ID, inviteToken, storageLimit)
activeInviteToken, err := c.FamilyRepo.AddMemberInvite(ctx, adminUserID, potentialMemberUser.ID, inviteToken)
if err != nil {
return stacktrace.Propagate(err, "")
}
@@ -232,7 +232,7 @@ func (c *Controller) ModifyMemberStorage(ctx context.Context, actorUserID int64,
}
}
modifyStorageErr := c.FamilyRepo.ModifyMemberStorage(ctx, actorUserID, member.ID, storageLimit)
modifyStorageErr := c.FamilyRepo.ModifyMemberStorage(ctx, member.ID, storageLimit)
if modifyStorageErr != nil {
return stacktrace.Propagate(modifyStorageErr, "Failed to modify members storage")
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/aws/aws-sdk-go/service/s3"
"github.com/ente-io/museum/ente"
"github.com/ente-io/museum/pkg/controller"
"github.com/ente-io/museum/pkg/controller/collections"
"github.com/ente-io/museum/pkg/repo"
"github.com/ente-io/museum/pkg/utils/auth"
"github.com/ente-io/museum/pkg/utils/s3config"
@@ -23,7 +24,7 @@ type FileCopyController struct {
S3Config *s3config.S3Config
FileController *controller.FileController
FileRepo *repo.FileRepository
CollectionCtrl *controller.CollectionController
CollectionCtrl *collections.CollectionController
ObjectRepo *repo.ObjectRepository
}

View File

@@ -3,6 +3,7 @@ package user
import (
"errors"
"fmt"
"github.com/ente-io/museum/pkg/controller/collections"
"github.com/ente-io/museum/pkg/repo/two_factor_recovery"
"strings"
@@ -38,7 +39,7 @@ type UserController struct {
FileRepo *repo.FileRepository
CollectionRepo *repo.CollectionRepository
DataCleanupRepo *datacleanup.Repository
CollectionCtrl *controller.CollectionController
CollectionCtrl *collections.CollectionController
BillingRepo *repo.BillingRepository
BillingController *controller.BillingController
FamilyController *family.Controller
@@ -101,7 +102,7 @@ func NewUserController(
passkeyRepo *passkey.Repository,
storageBonusRepo *storageBonusRepo.Repository,
fileRepo *repo.FileRepository,
collectionController *controller.CollectionController,
collectionController *collections.CollectionController,
collectionRepo *repo.CollectionRepository,
dataCleanupRepository *datacleanup.Repository,
billingRepo *repo.BillingRepository,

View File

@@ -5,7 +5,6 @@ import (
"database/sql"
"fmt"
"strconv"
"strings"
t "time"
"github.com/prometheus/client_golang/prometheus"
@@ -102,56 +101,8 @@ func (repo *CollectionRepository) GetCollectionByType(userID int64, collectionTy
return c, nil
}
// GetCollectionsOwnedByUser returns the list of collections that a user owns
// todo: refactor this method
func (repo *CollectionRepository) GetCollectionsOwnedByUser(userID int64, updationTime int64, app ente.App) ([]ente.Collection, error) {
rows, err := repo.DB.Query(`
SELECT collections.collection_id, collections.owner_id, collections.encrypted_key, collections.key_decryption_nonce, collections.name, collections.encrypted_name, collections.name_decryption_nonce, collections.type, collections.app, collections.attributes, collections.updation_time, collections.is_deleted, collections.magic_metadata, collections.pub_magic_metadata
FROM collections
WHERE collections.owner_id = $1 AND collections.updation_time > $2 AND app = $3`, userID, updationTime, strings.ToLower(string(app)))
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
defer rows.Close()
collectionIDs := make([]int64, 0)
collections := make([]ente.Collection, 0)
result := make([]ente.Collection, 0)
for rows.Next() {
var c ente.Collection
var name, encryptedName, nameDecryptionNonce sql.NullString
if err := rows.Scan(&c.ID, &c.Owner.ID, &c.EncryptedKey, &c.KeyDecryptionNonce, &name, &encryptedName, &nameDecryptionNonce, &c.Type, &c.App, &c.Attributes, &c.UpdationTime, &c.IsDeleted, &c.MagicMetadata, &c.PublicMagicMetadata); err != nil {
return collections, stacktrace.Propagate(err, "")
}
if name.Valid && len(name.String) > 0 {
c.Name = name.String
} else {
c.EncryptedName = encryptedName.String
c.NameDecryptionNonce = nameDecryptionNonce.String
}
// TODO: Pull this information in the previous query
sharees, err := repo.GetSharees(c.ID)
if err != nil {
return collections, stacktrace.Propagate(err, "")
}
c.Sharees = sharees
collections = append(collections, c)
collectionIDs = append(collectionIDs, c.ID)
}
urlMap, err := repo.PublicCollectionRepo.GetCollectionToActivePublicURLMap(context.Background(), collectionIDs)
if err != nil {
return nil, stacktrace.Propagate(err, "failed to get publicURL info")
}
for _, c := range collections {
c.PublicURLs = urlMap[c.ID]
result = append(result, c)
}
return result, nil
}
func (repo *CollectionRepository) GetCollectionsOwnedByUserV2(userID int64, updationTime int64, app ente.App) ([]ente.Collection, error) {
rows, err := repo.DB.Query(`
func (repo *CollectionRepository) GetCollectionsOwnedByUserV2(userID int64, updationTime int64, app ente.App, limit *int64) ([]ente.Collection, error) {
query := `
SELECT
c.collection_id, c.owner_id, c.encrypted_key,c.key_decryption_nonce, c.name, c.encrypted_name, c.name_decryption_nonce, c.type, c.app, c.attributes, c.updation_time, c.is_deleted, c.magic_metadata, c.pub_magic_metadata,
users.user_id, users.encrypted_email, users.email_decryption_nonce, cs.role_type,
@@ -163,7 +114,14 @@ pct.access_token, pct.valid_till, pct.device_limit, pct.created_at, pct.updated_
ON (cs.to_user_id = users.user_id AND users.encrypted_email IS NOT NULL)
LEFT JOIN public_collection_tokens pct
ON (pct.collection_id = c.collection_id and pct.is_disabled=FALSE)
WHERE c.owner_id = $1 AND c.updation_time > $2 and c.app = $3`, userID, updationTime, string(app))
WHERE c.owner_id = $1 AND c.updation_time > $2 and c.app = $3`
args := []interface{}{userID, updationTime, string(app)}
if limit != nil {
query += " ORDER BY c.updation_time ASC LIMIT $4"
args = append(args, *limit)
}
rows, err := repo.DB.Query(query, args...)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
@@ -241,14 +199,21 @@ pct.access_token, pct.valid_till, pct.device_limit, pct.created_at, pct.updated_
// GetCollectionsSharedWithUser returns the list of collections that are shared
// with a user
func (repo *CollectionRepository) GetCollectionsSharedWithUser(userID int64, updationTime int64, app ente.App) ([]ente.Collection, error) {
rows, err := repo.DB.Query(`
func (repo *CollectionRepository) GetCollectionsSharedWithUser(userID int64, updationTime int64, app ente.App, limit *int64) ([]ente.Collection, error) {
query := `
SELECT collections.collection_id, collections.owner_id, users.encrypted_email, users.email_decryption_nonce, collection_shares.encrypted_key, collections.name, collections.encrypted_name, collections.name_decryption_nonce, collections.type, collections.app, collections.pub_magic_metadata, collection_shares.magic_metadata, collections.updation_time, collection_shares.is_deleted
FROM collections
INNER JOIN users
ON collections.owner_id = users.user_id
INNER JOIN collection_shares
ON collections.collection_id = collection_shares.collection_id AND collection_shares.to_user_id = $1 AND (collection_shares.updation_time > $2 OR collections.updation_time > $2) AND users.encrypted_email IS NOT NULL AND app = $3`, userID, updationTime, string(app))
ON collections.collection_id = collection_shares.collection_id AND collection_shares.to_user_id = $1 AND (collection_shares.updation_time > $2 OR collections.updation_time > $2) AND users.encrypted_email IS NOT NULL AND app = $3`
args := []interface{}{userID, updationTime, string(app)}
if limit != nil {
query += " ORDER BY collections.updation_time ASC LIMIT $4"
args = append(args, *limit)
}
rows, err := repo.DB.Query(query, args...)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
@@ -389,85 +354,6 @@ func (repo *CollectionRepository) GetAllSharedCollections(ctx context.Context, u
return result, nil
}
// DoesFileExistInCollections returns true if the file exists in one of the
// provided collections
func (repo *CollectionRepository) DoesFileExistInCollections(fileID int64, cIDs []int64) (bool, error) {
var exists bool
err := repo.DB.QueryRow(`SELECT EXISTS (SELECT 1 FROM collection_files WHERE file_id = $1 AND is_deleted = $2 AND collection_id = ANY ($3))`,
fileID, false, pq.Array(cIDs)).Scan(&exists)
return exists, stacktrace.Propagate(err, "")
}
func (repo *CollectionRepository) DoAllFilesExistInGivenCollections(fileIDs []int64, cIDs []int64) error {
// Query to get all distinct file_ids that exist in the collections
rows, err := repo.DB.Query(`
SELECT DISTINCT file_id
FROM collection_files
WHERE file_id = ANY ($1)
AND is_deleted = false
AND collection_id = ANY ($2)`,
pq.Array(fileIDs), pq.Array(cIDs))
if err != nil {
return stacktrace.Propagate(err, "")
}
defer rows.Close()
// Create a map of input fileIDs for easy lookup
fileIDMap := make(map[int64]bool)
for _, id := range fileIDs {
fileIDMap[id] = false // false means not found yet
}
// Mark files that were found
for rows.Next() {
var fileID int64
if err := rows.Scan(&fileID); err != nil {
return stacktrace.Propagate(err, "")
}
fileIDMap[fileID] = true // mark as found
}
if err = rows.Err(); err != nil {
return stacktrace.Propagate(err, "")
}
// Collect missing files
var missingFiles []int64
for id, found := range fileIDMap {
if !found {
missingFiles = append(missingFiles, id)
}
}
if len(missingFiles) > 0 {
return stacktrace.Propagate(fmt.Errorf("missing files %v", missingFiles), "")
}
return nil
}
// VerifyAllFileIDsExistsInCollection returns error if the fileIDs don't exist in the collection
func (repo *CollectionRepository) VerifyAllFileIDsExistsInCollection(ctx context.Context, cID int64, fileIDs []int64) error {
fileIdMap := make(map[int64]bool)
rows, err := repo.DB.QueryContext(ctx, `SELECT file_id FROM collection_files WHERE collection_id = $1 AND is_deleted = $2 AND file_id = ANY ($3)`,
cID, false, pq.Array(fileIDs))
if err != nil {
return stacktrace.Propagate(err, "")
}
for rows.Next() {
var fileID int64
if err := rows.Scan(&fileID); err != nil {
return stacktrace.Propagate(err, "")
}
fileIdMap[fileID] = true
}
// find fileIds that are not present in the collection
for _, fileID := range fileIDs {
if _, ok := fileIdMap[fileID]; !ok {
return stacktrace.Propagate(fmt.Errorf("fileID %d not found in collection %d", fileID, cID), "")
}
}
return nil
}
// GetCollectionShareeRole returns true if the collection is shared with the user
func (repo *CollectionRepository) GetCollectionShareeRole(cID int64, userID int64) (*ente.CollectionParticipantRole, error) {
var role *ente.CollectionParticipantRole
@@ -483,17 +369,6 @@ func (repo *CollectionRepository) GetOwnerID(collectionID int64) (int64, error)
return ownerID, stacktrace.Propagate(err, "failed to get collection owner")
}
// GetCollectionsFilesCount returns the number of non-deleted files which are present in the given collection
func (repo *CollectionRepository) GetCollectionsFilesCount(collectionID int64) (int64, error) {
row := repo.DB.QueryRow(`SELECT count(*) FROM collection_files WHERE collection_id=$1 AND is_deleted = false`, collectionID)
var count int64 = 0
err := row.Scan(&count)
if err != nil {
return -1, stacktrace.Propagate(err, "")
}
return count, nil
}
// Share shares a collection with a userID
func (repo *CollectionRepository) Share(
collectionID int64,
@@ -869,21 +744,6 @@ func (repo *CollectionRepository) GetSharees(cID int64) ([]ente.CollectionUser,
return users, nil
}
// GetCollectionFileIDs return list of fileIDs are currently present in the given collection
// and fileIDs are owned by the collection owner
func (repo *CollectionRepository) GetCollectionFileIDs(collectionID int64, collectionOwnerID int64) ([]int64, error) {
// Collaboration Todo: Filter out files which are not owned by the collection owner
rows, err := repo.DB.Query(
`SELECT file_id
FROM collection_files
WHERE is_deleted=false
AND collection_id =$1 AND (f_owner_id is null or f_owner_id = $2)`, collectionID, collectionOwnerID)
if err != nil {
return make([]int64, 0), stacktrace.Propagate(err, "")
}
return convertRowsToFileId(rows)
}
func convertRowsToFileId(rows *sql.Rows) ([]int64, error) {
fileIDs := make([]int64, 0)
defer rows.Close()
@@ -1064,13 +924,3 @@ func (repo *CollectionRepository) GetSharedCollectionsCount(userID int64) (int64
}
return count, nil
}
func (repo *CollectionRepository) GetCollectionCount(fileID int64) (int64, error) {
row := repo.DB.QueryRow(`SELECT count(*) FROM collection_files WHERE file_id = $1 and is_deleted = false`, fileID)
var count int64 = 0
err := row.Scan(&count)
if err != nil {
return -1, stacktrace.Propagate(err, "")
}
return count, nil
}

View File

@@ -0,0 +1,123 @@
package repo
import (
"context"
"fmt"
"github.com/ente-io/stacktrace"
"github.com/lib/pq"
)
// GetCollectionFileIDs return list of fileIDs are currently present in the given collection
// and fileIDs are owned by the collection owner
func (repo *CollectionRepository) GetCollectionFileIDs(collectionID int64, collectionOwnerID int64) ([]int64, error) {
// Collaboration Todo: Filter out files which are not owned by the collection owner
rows, err := repo.DB.Query(
`SELECT file_id
FROM collection_files
WHERE is_deleted=false
AND collection_id =$1 AND (f_owner_id is null or f_owner_id = $2)`, collectionID, collectionOwnerID)
if err != nil {
return make([]int64, 0), stacktrace.Propagate(err, "")
}
return convertRowsToFileId(rows)
}
// DoesFileExistInCollections returns true if the file exists in one of the
// provided collections
func (repo *CollectionRepository) DoesFileExistInCollections(fileID int64, cIDs []int64) (bool, error) {
var exists bool
err := repo.DB.QueryRow(`SELECT EXISTS (SELECT 1 FROM collection_files WHERE file_id = $1 AND is_deleted = $2 AND collection_id = ANY ($3))`,
fileID, false, pq.Array(cIDs)).Scan(&exists)
return exists, stacktrace.Propagate(err, "")
}
func (repo *CollectionRepository) DoAllFilesExistInGivenCollections(fileIDs []int64, cIDs []int64) error {
// Query to get all distinct file_ids that exist in the collections
rows, err := repo.DB.Query(`
SELECT DISTINCT file_id
FROM collection_files
WHERE file_id = ANY ($1)
AND is_deleted = false
AND collection_id = ANY ($2)`,
pq.Array(fileIDs), pq.Array(cIDs))
if err != nil {
return stacktrace.Propagate(err, "")
}
defer rows.Close()
// Create a map of input fileIDs for easy lookup
fileIDMap := make(map[int64]bool)
for _, id := range fileIDs {
fileIDMap[id] = false // false means not found yet
}
// Mark files that were found
for rows.Next() {
var fileID int64
if err := rows.Scan(&fileID); err != nil {
return stacktrace.Propagate(err, "")
}
fileIDMap[fileID] = true // mark as found
}
if err = rows.Err(); err != nil {
return stacktrace.Propagate(err, "")
}
// Collect missing files
var missingFiles []int64
for id, found := range fileIDMap {
if !found {
missingFiles = append(missingFiles, id)
}
}
if len(missingFiles) > 0 {
return stacktrace.Propagate(fmt.Errorf("missing files %v", missingFiles), "")
}
return nil
}
// VerifyAllFileIDsExistsInCollection returns error if the fileIDs don't exist in the collection
func (repo *CollectionRepository) VerifyAllFileIDsExistsInCollection(ctx context.Context, cID int64, fileIDs []int64) error {
fileIdMap := make(map[int64]bool)
rows, err := repo.DB.QueryContext(ctx, `SELECT file_id FROM collection_files WHERE collection_id = $1 AND is_deleted = $2 AND file_id = ANY ($3)`,
cID, false, pq.Array(fileIDs))
if err != nil {
return stacktrace.Propagate(err, "")
}
for rows.Next() {
var fileID int64
if err := rows.Scan(&fileID); err != nil {
return stacktrace.Propagate(err, "")
}
fileIdMap[fileID] = true
}
// find fileIds that are not present in the collection
for _, fileID := range fileIDs {
if _, ok := fileIdMap[fileID]; !ok {
return stacktrace.Propagate(fmt.Errorf("fileID %d not found in collection %d", fileID, cID), "")
}
}
return nil
}
// GetCollectionsFilesCount returns the number of non-deleted files which are present in the given collection
func (repo *CollectionRepository) GetCollectionsFilesCount(collectionID int64) (int64, error) {
row := repo.DB.QueryRow(`SELECT count(*) FROM collection_files WHERE collection_id=$1 AND is_deleted = false`, collectionID)
var count int64 = 0
err := row.Scan(&count)
if err != nil {
return -1, stacktrace.Propagate(err, "")
}
return count, nil
}
func (repo *CollectionRepository) GetCollectionCount(fileID int64) (int64, error) {
row := repo.DB.QueryRow(`SELECT count(*) FROM collection_files WHERE file_id = $1 and is_deleted = false`, fileID)
var count int64 = 0
err := row.Scan(&count)
if err != nil {
return -1, stacktrace.Propagate(err, "")
}
return count, nil
}

View File

@@ -3,12 +3,8 @@ package embedding
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/ente-io/museum/ente"
"github.com/ente-io/stacktrace"
"github.com/lib/pq"
"github.com/sirupsen/logrus"
)
// Repository defines the methods for inserting, updating and retrieving
@@ -17,74 +13,6 @@ type Repository struct {
DB *sql.DB
}
// Create inserts a new embedding
func (r *Repository) InsertOrUpdate(ctx context.Context, ownerID int64, entry ente.InsertOrUpdateEmbeddingRequest, size int, version int, dc string) (ente.Embedding, error) {
var updatedAt int64
err := r.DB.QueryRowContext(ctx, `
INSERT INTO embeddings
(file_id, owner_id, model, size, version, datacenters)
VALUES
($1, $2, $3, $4, $5, ARRAY[$6]::s3region[])
ON CONFLICT ON CONSTRAINT unique_embeddings_file_id_model
DO UPDATE
SET
updated_at = now_utc_micro_seconds(),
size = $4,
version = $5,
datacenters = CASE
WHEN $6 = ANY(COALESCE(embeddings.datacenters, ARRAY['b2-eu-cen']::s3region[])) THEN embeddings.datacenters
ELSE array_append(COALESCE(embeddings.datacenters, ARRAY['b2-eu-cen']::s3region[]), $6::s3region)
END
RETURNING updated_at`,
entry.FileID, ownerID, entry.Model, size, version, dc).Scan(&updatedAt)
if err != nil {
// check if error is due to model enum invalid value
if err.Error() == fmt.Sprintf("pq: invalid input value for enum model: \"%s\"", entry.Model) {
return ente.Embedding{}, stacktrace.Propagate(ente.ErrBadRequest, "invalid model value")
}
return ente.Embedding{}, stacktrace.Propagate(err, "")
}
return ente.Embedding{
FileID: entry.FileID,
Model: entry.Model,
EncryptedEmbedding: entry.EncryptedEmbedding,
DecryptionHeader: entry.DecryptionHeader,
UpdatedAt: updatedAt,
}, nil
}
// GetDiff returns the embeddings that have been updated since the given time
func (r *Repository) GetDiff(ctx context.Context, ownerID int64, model ente.Model, sinceTime int64, limit int16) ([]ente.Embedding, error) {
rows, err := r.DB.QueryContext(ctx, `SELECT file_id, model, encrypted_embedding, decryption_header, updated_at, version, size
FROM embeddings
WHERE owner_id = $1 AND model = $2 AND updated_at > $3
ORDER BY updated_at ASC
LIMIT $4`, ownerID, model, sinceTime, limit)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return convertRowsToEmbeddings(rows)
}
func (r *Repository) GetFilesEmbedding(ctx context.Context, ownerID int64, model ente.Model, fileIDs []int64) ([]ente.Embedding, error) {
rows, err := r.DB.QueryContext(ctx, `SELECT file_id, model, encrypted_embedding, decryption_header, updated_at, version, size
FROM embeddings
WHERE owner_id = $1 AND model = $2 AND file_id = ANY($3)`, ownerID, model, pq.Array(fileIDs))
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
return convertRowsToEmbeddings(rows)
}
func (r *Repository) DeleteAll(ctx context.Context, ownerID int64) error {
_, err := r.DB.ExecContext(ctx, "DELETE FROM embeddings WHERE owner_id = $1", ownerID)
if err != nil {
return stacktrace.Propagate(err, "")
}
return nil
}
func (r *Repository) Delete(fileID int64) error {
_, err := r.DB.Exec("DELETE FROM embeddings WHERE file_id = $1", fileID)
if err != nil {
@@ -117,33 +45,6 @@ func (r *Repository) GetDatacenters(ctx context.Context, fileID int64) ([]string
return datacenters, nil
}
// GetOtherDCsForFileAndModel returns the list of datacenters where the embeddings are stored for a given file and model, excluding the ignoredDC
func (r *Repository) GetOtherDCsForFileAndModel(ctx context.Context, fileID int64, model string, ignoredDC string) ([]string, error) {
rows, err := r.DB.QueryContext(ctx, `SELECT datacenters FROM embeddings WHERE file_id = $1 AND model = $2`, fileID, model)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
uniqueDatacenters := make(map[string]bool)
for rows.Next() {
var datacenters []string
err = rows.Scan(pq.Array(&datacenters))
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
for _, dc := range datacenters {
// add to uniqueDatacenters if it is not the ignoredDC
if dc != ignoredDC {
uniqueDatacenters[dc] = true
}
}
}
datacenters := make([]string, 0, len(uniqueDatacenters))
for dc := range uniqueDatacenters {
datacenters = append(datacenters, dc)
}
return datacenters, nil
}
// RemoveDatacenter removes the given datacenter from the list of datacenters
func (r *Repository) RemoveDatacenter(ctx context.Context, fileID int64, dc string) error {
_, err := r.DB.ExecContext(ctx, `UPDATE embeddings SET datacenters = array_remove(datacenters, $1) WHERE file_id = $2`, dc, fileID)
@@ -152,87 +53,3 @@ func (r *Repository) RemoveDatacenter(ctx context.Context, fileID int64, dc stri
}
return nil
}
// AddNewDC adds the dc name to the list of datacenters, if it doesn't exist already, for a given file, model and user. It also updates the size of the embedding
func (r *Repository) AddNewDC(ctx context.Context, fileID int64, model ente.Model, userID int64, size int, dc string) error {
res, err := r.DB.ExecContext(ctx, `
UPDATE embeddings
SET size = $1,
datacenters = CASE
WHEN $2::s3region = ANY(datacenters) THEN datacenters
ELSE array_append(datacenters, $2::s3region)
END
WHERE file_id = $3 AND model = $4 AND owner_id = $5`, size, dc, fileID, model, userID)
if err != nil {
return stacktrace.Propagate(err, "")
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return stacktrace.Propagate(err, "")
}
if rowsAffected == 0 {
return stacktrace.Propagate(errors.New("no row got updated"), "")
}
return nil
}
func (r *Repository) GetIndexedFiles(ctx context.Context, id int64, model ente.Model, since int64, limit *int64) ([]ente.IndexedFile, error) {
var rows *sql.Rows
var err error
if limit == nil {
rows, err = r.DB.QueryContext(ctx, `SELECT file_id, updated_at FROM embeddings WHERE owner_id = $1 AND model = $2 AND updated_at > $3`, id, model, since)
} else {
rows, err = r.DB.QueryContext(ctx, `SELECT file_id, updated_at FROM embeddings WHERE owner_id = $1 AND model = $2 AND updated_at > $3 LIMIT $4`, id, model, since, *limit)
}
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
defer func() {
if err := rows.Close(); err != nil {
logrus.Error(err)
}
}()
result := make([]ente.IndexedFile, 0)
for rows.Next() {
var meta ente.IndexedFile
err := rows.Scan(&meta.FileID, &meta.UpdatedAt)
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
result = append(result, meta)
}
return result, nil
}
func convertRowsToEmbeddings(rows *sql.Rows) ([]ente.Embedding, error) {
defer func() {
if err := rows.Close(); err != nil {
logrus.Error(err)
}
}()
result := make([]ente.Embedding, 0)
for rows.Next() {
embedding := ente.Embedding{}
var encryptedEmbedding, decryptionHeader sql.NullString
var version sql.NullInt32
err := rows.Scan(&embedding.FileID, &embedding.Model, &encryptedEmbedding, &decryptionHeader, &embedding.UpdatedAt, &version, &embedding.Size)
if encryptedEmbedding.Valid && len(encryptedEmbedding.String) > 0 {
embedding.EncryptedEmbedding = encryptedEmbedding.String
}
if decryptionHeader.Valid && len(decryptionHeader.String) > 0 {
embedding.DecryptionHeader = decryptionHeader.String
}
v := 1
if version.Valid {
v = int(version.Int32)
}
embedding.Version = &v
if err != nil {
return nil, stacktrace.Propagate(err, "")
}
result = append(result, embedding)
}
return result, nil
}

View File

@@ -90,16 +90,16 @@ func (repo *FamilyRepository) CloseFamily(ctx context.Context, adminID int64) er
// AddMemberInvite inserts a family invitation entry for this given pair of admin & member and return the active inviteToken
// which can be used to accept the invite
func (repo *FamilyRepository) AddMemberInvite(ctx context.Context, adminID int64, memberID int64, inviteToken string, storageLimit *int64) (string, error) {
func (repo *FamilyRepository) AddMemberInvite(ctx context.Context, adminID int64, memberID int64, inviteToken string) (string, error) {
if adminID == memberID {
return "", stacktrace.Propagate(errors.New("memberID and adminID can not be same"), "")
}
// on conflict, we should not change the status from 'ACCEPTED' to `INVITED`.
// Also, the token should not be updated if the user is already in `INVITED` state.
_, err := repo.DB.ExecContext(ctx, `INSERT INTO families(id, admin_id, member_id, status, token, storage_limit)
VALUES($1, $2, $3, $4, $5, $6) ON CONFLICT (admin_id,member_id)
DO UPDATE SET(status, token) = ($4, $5) WHERE NOT (families.status = ANY($7))`,
uuid.New(), adminID, memberID, ente.INVITED, inviteToken, storageLimit, pq.Array([]ente.MemberStatus{ente.INVITED, ente.ACCEPTED}))
_, err := repo.DB.ExecContext(ctx, `INSERT INTO families(id, admin_id, member_id, status, token)
VALUES($1, $2, $3, $4, $5) ON CONFLICT (admin_id,member_id)
DO UPDATE SET(status, token) = ($4, $5) WHERE NOT (families.status = ANY($6))`,
uuid.New(), adminID, memberID, ente.INVITED, inviteToken, pq.Array([]ente.MemberStatus{ente.INVITED, ente.ACCEPTED}))
if err != nil {
return "", stacktrace.Propagate(err, "")
}
@@ -197,7 +197,7 @@ func (repo *FamilyRepository) RemoveMember(ctx context.Context, adminID int64, m
}
// UpdateStorage is used to set Pre-existing Members Storage Limit.
func (repo *FamilyRepository) ModifyMemberStorage(ctx context.Context, adminID int64, id uuid.UUID, storageLimit *int64) error {
func (repo *FamilyRepository) ModifyMemberStorage(ctx context.Context, id uuid.UUID, storageLimit *int64) error {
_, err := repo.DB.Exec(`UPDATE families SET storage_limit=$1 where id=$2`, storageLimit, id)
if err != nil {
return stacktrace.Propagate(err, "Could not update Members Storage Limit")

View File

@@ -11,9 +11,9 @@ import { Titlebar } from "@/base/components/Titlebar";
import { errorDialogAttributes } from "@/base/components/utils/dialog";
import { useModalVisibility } from "@/base/components/utils/modal";
import { useBaseContext } from "@/base/context";
import { formattedDateTime } from "@/base/i18n-date";
import log from "@/base/log";
import SingleInputForm from "@ente/shared/components/SingleInputForm";
import { formatDateTimeFull } from "@ente/shared/time/format";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -293,7 +293,7 @@ const ManagePasskeyDrawer: React.FC<ManagePasskeyDrawerProps> = ({
onRootClose={onClose}
/>
<CreatedAtEntry>
{formatDateTimeFull(passkey.createdAt / 1000)}
{formattedDateTime(passkey.createdAt)}
</CreatedAtEntry>
<RowButtonGroup>
<RowButton

View File

@@ -36,7 +36,7 @@ const Passkey = z.object({
*/
friendlyName: z.string(),
/**
* Epoch milliseconds when this passkey was created.
* Epoch microseconds when this passkey was created.
*/
createdAt: z.number(),
});

View File

@@ -81,7 +81,7 @@ const RemoteAuthenticatorEntityChange = z.object({
*/
isDeleted: z.boolean(),
/**
* Epoch milliseconds when this entity was last updated.
* Epoch microseconds when this entity was last updated.
*
* This value is suitable for being passed as the `sinceTime` in the diff
* requests to implement pagination.

View File

@@ -17,6 +17,7 @@ import { Titlebar } from "@/base/components/Titlebar";
import { useModalVisibility } from "@/base/components/utils/modal";
import { useBaseContext } from "@/base/context";
import { sharedCryptoWorker } from "@/base/crypto";
import { formattedDateTime } from "@/base/i18n-date";
import log from "@/base/log";
import { appendCollectionKeyToShareURL } from "@/gallery/services/share";
import type {
@@ -34,7 +35,6 @@ import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
import { CustomError, parseSharingErrorCodes } from "@ente/shared/error";
import { formatDateTime } from "@ente/shared/time/format";
import AddIcon from "@mui/icons-material/Add";
import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings";
import BlockIcon from "@mui/icons-material/Block";
@@ -1539,8 +1539,8 @@ const ManageLinkExpiry: React.FC<ManageLinkExpiryProps> = ({
isLinkExpired(publicShareProp?.validTill)
? t("link_expired")
: publicShareProp?.validTill
? formatDateTime(
publicShareProp?.validTill / 1000,
? formattedDateTime(
publicShareProp.validTill / 1000,
)
: t("never")
}

View File

@@ -270,7 +270,7 @@ function ContinuousExport({ continuousExport, toggleContinuousExport }) {
return (
<SpaceBetweenFlex minHeight={"48px"}>
<Typography sx={{ color: "text.muted" }}>
{t("CONTINUOUS_EXPORT")}
{t("sync_continuously")}
</Typography>
<Box>
<EnteSwitch

View File

@@ -1,9 +1,9 @@
import { LinkButton } from "@/base/components/LinkButton";
import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton";
import { formattedNumber } from "@/base/i18n";
import { formattedDateTime } from "@/base/i18n-date";
import { EnteFile } from "@/media/file";
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { formatDateTime } from "@ente/shared/time/format";
import { DialogActions, DialogContent, Stack, Typography } from "@mui/material";
import { t } from "i18next";
import { useState } from "react";
@@ -19,6 +19,8 @@ interface Props {
}
export default function ExportFinished(props: Props) {
const { lastExportTime } = props;
const [pendingFileListView, setPendingFileListView] =
useState<boolean>(false);
@@ -35,7 +37,7 @@ export default function ExportFinished(props: Props) {
<Stack sx={{ pr: 2 }}>
<SpaceBetweenFlex minHeight={"48px"}>
<Typography sx={{ color: "text.muted" }}>
{t("PENDING_ITEMS")}
{t("pending_items")}
</Typography>
{props.pendingExports.length ? (
<LinkButton onClick={openPendingFileList}>
@@ -52,8 +54,8 @@ export default function ExportFinished(props: Props) {
{t("last_export_time")}
</Typography>
<Typography>
{props.lastExportTime
? formatDateTime(props.lastExportTime)
{lastExportTime
? formattedDateTime(new Date(lastExportTime))
: t("never")}
</Typography>
</SpaceBetweenFlex>

View File

@@ -36,25 +36,25 @@ export default function ExportInProgress(props: Props) {
<VerticallyCentered>
<Typography sx={{ mb: 1.5 }}>
{props.exportStage === ExportStage.STARTING ? (
t("EXPORT_STARTING")
t("export_starting")
) : props.exportStage === ExportStage.MIGRATION ? (
t("MIGRATING_EXPORT")
t("preparing")
) : props.exportStage ===
ExportStage.RENAMING_COLLECTION_FOLDERS ? (
t("RENAMING_COLLECTION_FOLDERS")
t("renaming_album_folders")
) : props.exportStage ===
ExportStage.TRASHING_DELETED_FILES ? (
t("TRASHING_DELETED_FILES")
t("trashing_deleted_files")
) : props.exportStage ===
ExportStage.TRASHING_DELETED_COLLECTIONS ? (
t("TRASHING_DELETED_COLLECTIONS")
t("trashing_deleted_albums")
) : (
<Typography
component="span"
sx={{ color: "text.muted" }}
>
<Trans
i18nKey={"EXPORT_PROGRESS"}
i18nKey={"export_progress"}
components={{
a: (
<Typography
@@ -104,7 +104,7 @@ export default function ExportInProgress(props: Props) {
color="critical"
onClick={props.stopExport}
>
{t("STOP_EXPORT")}
{t("stop")}
</FocusVisibleButton>
</DialogActions>
</>

View File

@@ -59,7 +59,7 @@ const ExportPendingList = (props: Iprops) => {
open={props.isOpen}
onClose={props.onClose}
paperMaxWidth="444px"
title={t("PENDING_ITEMS")}
title={t("pending_items")}
>
<ItemList
maxHeight={240}

View File

@@ -1,3 +1,5 @@
import { isSameDay } from "@/base/date";
import { formattedDate } from "@/base/i18n-date";
import log from "@/base/log";
import type { FileInfoProps } from "@/gallery/components/FileInfo";
import {
@@ -13,6 +15,7 @@ import type { GalleryBarMode } from "@/new/photos/components/gallery/reducer";
import { TRASH_SECTION } from "@/new/photos/services/collection";
import { styled } from "@mui/material";
import { PhotoViewer } from "components/PhotoViewer";
import { t } from "i18next";
import { useRouter } from "next/router";
import { GalleryContext } from "pages/gallery";
import PhotoSwipe from "photoswipe";
@@ -66,6 +69,18 @@ export type DisplayFile = EnteFile & {
isSourceLoaded?: boolean;
conversionFailed?: boolean;
canForceConvert?: boolean;
/**
* [Note: Timeline date string]
*
* The timeline date string is a formatted date string under which a
* particular file should be grouped in the gallery listing. e.g. "Today",
* "Yesterday", "Fri, 21 Feb" etc.
*
* All files which have the same timelineDateString will be grouped under a
* single section in the gallery listing, prefixed by the timelineDateString
* itself, and a checkbox to select all files on that date.
*/
timelineDateString?: string;
};
export type PhotoFrameProps = Pick<
@@ -178,6 +193,7 @@ const PhotoFrame = ({
w: window.innerWidth,
h: window.innerHeight,
title: file.pubMagicMetadata?.data.caption,
timelineDateString: fileTimelineDateString(file),
}));
setDisplayFiles(result);
setFetching({});
@@ -287,6 +303,7 @@ const PhotoFrame = ({
const handleSelect = handleSelectCreator(
setSelected,
mode,
galleryContext.user?.id,
activeCollectionID,
activePersonID,
setRangeStart,
@@ -313,16 +330,9 @@ const PhotoFrame = ({
(index - i) * direction > 0;
i += direction
) {
handleSelect(
displayFiles[i].id,
displayFiles[i].ownerID === galleryContext.user?.id,
)(!checked);
handleSelect(displayFiles[i])(!checked);
}
handleSelect(
displayFiles[index].id,
displayFiles[index].ownerID === galleryContext.user?.id,
index,
)(!checked);
handleSelect(displayFiles[index], index)(!checked);
}
};
@@ -337,11 +347,7 @@ const PhotoFrame = ({
updateURL={updateThumbURL(index)}
onClick={onThumbnailClick(index)}
selectable={selectable}
onSelect={handleSelect(
item.id,
item.ownerID === galleryContext.user?.id,
index,
)}
onSelect={handleSelect(item, index)}
selected={
(!mode
? selected.collectionID === activeCollectionID
@@ -527,6 +533,7 @@ const PhotoFrame = ({
/* @ts-ignore TODO(PS): test */
open={open5}
onClose={handleClose}
user={galleryContext.user ?? undefined}
files={files}
initialIndex={currentIndex}
{...{
@@ -664,3 +671,15 @@ const updateDisplayFileSource = (
file.src = url as string;
}
};
/**
* See: [Note: Timeline date string]
*/
const fileTimelineDateString = (item: EnteFile) => {
const date = new Date(item.metadata.creationTime / 1000);
return isSameDay(date, new Date())
? t("today")
: isSameDay(date, new Date(Date.now() - 24 * 60 * 60 * 1000))
? t("yesterday")
: formattedDate(date);
};

View File

@@ -1,4 +1,5 @@
import { assertionFailed } from "@/base/assert";
import { isSameDay } from "@/base/date";
import { EnteFile } from "@/media/file";
import {
GAP_BTW_TILES,
@@ -7,7 +8,6 @@ import {
MIN_COLUMNS,
} from "@/new/photos/components/PhotoList";
import { FlexWrapper } from "@ente/shared/components/Container";
import { formatDate } from "@ente/shared/time/format";
import { Box, Checkbox, Link, Typography, styled } from "@mui/material";
import type { PhotoFrameProps } from "components/PhotoFrame";
import { t } from "i18next";
@@ -20,7 +20,7 @@ import {
ListChildComponentProps,
areEqual,
} from "react-window";
import { handleSelectCreator } from "utils/photoFrame";
import { handleSelectCreatorMulti } from "utils/photoFrame";
import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery";
export const DATE_CONTAINER_HEIGHT = 48;
@@ -185,7 +185,9 @@ const NothingContainer = styled(ListItemContainer)`
type Props = Pick<PhotoFrameProps, "mode" | "modePlus"> & {
height: number;
width: number;
displayFiles: EnteFile[];
displayFiles: (EnteFile & {
timelineDateString?: string;
})[];
showAppDownloadBanner: boolean;
getThumbnail: (
file: EnteFile,
@@ -271,7 +273,11 @@ export function PhotoList({
const shouldRefresh = useRef(false);
const listRef = useRef(null);
const [checkedDates, setCheckedDates] = useState({});
// Timeline date strings for which all photos have been selected.
//
// See: [Note: Timeline date string]
const [checkedTimelineDateStrings, setCheckedTimelineDateStrings] =
useState(new Set());
const fittableColumns = getFractionFittableColumns(width);
let columns = Math.floor(fittableColumns);
@@ -434,14 +440,7 @@ export function PhotoList({
timeStampList.push({
itemType: ITEM_TYPE.TIME,
date: isSameDay(new Date(currentDate), new Date())
? t("TODAY")
: isSameDay(
new Date(currentDate),
new Date(Date.now() - A_DAY),
)
? t("YESTERDAY")
: formatDate(currentDate),
date: item.timelineDateString,
id: currentDate.toString(),
});
timeStampList.push({
@@ -743,61 +742,60 @@ export function PhotoList({
// Nothing to do here if nothing is selected.
if (!galleryContext.selectedFile) return;
const notSelectedFiles = displayFiles?.filter(
const notSelectedFiles = (displayFiles ?? []).filter(
(item) => !galleryContext.selectedFile[item.id],
);
const unselectedDates = [
...new Set(notSelectedFiles?.map((item) => getDate(item))), // to get file's date which were manually unselected
];
const localSelectedFiles = displayFiles.filter(
const unselectedDates = new Set(
notSelectedFiles.map((item) => item.timelineDateString),
); // to get file's date which were manually unselected
const localSelectedFiles = (displayFiles ?? []).filter(
// to get files which were manually selected
(item) => !unselectedDates.includes(getDate(item)),
(item) => !unselectedDates.has(item.timelineDateString),
);
const localSelectedDates = [
...new Set(localSelectedFiles?.map((item) => getDate(item))),
]; // to get file's date which were manually selected
const localSelectedDates = new Set(
localSelectedFiles.map((item) => item.timelineDateString),
); // to get file's date which were manually selected
unselectedDates.forEach((date) => {
setCheckedDates((prev) => ({
...prev,
[date]: false,
})); // To uncheck select all checkbox if any of the file on the date is unselected
});
localSelectedDates.map((date) => {
setCheckedDates((prev) => ({
...prev,
[date]: true,
}));
// To check select all checkbox if all of the files on the date is selected manually
setCheckedTimelineDateStrings((prev) => {
const checked = new Set(prev);
// Uncheck the "Select all" checkbox if any of the files on the date
// is unselected.
unselectedDates.forEach((date) => checked.delete(date));
// Check the "Select all" checkbox if all of the files on a date are
// selected.
localSelectedDates.forEach((date) => checked.add(date));
return checked;
});
}, [galleryContext.selectedFile]);
const handleSelect = handleSelectCreator(
const handleSelect = handleSelectCreatorMulti(
galleryContext.setSelectedFiles,
mode,
galleryContext?.user?.id,
activeCollectionID,
activePersonID,
);
const onChangeSelectAllCheckBox = (date: string) => {
const dates = { ...checkedDates, [date]: !checkedDates[date] };
const isDateSelected = !checkedDates[date];
setCheckedDates(dates);
const next = new Set(checkedTimelineDateStrings);
let isDateSelected: boolean;
if (!next.has(date)) {
next.add(date);
isDateSelected = true;
} else {
next.delete(date);
isDateSelected = false;
}
setCheckedTimelineDateStrings(next);
const filesOnADay = displayFiles?.filter(
(item) => getDate(item) === date,
(item) => item.timelineDateString === date,
); // all files on a checked/unchecked day
filesOnADay.forEach((file) => {
handleSelect(
file.id,
file.ownerID === galleryContext?.user?.id,
)(isDateSelected);
});
handleSelect(filesOnADay)(isDateSelected);
};
const renderListItem = (
@@ -817,7 +815,9 @@ export function PhotoList({
<Checkbox
key={item.date}
name={item.date}
checked={!!checkedDates[item.date]}
checked={checkedTimelineDateStrings.has(
item.date,
)}
onChange={() =>
onChangeSelectAllCheckBox(item.date)
}
@@ -836,7 +836,9 @@ export function PhotoList({
<Checkbox
key={listItem.date}
name={listItem.date}
checked={!!checkedDates[listItem.date]}
checked={checkedTimelineDateStrings.has(
listItem.date,
)}
onChange={() =>
onChangeSelectAllCheckBox(listItem.date)
}
@@ -918,24 +920,3 @@ export function PhotoList({
</List>
);
}
const A_DAY = 24 * 60 * 60 * 1000;
const getDate = (item: EnteFile) => {
const currentDate = item.metadata.creationTime / 1000;
const date = isSameDay(new Date(currentDate), new Date())
? t("TODAY")
: isSameDay(new Date(currentDate), new Date(Date.now() - A_DAY))
? t("YESTERDAY")
: formatDate(currentDate);
return date;
};
const isSameDay = (first: Date, second: Date) => {
return (
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate()
);
};

View File

@@ -903,6 +903,8 @@ const localeName = (locale: SupportedLocale) => {
return "Українська";
case "vi-VN":
return "Tiếng Việt";
case "ja-JP":
return "日本語";
}
};

View File

@@ -3,6 +3,7 @@ import type { ButtonishProps } from "@/base/components/mui";
import { UnstyledButton } from "@/new/photos/components/UnstyledButton";
import type { UserDetails } from "@/new/photos/services/user-details";
import {
familyMemberStorageLimit,
familyUsage,
isPartOfFamilyWithOtherMembers,
} from "@/new/photos/services/user-details";
@@ -118,42 +119,63 @@ interface SubscriptionCardContentOverlayProps {
export const SubscriptionCardContentOverlay: React.FC<
SubscriptionCardContentOverlayProps
> = ({ userDetails }) => (
<Overlay>
<Stack
sx={{
height: "100%",
justifyContent: "space-between",
padding: "20px 16px",
}}
>
{isPartOfFamilyWithOtherMembers(userDetails) ? (
<FamilySubscriptionCardContents userDetails={userDetails} />
) : (
<IndividualSubscriptionCardContents userDetails={userDetails} />
)}
</Stack>
</Overlay>
);
const IndividualSubscriptionCardContents: React.FC<
SubscriptionCardContentOverlayProps
> = ({ userDetails }) => {
const totalStorage =
userDetails.subscription.storage + userDetails.storageBonus;
const inFamily = isPartOfFamilyWithOtherMembers(userDetails);
const storageLimit = inFamily
? familyMemberStorageLimit(userDetails)
: undefined;
return (
<>
<StorageSection storage={totalStorage} usage={userDetails.usage} />
<IndividualUsageSection
usage={userDetails.usage}
fileCount={userDetails.fileCount}
storage={totalStorage}
/>
</>
<Overlay>
<Stack
sx={{
height: "100%",
justifyContent: "space-between",
padding: "20px 16px",
}}
>
{inFamily ? (
storageLimit ? (
<UserSubscriptionCardContents
userDetails={userDetails}
totalStorage={storageLimit}
/>
) : (
<FamilySubscriptionCardContents
userDetails={userDetails}
/>
)
) : (
<UserSubscriptionCardContents
userDetails={userDetails}
totalStorage={
userDetails.subscription.storage +
userDetails.storageBonus
}
/>
)}
</Stack>
</Overlay>
);
};
type UserSubscriptionCardContentsProps = SubscriptionCardContentOverlayProps & {
totalStorage: number;
};
const UserSubscriptionCardContents: React.FC<
UserSubscriptionCardContentsProps
> = ({ userDetails, totalStorage }) => (
<>
<StorageSection storage={totalStorage} usage={userDetails.usage} />
<IndividualUsageSection
usage={userDetails.usage}
fileCount={userDetails.fileCount}
storage={totalStorage}
/>
</>
);
interface StorageSectionProps {
usage: number;
storage: number;

View File

@@ -434,15 +434,20 @@ const Page: React.FC = () => {
}, [state.isRecomputingSearchResults, state.pendingSearchSuggestions]);
const selectAll = (e: KeyboardEvent) => {
// ignore ctrl/cmd + a if the user is typing in a text field
// Ignore CTRL/CMD + a if the user is typing in a text field.
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
// if any of the modals are open, don't select all
// Ignore select all if:
if (
// - We haven't fetched the user yet;
!user ||
// - There is nothing to select;
!filteredFiles?.length ||
// - Any of the modals are open.
uploadTypeSelectorView ||
openCollectionSelector ||
collectionNamerView ||
@@ -451,13 +456,15 @@ const Page: React.FC = () => {
fixCreationTimeVisibilityProps.open ||
exportVisibilityProps.open ||
authenticateUserVisibilityProps.open ||
isPhotoSwipeOpen ||
!filteredFiles?.length ||
!user
isPhotoSwipeOpen
) {
return;
}
// Prevent the browser's default select all handling.
e.preventDefault();
// Create a selection with everything based on the current context.
const selected = {
ownCount: 0,
count: 0,
@@ -804,6 +811,8 @@ const Page: React.FC = () => {
if (!user) {
// Don't render until we dispatch "mount" with the logged in user.
//
// Tag: [Note: Gallery children can assume user]
return <div></div>;
}

View File

@@ -1,16 +1,18 @@
import type { SelectionContext } from "@/new/photos/components/gallery";
import type { GalleryBarMode } from "@/new/photos/components/gallery/reducer";
import { SetSelectedState } from "types/gallery";
import { SetSelectedState, type SelectedState } from "types/gallery";
// TODO: All this is unnecessarily complex, and needs reworking.
export const handleSelectCreator =
(
setSelected: SetSelectedState,
mode: GalleryBarMode | undefined,
userID: number | undefined,
activeCollectionID: number,
activePersonID: string | undefined,
setRangeStart?,
) =>
(id: number, isOwnFile: boolean, index?: number) =>
({ id, ownerID }: { id: number; ownerID: number }, index?: number) =>
(checked: boolean) => {
if (typeof index !== "undefined") {
if (checked) {
@@ -19,84 +21,13 @@ export const handleSelectCreator =
setRangeStart(undefined);
}
}
setSelected((selected) => {
if (!mode) {
// Retain older behavior for non-gallery call sites.
if (selected.collectionID !== activeCollectionID) {
selected = {
ownCount: 0,
count: 0,
collectionID: 0,
context: undefined,
};
}
} else if (!selected.context) {
// Gallery will specify a mode, but a fresh selection starts off
// without a context, so fill it in with the current context.
selected = {
...selected,
context:
mode == "people"
? { mode, personID: activePersonID! }
: {
mode,
collectionID: activeCollectionID!,
},
};
} else {
// Both mode and context are defined.
if (selected.context.mode != mode) {
// Clear selection if mode has changed.
selected = {
ownCount: 0,
count: 0,
collectionID: 0,
context:
mode == "people"
? { mode, personID: activePersonID! }
: {
mode,
collectionID: activeCollectionID!,
},
};
} else {
if (selected.context?.mode == "people") {
if (selected.context.personID != activePersonID) {
// Clear selection if person has changed.
selected = {
ownCount: 0,
count: 0,
collectionID: 0,
context: {
mode: selected.context?.mode,
personID: activePersonID!,
},
};
}
} else {
if (
selected.context.collectionID != activeCollectionID
) {
// Clear selection if collection has changed.
selected = {
ownCount: 0,
count: 0,
collectionID: 0,
context: {
mode: selected.context?.mode,
collectionID: activeCollectionID!,
},
};
}
}
}
}
const newContext: SelectionContext | undefined = !mode
? undefined
: mode == "people"
? { mode, personID: activePersonID! }
: { mode, collectionID: activeCollectionID! };
setSelected((_selected) => {
const { selected, newContext } = createSelectedAndContext(
mode,
activeCollectionID,
activePersonID,
_selected,
);
const handleCounterChange = (count: number) => {
if (selected[id] === checked) {
@@ -110,7 +41,7 @@ export const handleSelectCreator =
};
const handleAllCounterChange = () => {
if (isOwnFile) {
if (ownerID === userID) {
return {
ownCount: handleCounterChange(selected.ownCount),
count: handleCounterChange(selected.count),
@@ -130,3 +61,139 @@ export const handleSelectCreator =
};
});
};
export const handleSelectCreatorMulti =
(
setSelected: SetSelectedState,
mode: GalleryBarMode | undefined,
userID: number | undefined,
activeCollectionID: number,
activePersonID: string | undefined,
) =>
(files: { id: number; ownerID: number }[]) =>
(checked: boolean) => {
setSelected((_selected) => {
const { selected, newContext } = createSelectedAndContext(
mode,
activeCollectionID,
activePersonID,
_selected,
);
const newSelected = { ...selected };
let newCount = selected.count;
let newOwnCount = selected.ownCount;
if (checked) {
for (const file of files) {
if (!newSelected[file.id]) {
newSelected[file.id] = true;
newCount++;
if (file.ownerID === userID) newOwnCount++;
}
}
} else {
for (const file of files) {
if (newSelected[file.id]) {
newSelected[file.id] = false;
newCount--;
if (file.ownerID === userID) newOwnCount--;
}
}
}
return {
...newSelected,
count: newCount,
ownCount: newOwnCount,
collectionID: activeCollectionID,
context: newContext,
};
});
};
const createSelectedAndContext = (
mode: GalleryBarMode | undefined,
activeCollectionID: number,
activePersonID: string | undefined,
selected: SelectedState,
) => {
if (!mode) {
// Retain older behavior for non-gallery call sites.
if (selected.collectionID !== activeCollectionID) {
selected = {
ownCount: 0,
count: 0,
collectionID: 0,
context: undefined,
};
}
} else if (!selected.context) {
// Gallery will specify a mode, but a fresh selection starts off
// without a context, so fill it in with the current context.
selected = {
...selected,
context:
mode == "people"
? { mode, personID: activePersonID! }
: {
mode,
collectionID: activeCollectionID!,
},
};
} else {
// Both mode and context are defined.
if (selected.context.mode != mode) {
// Clear selection if mode has changed.
selected = {
ownCount: 0,
count: 0,
collectionID: 0,
context:
mode == "people"
? { mode, personID: activePersonID! }
: {
mode,
collectionID: activeCollectionID!,
},
};
} else {
if (selected.context?.mode == "people") {
if (selected.context.personID != activePersonID) {
// Clear selection if person has changed.
selected = {
ownCount: 0,
count: 0,
collectionID: 0,
context: {
mode: selected.context?.mode,
personID: activePersonID!,
},
};
}
} else {
if (selected.context.collectionID != activeCollectionID) {
// Clear selection if collection has changed.
selected = {
ownCount: 0,
count: 0,
collectionID: 0,
context: {
mode: selected.context?.mode,
collectionID: activeCollectionID!,
},
};
}
}
}
}
const newContext: SelectionContext | undefined = !mode
? undefined
: mode == "people"
? { mode, personID: activePersonID! }
: { mode, collectionID: activeCollectionID! };
return { selected, newContext };
};

View File

@@ -1,6 +1,8 @@
/**
* Convert an epoch microsecond value to a JavaScript date.
*
* [Note: Remote timestamps are epoch microseconds]
*
* This is a convenience API for dealing with optional epoch microseconds in
* various data structures. Remote talks in terms of epoch microseconds, but
* JavaScript dates are underlain by epoch milliseconds, and this does a
@@ -12,3 +14,11 @@ export const dateFromEpochMicroseconds = (
epochMicroseconds === undefined
? undefined
: new Date(epochMicroseconds / 1000);
/**
* Return `true` if both the given dates have the same day.
*/
export const isSameDay = (first: Date, second: Date) =>
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate();

View File

@@ -4,7 +4,73 @@
* Note that we rely on the current behaviour of a full reload on changing the
* language. See: [Note: Changing locale causes a full reload].
*/
import i18n from "i18next";
import i18n, { t } from "i18next";
const _dateFormat = new Intl.DateTimeFormat(i18n.language, {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
});
const _dateWithoutYearFormat = new Intl.DateTimeFormat(i18n.language, {
weekday: "short",
day: "numeric",
month: "short",
});
const _timeFormat = new Intl.DateTimeFormat(i18n.language, {
timeStyle: "short",
});
/**
* Return a locale aware formatted date from the given {@link Date}.
*
* The behaviour depends upon whether the given {@link date} falls within the
* current calendar year.
*
* - For dates in the current year, year is omitted, e.g, "Fri, 21 Feb".
*
* - Otherwise, the year is included, e.g., "Fri, 21 Feb 2025".
*/
export const formattedDate = (date: Date) =>
(isSameYear(date) ? _dateWithoutYearFormat : _dateFormat).format(date);
const isSameYear = (date: Date) =>
new Date().getFullYear() === date.getFullYear();
/**
* Return a locale aware formatted time from the given {@link Date}.
*
* Example: "11:51 AM"
*/
export const formattedTime = (date: Date) => _timeFormat.format(date);
/**
* Return a locale aware formatted date and time from the given {@link Date},
* using the year omission behavior as documented in {@link formattedDate}.
*
* Example:
* - If within year: "Fri, 21 Feb at 11:51 AM".
* - Otherwise: "Fri, 21 Feb 2025 at 11:51 AM"
*
* @param dateOrEpochMicroseconds A JavaScript Date or a numeric epoch
* microseconds value.
*
* As a convenience, this function can be either be directly passed a JavaScript
* date, or it can be given the raw epoch microseconds value and it'll convert
* internally.
*
* See: [Note: Remote timestamps are epoch microseconds]
*/
export const formattedDateTime = (dateOrEpochMicroseconds: Date | number) =>
_formattedDateTime(toDate(dateOrEpochMicroseconds));
const _formattedDateTime = (date: Date) =>
[formattedDate(date), t("at"), formattedTime(date)].join(" ");
const toDate = (dm: Date | number) =>
typeof dm == "number" ? new Date(dm / 1000) : dm;
let _relativeTimeFormat: Intl.RelativeTimeFormat | undefined;

View File

@@ -33,6 +33,7 @@ export const supportedLocales = [
"lt-LT" /* Lithuanian */,
"uk-UA" /* Ukrainian */,
"vi-VN" /* Vietnamese */,
"ja-JP" /* Japanese */,
] as const;
/** The type of {@link supportedLocales}. */
@@ -134,6 +135,8 @@ export const setupI18n = async () => {
// Value is an epoch microsecond so that we can directly pass the
// timestamps we get from our API responses. The formatter expects
// milliseconds, so divide by 1000.
//
// See [Note: Remote timestamps are epoch microseconds].
return (val) => formatter.format(val / 1000);
});
};
@@ -190,6 +193,8 @@ const closestSupportedLocale = (
return "uk-UA";
} else if (ls.startsWith("vi")) {
return "vi-VN";
} else if (ls.startsWith("ja")) {
return "ja-JP";
}
}

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "أدخل الاسم",
"uploader_name_hint": "أضف اسما حتى يتمكن أصدقاؤك من معرفة من يشكرون على هذه الصور الرائعة!",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "Увядзіце імя",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "Eine neue Version von Ente wurde veröffentlicht, aber sie kann nicht automatisch heruntergeladen und installiert werden.",
"download_and_install": "Herunterladen und installieren",
"ignore_this_version": "Diese Version ignorieren",
"TODAY": "Heute",
"YESTERDAY": "Gestern",
"today": "Heute",
"yesterday": "Gestern",
"enter_name": "Name eingeben",
"uploader_name_hint": "Füge einen Namen hinzu, damit deine Freunde wissen, wem sie für diese tollen Fotos zu danken haben!",
"name_placeholder": "Name...",
@@ -529,15 +529,15 @@
"gb": "GB",
"tb": "TB"
},
"STOP_EXPORT": "Stop",
"EXPORT_PROGRESS": "<a>{{progress.success, number}} / {{progress.total, number}}</a> Dateien synchronisiert",
"MIGRATING_EXPORT": "Vorbereiten...",
"RENAMING_COLLECTION_FOLDERS": "Albumordner umbenennen...",
"TRASHING_DELETED_FILES": "Verschiebe gelöschte Dateien in den Trash-Ordner...",
"TRASHING_DELETED_COLLECTIONS": "Verschiebe gelöschte Alben in den Trash-Ordner...",
"CONTINUOUS_EXPORT": "Stets aktuell halten",
"PENDING_ITEMS": "Ausstehende Dateien",
"EXPORT_STARTING": "Starte Export...",
"stop": "",
"sync_continuously": "Stets aktuell halten",
"export_starting": "Starte Export...",
"preparing": "Vorbereiten...",
"renaming_album_folders": "Albumordner umbenennen...",
"trashing_deleted_files": "Verschiebe gelöschte Dateien in den Trash-Ordner...",
"trashing_deleted_albums": "Verschiebe gelöschte Alben in den Trash-Ordner...",
"export_progress": "<a>{{progress.success, number}} / {{progress.total, number}}</a> Dateien synchronisiert",
"pending_items": "Ausstehende Dateien",
"delete_account_reason_label": "Was ist der Hauptgrund für die Löschung deines Kontos?",
"delete_account_reason_placeholder": "Wähle einen Grund aus",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "Lade {{name}} herunter",
"download_failed": "Herunterladen fehlgeschlagen",
"download_progress": "{{count, number}} / {{total, number}} Dateien",
"CHRISTMAS": "Weihnachten",
"CHRISTMAS_EVE": "Heiligabend",
"NEW_YEAR": "Neujahr",
"NEW_YEAR_EVE": "Silvester",
"christmas": "Weihnachten",
"christmas_eve": "Heiligabend",
"new_year": "Neujahr",
"new_year_eve": "Silvester",
"image": "Bild",
"video": "Video",
"live_photo": "Live-foto",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "Σήμερα",
"YESTERDAY": "Χθες",
"today": "Σήμερα",
"yesterday": "Χθες",
"enter_name": "Εισάγετε όνομα",
"uploader_name_hint": "Προσθέστε ένα όνομα, ώστε οι φίλοι σας να γνωρίζουν ποιον να ευχαριστήσουν για αυτές τις υπέροχες φωτογραφίες!",
"name_placeholder": "Όνομα...",
@@ -529,15 +529,15 @@
"gb": "GB",
"tb": "TB"
},
"STOP_EXPORT": "Διακοπή",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "Προετοιμασία...",
"RENAMING_COLLECTION_FOLDERS": "Μετονομασία φακέλων άλμπουμ...",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "Συνεχής συγχρονισμός",
"PENDING_ITEMS": "Εκκρεμή αντικείμενα",
"EXPORT_STARTING": "Έναρξη εξαγωγής...",
"stop": "Διακοπή",
"sync_continuously": "Συνεχής συγχρονισμός",
"export_starting": "Έναρξη εξαγωγής...",
"preparing": "Προετοιμασία...",
"renaming_album_folders": "Μετονομασία φακέλων άλμπουμ...",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "Εκκρεμή αντικείμενα",
"delete_account_reason_label": "Ποιος είναι ο κύριος λόγος που διαγράφετε το λογαριασμό σας;",
"delete_account_reason_placeholder": "Επιλέξτε ένα λόγο",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "Λήψη {{name}}",
"download_failed": "Η λήψη απέτυχε",
"download_progress": "{{count, number}} / {{total, number}} αρχεία",
"CHRISTMAS": "Χριστούγεννα",
"CHRISTMAS_EVE": "Παραμονή Χριστουγέννων",
"NEW_YEAR": "Πρωτοχρονιά",
"NEW_YEAR_EVE": "Παραμονή Πρωτοχρονιάς",
"christmas": "Χριστούγεννα",
"christmas_eve": "Παραμονή Χριστουγέννων",
"new_year": "Πρωτοχρονιά",
"new_year_eve": "Παραμονή Πρωτοχρονιάς",
"image": "Εικόνα",
"video": "Βίντεο",
"live_photo": "Ζωντανή φωτογραφία",

View File

@@ -490,8 +490,8 @@
"update_available_message": "A new version of Ente has been released, but it cannot be automatically downloaded and installed.",
"download_and_install": "Download and install",
"ignore_this_version": "Ignore this version",
"TODAY": "Today",
"YESTERDAY": "Yesterday",
"today": "Today",
"yesterday": "Yesterday",
"enter_name": "Enter name",
"uploader_name_hint": "Add a name so that your friends know who to thank for these great photos!",
"name_placeholder": "Name...",
@@ -529,15 +529,15 @@
"gb": "GB",
"tb": "TB"
},
"STOP_EXPORT": "Stop",
"EXPORT_PROGRESS": "<a>{{progress.success, number}} / {{progress.total, number}}</a> items synced",
"MIGRATING_EXPORT": "Preparing...",
"RENAMING_COLLECTION_FOLDERS": "Renaming album folders...",
"TRASHING_DELETED_FILES": "Trashing deleted files...",
"TRASHING_DELETED_COLLECTIONS": "Trashing deleted albums...",
"CONTINUOUS_EXPORT": "Sync continuously",
"PENDING_ITEMS": "Pending items",
"EXPORT_STARTING": "Export starting...",
"stop": "Stop",
"sync_continuously": "Sync continuously",
"export_starting": "Export starting...",
"preparing": "Preparing...",
"renaming_album_folders": "Renaming album folders...",
"trashing_deleted_files": "Trashing deleted files...",
"trashing_deleted_albums": "Trashing deleted albums...",
"export_progress": "<a>{{progress.success, number}} / {{progress.total, number}}</a> items synced",
"pending_items": "Pending items",
"delete_account_reason_label": "What is the main reason you are deleting your account?",
"delete_account_reason_placeholder": "Select a reason",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "Downloading {{name}}",
"download_failed": "Download failed",
"download_progress": "{{count, number}} / {{total, number}} files",
"CHRISTMAS": "Christmas",
"CHRISTMAS_EVE": "Christmas Eve",
"NEW_YEAR": "New Year",
"NEW_YEAR_EVE": "New Year's Eve",
"christmas": "Christmas",
"christmas_eve": "Christmas Eve",
"new_year": "New Year",
"new_year_eve": "New Year's Eve",
"image": "Image",
"video": "Video",
"live_photo": "Live photo",

View File

@@ -490,8 +490,8 @@
"update_available_message": "Una nueva versión de ente ha sido lanzada, pero no se puede descargar e instalar automáticamente.",
"download_and_install": "Descargar e instalar",
"ignore_this_version": "Ignorar esta versión",
"TODAY": "Hoy",
"YESTERDAY": "Ayer",
"today": "Hoy",
"yesterday": "Ayer",
"enter_name": "Introducir nombre",
"uploader_name_hint": "¡Añade un nombre para que tus amigos sepan a quién dar las gracias por estas fotos geniales!",
"name_placeholder": "Nombre...",
@@ -529,15 +529,15 @@
"gb": "GB",
"tb": "TB"
},
"STOP_EXPORT": "Stop",
"EXPORT_PROGRESS": "<a>{{progress.success}} / {{progress.total}}</a> archivos exportados",
"MIGRATING_EXPORT": "Preparando...",
"RENAMING_COLLECTION_FOLDERS": "Renombrando carpetas del álbum...",
"TRASHING_DELETED_FILES": "Eliminando ficheros borrados...",
"TRASHING_DELETED_COLLECTIONS": "Eliminando álbumes borrados...",
"CONTINUOUS_EXPORT": "Sincronizar continuamente",
"PENDING_ITEMS": "Elementos pendientes",
"EXPORT_STARTING": "Exportar iniciando...",
"stop": "",
"sync_continuously": "Sincronizar continuamente",
"export_starting": "Exportar iniciando...",
"preparing": "Preparando...",
"renaming_album_folders": "Renombrando carpetas del álbum...",
"trashing_deleted_files": "Eliminando ficheros borrados...",
"trashing_deleted_albums": "Eliminando álbumes borrados...",
"export_progress": "<a>{{progress.success}} / {{progress.total}}</a> archivos exportados",
"pending_items": "Elementos pendientes",
"delete_account_reason_label": "¿Cuál es la razón principal por la que eliminas tu cuenta?",
"delete_account_reason_placeholder": "Selecciona una razón",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "Descargando {{name}}",
"download_failed": "Error al descargar",
"download_progress": "{{count, number}} / {{total, number}} archivos",
"CHRISTMAS": "Navidad",
"CHRISTMAS_EVE": "Nochebuena",
"NEW_YEAR": "Año Nuevo",
"NEW_YEAR_EVE": "Nochevieja",
"christmas": "Navidad",
"christmas_eve": "Nochebuena",
"new_year": "Año Nuevo",
"new_year_eve": "Nochevieja",
"image": "Imagen",
"video": "Video",
"live_photo": "Foto en vivo",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "Lisää nimi",
"uploader_name_hint": "Lisää nimi, jotta ystäväsi tietävät, ketä kiittää näistä hienoista kuvista!",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "Une nouvelle version de Ente est sortie, mais elle ne peut pas être automatiquement téléchargée puis installée.",
"download_and_install": "Télécharger et installer",
"ignore_this_version": "Ignorer cette version",
"TODAY": "Aujourd'hui",
"YESTERDAY": "Hier",
"today": "Aujourd'hui",
"yesterday": "Hier",
"enter_name": "Saisir un nom",
"uploader_name_hint": "Ajouter un nom afin que vos amis sachent qui remercier pour ces magnifiques photos!",
"name_placeholder": "Nom...",
@@ -529,15 +529,15 @@
"gb": "Go",
"tb": "To"
},
"STOP_EXPORT": "Stop",
"EXPORT_PROGRESS": "<a>{{progress.success}} / {{progress.total}}</a> fichiers exportés",
"MIGRATING_EXPORT": "Préparations...",
"RENAMING_COLLECTION_FOLDERS": "Renommage des dossiers de l'album en cours...",
"TRASHING_DELETED_FILES": "Mise à la corbeille des fichiers supprimés...",
"TRASHING_DELETED_COLLECTIONS": "Mise à la corbeille des albums supprimés...",
"CONTINUOUS_EXPORT": "Synchronisation en continu",
"PENDING_ITEMS": "Objets en attente",
"EXPORT_STARTING": "Démarrage de l'export...",
"stop": "Arrêter",
"sync_continuously": "Synchronisation en continu",
"export_starting": "Démarrage de l'export...",
"preparing": "En cours de préparation...",
"renaming_album_folders": "Renommage des dossiers de l'album en cours...",
"trashing_deleted_files": "Mise à la corbeille des fichiers supprimés...",
"trashing_deleted_albums": "Mise à la corbeille des albums supprimés...",
"export_progress": "<a>{{progress.success}} / {{progress.total}}</a> fichiers exportés",
"pending_items": "Objets en attente",
"delete_account_reason_label": "Quelle est la raison principale de la suppression de votre compte ?",
"delete_account_reason_placeholder": "Choisir une raison",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "Téléchargement de {{name}}",
"download_failed": "Échec du téléchargement",
"download_progress": "{{count, number}} / {{total, number}} fichiers",
"CHRISTMAS": "Noël",
"CHRISTMAS_EVE": "Réveillon de Noël",
"NEW_YEAR": "Nouvel an",
"NEW_YEAR_EVE": "Réveillon de Nouvel An",
"christmas": "Noël",
"christmas_eve": "Réveillon de Noël",
"new_year": "Nouvel an",
"new_year_eve": "Réveillon du Nouvel An",
"image": "Image",
"video": "Vidéo",
"live_photo": "Photos en direct",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "Versi baru Ente telah dirilis, namun tidak dapat diunduh dan diinstal secara otomatis.",
"download_and_install": "Unduh dan instal",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "Kemarin",
"today": "",
"yesterday": "Kemarin",
"enter_name": "Masukkan nama",
"uploader_name_hint": "Tambahkan nama agar teman Anda tahu kepada siapa harus berterima kasih atas foto-foto hebat ini!",
"name_placeholder": "Nama...",
@@ -529,15 +529,15 @@
"gb": "GB",
"tb": "TB"
},
"STOP_EXPORT": "Hentikan",
"EXPORT_PROGRESS": "<a>{{progress.success, number}} / {{progress.total, number}}</a> item tersinkron",
"MIGRATING_EXPORT": "Menyiapkan...",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "Hentikan",
"sync_continuously": "",
"export_starting": "",
"preparing": "Menyiapkan...",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "<a>{{progress.success, number}} / {{progress.total, number}}</a> item tersinkron",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "Mengunduh {{name}}",
"download_failed": "Gagal mengunduh",
"download_progress": "{{count, number}} / {{total, number}} file",
"CHRISTMAS": "Natal",
"CHRISTMAS_EVE": "Malam Natal",
"NEW_YEAR": "Tahun Baru",
"NEW_YEAR_EVE": "Malam Tahun Baru",
"christmas": "Natal",
"christmas_eve": "Malam Natal",
"new_year": "Tahun Baru",
"new_year_eve": "Malam Tahun Baru",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "Nýtt ár",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "Nýtt ár",
"new_year_eve": "",
"image": "Mynd",
"video": "Mynband",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "Una nuova versione di Ente è stata rilasciata, ma non può essere scaricata e installata automaticamente.",
"download_and_install": "Scarica e installa",
"ignore_this_version": "Ignora questa versione",
"TODAY": "Oggi",
"YESTERDAY": "Ieri",
"today": "Oggi",
"yesterday": "Ieri",
"enter_name": "Inserisci il nome",
"uploader_name_hint": "Aggiungi un nome in modo che i tuoi amici sappiano chi ringraziare per queste fantastiche foto!",
"name_placeholder": "Nome...",
@@ -529,15 +529,15 @@
"gb": "GB",
"tb": "TB"
},
"STOP_EXPORT": "Stop",
"EXPORT_PROGRESS": "<a>{{progress.success, number}} / {{progress.total, number}}</a> elementi sincronizzati",
"MIGRATING_EXPORT": "Preparazione...",
"RENAMING_COLLECTION_FOLDERS": "Rinomino cartelle album...",
"TRASHING_DELETED_FILES": "Sposto i file nel cestino...",
"TRASHING_DELETED_COLLECTIONS": "Sposto gli album nel cestino...",
"CONTINUOUS_EXPORT": "Sincronizza continuamente",
"PENDING_ITEMS": "Elementi in sospeso",
"EXPORT_STARTING": "Esportazione iniziata...",
"stop": "",
"sync_continuously": "Sincronizza continuamente",
"export_starting": "Esportazione iniziata...",
"preparing": "Preparazione...",
"renaming_album_folders": "Rinomino cartelle album...",
"trashing_deleted_files": "Sposto i file nel cestino...",
"trashing_deleted_albums": "Sposto gli album nel cestino...",
"export_progress": "<a>{{progress.success, number}} / {{progress.total, number}}</a> elementi sincronizzati",
"pending_items": "Elementi in sospeso",
"delete_account_reason_label": "Qual è il motivo per cui stai cancellando il tuo account?",
"delete_account_reason_placeholder": "Seleziona un motivo",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "Download {{name}}",
"download_failed": "Download fallito",
"download_progress": "{{count, number}} / {{total, number}} file",
"CHRISTMAS": "Natale",
"CHRISTMAS_EVE": "Vigilia di Natale",
"NEW_YEAR": "Capodanno",
"NEW_YEAR_EVE": "Vigilia di Capodanno",
"christmas": "Natale",
"christmas_eve": "Vigilia di Natale",
"new_year": "Capodanno",
"new_year_eve": "Vigilia di Capodanno",
"image": "Immagine",
"video": "Video",
"live_photo": "",

View File

@@ -66,7 +66,7 @@
"5": "バックアップが完了しました"
},
"FILE_NOT_UPLOADED_LIST": "次のファイルはアップロードされませんでした",
"initial_load_delay_warning": "",
"initial_load_delay_warning": "最初の負荷に時間がかかる場合があります",
"no_account": "アカウントがありませんか",
"existing_account": "既にアカウントをお持ちの方",
"create": "作成",
@@ -87,8 +87,8 @@
"title_accounts": "Ente アカウント",
"upload_first_photo": "最初の写真をアップロード",
"import_your_folders": "フォルダをインポート",
"upload_dropzone_hint": "",
"watch_folder_dropzone_hint": "",
"upload_dropzone_hint": "ファイルをバックアップするにはドロップします",
"watch_folder_dropzone_hint": "ドロップして監視フォルダを追加",
"trash_files_title": "ファイルを削除しますか?",
"trash_file_title": "ファイルを削除しますか?",
"delete_files_title": "すぐに削除しますか?",
@@ -99,7 +99,7 @@
"delete_key": "削除 (DEL)",
"favorite": "お気に入り",
"favorite_key": "お気に入り (L)",
"unfavorite_key": "",
"unfavorite_key": "Unfavorite (L)",
"convert": "変換する",
"multi_folder_upload": "複数のフォルダが検出されました",
"upload_to_choice": "アップロードしますか",
@@ -126,7 +126,7 @@
"incorrect_recovery_key": "リカバリーキーが正しくありません",
"sorry": "申し訳ありません",
"no_recovery_key_message": "エンドツーエンドの暗号化プロトコルの性質上、あなたのデータはパスワードやリカバリキーなしで復号できません",
"no_two_factor_recovery_key_message": "",
"no_two_factor_recovery_key_message": "あなたの登録したメールアドレスから<a>{{emailID}}</a> にメールをお送りください",
"contact_support": "お問い合わせ",
"help": "Help",
"ente_help": "Ente Help",
@@ -137,14 +137,14 @@
"logout": "ログアウト",
"logout_message": "本当にログアウトしてよろしいですか?",
"delete_account": "アカウント削除",
"delete_account_manually_message": "",
"delete_account_manually_message": "<p>ご登録のメールアドレスから <a>{{emailID}}</a> にメールを送信してください。</p><p>あなたのリクエストは72時間以内に処理されます。</p>",
"change_email": "メールアドレスを変更",
"ok": "OK",
"success": "成功",
"error": "エラー",
"offline_message": "オフラインです。キャッシュされた内容が表示されています",
"install": "インストール",
"install_mobile_app": "",
"install_mobile_app": "<a>Android</a> または <b>iOS</b> アプリをインストールして、すべての写真を自動的にバックアップします",
"download_app": "デスクトップアプリをダウンロード",
"download_app_message": "申し訳ありませんが、この操作は現在デスクトップアプリでのみサポートされています",
"subscription": "サブスクリプション",
@@ -152,26 +152,26 @@
"manage_family": "ファミリー管理",
"family_plan": "ファミリープラン",
"leave_family_plan": "ファミリープランから退会",
"leave": "",
"leave": "離脱",
"leave_family_plan_confirm": "本当にファミリープランを退会しますか?",
"choose_plan": "プランを選択",
"manage_plan": "サブスクリプションの管理",
"current_usage": "現在の使用量は <strong>{{usage}}</strong>",
"two_months_free": "",
"two_months_free": "年次プランでは2ヶ月分無料",
"free_plan_option": "無料プランを継続する",
"free_plan_description": "{{storage}} は永久に無料です",
"active": "アクティブ",
"subscription_info_free": "無料プランをご利用いただいています",
"subscription_info_family": "あなたはファミリープランを利用しています:",
"subscription_info_expired": "",
"subscription_info_expired": "サブスクリプションの有効期限が切れています。<a>更新</a>してください",
"subscription_info_renewal_cancelled": "サブスクリプションは{{date, date}}でキャンセルされます",
"subscription_info_storage_quota_exceeded": "",
"subscription_info_storage_quota_exceeded": "ストレージの上限を超えました。<a>アップグレードしてください。</a>",
"subscription_status_renewal_active": "{{date, date}}更新",
"subscription_status_renewal_cancelled": "{{date, date}}終了",
"add_on_valid_till": "あなたの {{storage}} アドオンは {{date, date}} まで有効です",
"subscription_expired": "サブスクリプションが期限切れです",
"storage_quota_exceeded": "ストレージの上限に達しました",
"subscription_purchase_success": "",
"subscription_purchase_success": "<p>お支払いを確認しました。</p> <p>あなたのサブスクリプションは <strong>{{date, date}}</strong> まで有効です。</p>",
"subscription_purchase_cancelled": "購入がキャンセルされました。購読する場合は、もう一度お試しください",
"subscription_purchase_failed": "サブスクリプションの購入に失敗しました。もう一度お試しください",
"subscription_verification_error": "サブスクリプションの検証に失敗しました",
@@ -186,9 +186,9 @@
"update_subscription_title": "プラン変更を確認",
"update_subscription_message": "プランを変更して良いですか?",
"cancel_subscription": "サブスクリプションをキャンセル",
"cancel_subscription_message": "",
"cancel_subscription_with_addon_message": "",
"subscription_cancel_success": "",
"cancel_subscription_message": "<p>請求期間の終了時に、お客様のデータはすべて弊社のサーバーから削除されます。 <p></p><p>サブスクリプションをキャンセルしてもよろしいですか?</p>",
"cancel_subscription_with_addon_message": "<p>サブスクリプションを解約しますか?</p>",
"subscription_cancel_success": "サブスクリプションをキャンセルしました",
"reactivate_subscription": "サブスクリプションを再有効化",
"reactivate_subscription_message": "再有効化すると、{{date, date}} に請求されます",
"subscription_activate_success": "サブスクリプションが正常に有効になりました ",
@@ -201,7 +201,7 @@
"rename_album": "アルバム名を変更",
"delete_album": "アルバムを削除",
"delete_album_title": "アルバムを削除しますか?",
"delete_album_message": "",
"delete_album_message": "このアルバムに含まれている写真 (およびビデオ) を <a>すべて</a> 他のアルバムからも削除しますか?",
"delete_photos": "写真を削除",
"keep_photos": "写真を残す",
"share_album": "アルバムを共有",
@@ -219,27 +219,27 @@
"description": "説明",
"file_type": "ファイルの種類",
"magic": "マジック",
"photos_count_zero": "",
"photos_count_one": "",
"photos_count": "",
"photos_count_zero": "0枚",
"photos_count_one": "1枚",
"photos_count": "{{count, number}}枚",
"terms_and_conditions": "<a>規約</a> および <b>プライバシー ポリシー</b> に同意します",
"people": "",
"indexing_scheduled": "",
"indexing_photos": "",
"indexing_fetching": "",
"indexing_people": "",
"syncing_wait": "",
"people_empty_too_few": "",
"unnamed_person": "",
"add_a_name": "",
"people": "人物",
"indexing_scheduled": "インデックス作成がスケジュールされています…",
"indexing_photos": "インデックスを更新しています…",
"indexing_fetching": "インデックスを同期しています…",
"indexing_people": "人を同期しています…",
"syncing_wait": "同期中…",
"people_empty_too_few": "人物の写真が十分にある場合、ここに人物が表示されます",
"unnamed_person": "未登録の人物",
"add_a_name": "名前を追加",
"new_person": "新しい人物",
"add_name": "名前を追加する",
"rename_person": "人物名を変更する",
"reset_person_confirm": "",
"reset_person_confirm_message": "",
"reset_person_confirm": "リセットしますか?",
"reset_person_confirm_message": "この人の名前、顔のグループ化、提案はリセットされます",
"ignore": "無視する",
"ignore_person_confirm": "",
"ignore_person_confirm_message": "",
"ignore_person_confirm": "無視しますか?",
"ignore_person_confirm_message": "この顔のグループ化は人リストには表示されません",
"ignored": "無視",
"show_person": "人物を表示",
"review_suggestions": "提案を確認",
@@ -257,17 +257,17 @@
"map": "マップ",
"enable_map": "マップを有効にする",
"enable_maps_confirm": "マップを有効にしますか?",
"enable_maps_confirm_message": "",
"enable_maps_confirm_message": "世界地図上にあなたの写真を表示します。\n\n地図はOpenStreetMapを利用しており、あなたの写真の位置情報が外部に共有されることはありません。\n\nこの機能は設定から無効にすることができます",
"disable_map": "マップを無効にする",
"disable_maps_confirm": "マップを無効にしますか?",
"disable_maps_confirm_message": "",
"disable_maps_confirm_message": "<p>ワールドマップでの写真の表示を無効にします。</p><p>設定からいつでもこの機能を有効にできます。</p>",
"details": "詳細",
"view_exif": "すべての Exif データを表示",
"no_exif": "Exifデータがありません",
"exif": "Exif",
"two_factor": "二要素",
"two_factor_authentication": "二要素認証",
"two_factor_qr_help": "",
"two_factor_qr_help": "以下のQRコードをお気に入りの認証アプリでスキャンしてください",
"two_factor_manual_entry_title": "コードを手動で入力",
"two_factor_manual_entry_message": "このコードをお気に入りの認証アプリに入力してください",
"scan_qr_title": "代わりにQRコードをスキャン",
@@ -276,44 +276,44 @@
"enabled": "有効",
"lost_2fa_device": "紛失した二要素デバイス",
"incorrect_code": "不正なコード",
"two_factor_info": "",
"two_factor_info": "セキュリティ強化のため、メールアドレスとパスワードに加えて追加の認証層を設定します",
"disable": "無効化",
"reconfigure": "再設定",
"reconfigure_two_factor_hint": "認証デバイスを更新する",
"update_two_factor": "二要素認証の更新",
"update_two_factor_message": "",
"update_two_factor_message": "転送を続行すると、以前に設定された認証情報が無効になります",
"update": "更新",
"disable_two_factor": "二要素認証の無効化",
"disable_two_factor_message": "",
"disable_two_factor_message": "2要素認証を無効にしてもよろしいですか",
"export_data": "データをエクスポート",
"select_folder": "フォルダを選択",
"select_zips": "",
"select_zips": "Zipを選択",
"faq": "FAQ",
"takeout_hint": "",
"takeout_hint": "すべてのzipを一つのフォルダに展開してアップロードします。または直接zipをアップロードします。詳細についてはFAQを参照してください。",
"destination": "宛先",
"start": "スタート",
"last_export_time": "",
"last_export_time": "最終エクスポート日時",
"export_again": "再同期",
"LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "ブラウザまたはアドオンがEnteのローカルストレージへのデータ保存をブロックしています",
"email_already_taken": "メールアドレスはすでに使用されています",
"ETAGS_BLOCKED": "",
"LIVE_PHOTOS_DETECTED": "",
"RETRY_FAILED": "",
"ETAGS_BLOCKED": "ブラウザまたはアドオンが <code>eTags</code> を使用して大きなファイルをアップロードすることを妨げています。",
"LIVE_PHOTOS_DETECTED": "Live Photos の写真とビデオファイルが1つのファイルに統合されました",
"RETRY_FAILED": "失敗したアップロードを再試行する",
"FAILED_UPLOADS": "アップロード失敗 ",
"failed_uploads_hint": "",
"SKIPPED_FILES": "",
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "",
"UNSUPPORTED_FILES": "",
"SUCCESSFUL_UPLOADS": "",
"SKIPPED_INFO": "",
"failed_uploads_hint": "アップロードが完了すると、これらを再試行するオプションがあります",
"SKIPPED_FILES": "無視されたアップロード",
"THUMBNAIL_GENERATION_FAILED_UPLOADS": "サムネイルの生成に失敗しました",
"UNSUPPORTED_FILES": "サポートされないファイル",
"SUCCESSFUL_UPLOADS": "アップロード成功",
"SKIPPED_INFO": "同じアルバムに一致する名前と内容を持つファイルがあるため、これらをスキップしました",
"UNSUPPORTED_INFO": "Enteはこれらのファイル形式をサポートしていません",
"BLOCKED_UPLOADS": "",
"INPROGRESS_UPLOADS": "",
"TOO_LARGE_UPLOADS": "",
"LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "",
"LARGER_THAN_AVAILABLE_STORAGE_INFO": "",
"TOO_LARGE_INFO": "",
"THUMBNAIL_GENERATION_FAILED_INFO": "",
"BLOCKED_UPLOADS": "アップロードがブロックされました",
"INPROGRESS_UPLOADS": "アップロード進行中",
"TOO_LARGE_UPLOADS": "ファイルサイズが大きい",
"LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "ストレージ容量が足りません",
"LARGER_THAN_AVAILABLE_STORAGE_INFO": "ストレージプランの最大サイズ制限を超えているため、これらのファイルはアップロードされませんでした",
"TOO_LARGE_INFO": "ファイルサイズの上限を超えているため、これらのファイルはアップロードされませんでした",
"THUMBNAIL_GENERATION_FAILED_INFO": "これらのファイルはアップロードされましたが、残念ながらサムネイルを生成できませんでした。",
"upload_to_album": "アルバムにアップロード",
"add_to_album": "アルバムに追加",
"move_to_album": "アルバムに移動",
@@ -344,66 +344,66 @@
"empty_trash": "ごみ箱を空にする",
"empty_trash_title": "ごみ箱を空にしますか?",
"empty_trash_message": "これらのファイルはEnteアカウントから完全に削除されます。",
"leave_album": "",
"leave_shared_album_title": "",
"leave_shared_album_message": "",
"leave_shared_album": "",
"confirm_remove_message": "",
"confirm_remove_incl_others_message": "",
"oldest": "",
"last_updated": "",
"name": "",
"fix_creation_time": "",
"fix_creation_time_in_progress": "",
"fix_creation_time_file_updated": "",
"fix_creation_time_completed": "",
"fix_creation_time_completed_with_errors": "",
"fix_creation_time_options": "",
"exif_date_time_original": "",
"exif_date_time_digitized": "",
"leave_album": "アルバムを抜ける",
"leave_shared_album_title": "共有アルバムを抜けてよいですか?",
"leave_shared_album_message": "アルバムから抜けると、このアルバムは見えなくなります。",
"leave_shared_album": "はい、抜けます",
"confirm_remove_message": "選択したアイテムはこのアルバムから削除されます。このアルバムにのみ含まれるアイテムは未分類に移動されます。",
"confirm_remove_incl_others_message": "削除したアイテムのいくつかは他の人によって追加されました。あなたはそれらにアクセスできなくなります。",
"oldest": "古い順",
"last_updated": "更新日順",
"name": "名前順",
"fix_creation_time": "時刻を修正",
"fix_creation_time_in_progress": "時刻を修正中",
"fix_creation_time_file_updated": "ファイルの時刻が更新されました",
"fix_creation_time_completed": "すべてのファイルが正常に更新されました",
"fix_creation_time_completed_with_errors": "ファイルの更新に失敗しました。もう一度やり直してください",
"fix_creation_time_options": "使用するオプションを選択してください",
"exif_date_time_original": "Exif:DateTimeOriginal",
"exif_date_time_digitized": "Exif:DateTimeDigitized",
"exif_metadata_date": "Exif:メタデータ日付",
"custom_time": "",
"custom_time": "カスタムタイム",
"caption_character_limit": "最大5000文字",
"sharing_details": "共有の詳細",
"modify_sharing": "共有を変更",
"add_collaborators": "",
"add_new_email": "",
"shared_with_people_count_zero": "",
"shared_with_people_count_one": "",
"shared_with_people_count": "",
"participants_count_zero": "",
"participants_count_one": "",
"participants_count": "",
"add_viewers": "",
"change_permission_to_viewer": "",
"change_permission_to_collaborator": "",
"change_permission_title": "",
"confirm_convert_to_viewer": "",
"confirm_convert_to_collaborator": "",
"manage": "",
"added_as": "",
"collaborator_hint": "",
"remove_participant": "",
"remove_participant_title": "",
"remove_participant_message": "",
"confirm_remove": "",
"add_collaborators": "コラボレーターを追加",
"add_new_email": "新しいメールアドレスを追加",
"shared_with_people_count_zero": "特定の人と共有",
"shared_with_people_count_one": "1人と共有",
"shared_with_people_count": "{{count, number}} 人と共有",
"participants_count_zero": "参加者がいません",
"participants_count_one": "1人の参加者",
"participants_count": "{{count, number}} 人の参加者",
"add_viewers": "ビューアーを追加",
"change_permission_to_viewer": "{{selectedEmail}} は写真をアルバムに追加できなくなります※{user} が追加した写真は今後も{user} が削除できます",
"change_permission_to_collaborator": "{{selectedEmail}} はアルバムに写真を追加できるようになります",
"change_permission_title": "権限を変更しますか?",
"confirm_convert_to_viewer": "はい、ビューアに変換します",
"confirm_convert_to_collaborator": "はい、共同編集者に変換します",
"manage": "管理",
"added_as": "追加:",
"collaborator_hint": "コラボレーターは共有アルバムに写真やビデオを追加できます",
"remove_participant": "参加者を削除",
"remove_participant_title": "削除しますか?",
"remove_participant_message": "<p>{{selectedEmail}} はアルバムから削除されます。</p> <p>また、そのユーザーが追加した写真もアルバムから削除されます。</p>",
"confirm_remove": "はい、削除します",
"owner": "オーナー",
"collaborators": "コラボレーター",
"viewers": "ビューアー",
"add_more": "さらに追加",
"or_add_existing": "",
"or_add_existing": "または既存のものを選択",
"NOT_FOUND": "404 - 見つかりません",
"link_expired": "",
"link_expired_message": "",
"manage_link": "",
"link_request_limit_exceeded": "",
"link_expired": "リンクは有効期限切れです",
"link_expired_message": "このリンクは有効期限が切れているか無効になっています",
"manage_link": "リンクを管理",
"link_request_limit_exceeded": "このアルバムはあまりにも多くの端末で閲覧されています",
"allow_downloads": "ダウンロードを許可",
"allow_adding_photos": "",
"allow_adding_photos_hint": "",
"device_limit": "",
"none": "",
"link_expiry": "",
"never": "",
"allow_adding_photos": "写真の追加を許可",
"allow_adding_photos_hint": "リンクを持つ人が共有アルバムに写真を追加できるようにします。",
"device_limit": "デバイスの制限",
"none": "なし",
"link_expiry": "リンクの期限切れ",
"never": "なし",
"after_time": {
"hour": "1時間後",
"day": "1日後",
@@ -420,8 +420,8 @@
"public_link_enabled": "公開リンクが有効です",
"collect_photos": "写真を収集する",
"disable_file_download": "ダウンロードを無効化",
"disable_file_download_message": "",
"shared_using": "",
"disable_file_download_message": "<p>ファイルのダウンロードボタンを無効化しますか?</p> <p>閲覧者は、スクリーンショットを撮ったり、外部ツールを使用して写真を保存したりすることが可能です。</p>",
"shared_using": "<a>{{url}}</a> を使用して共有",
"sharing_referral_code": "コード <strong>{{referralCode}}</strong> を使用して、10 GB の空き領域を入手",
"live_photo_indicator": "LIVE",
"disable_password": "パスワードロックを無効化する",
@@ -430,17 +430,17 @@
"lock": "ロック",
"file": "ファイル",
"folder": "フォルダ",
"google_takeout": "",
"deduplicate_files": "",
"remove_duplicates": "",
"total_size": "",
"count": "",
"deselect_all": "",
"no_duplicates": "",
"duplicate_group_description": "",
"remove_duplicates_button_count": "",
"stop_uploads_title": "",
"stop_uploads_message": "",
"google_takeout": "Google から取り出し",
"deduplicate_files": "重複ファイル",
"remove_duplicates": "重複を削除",
"total_size": "合計サイズ",
"count": "カウント",
"deselect_all": "選択解除",
"no_duplicates": "重複はありません",
"duplicate_group_description": "{{count}} 個のアイテム、 {{itemSize}}",
"remove_duplicates_button_count": "{{count, number}} 件を削除",
"stop_uploads_title": "アップロードを中止しますか?",
"stop_uploads_message": "進行中のすべてのアップロードを中止してもよろしいですか?",
"yes_stop_uploads": "はい、アップロードを中止します",
"stop_downloads_title": "ダウンロードを中止しますか?",
"stop_downloads_message": "進行中のすべてのダウンロードを停止してもよろしいですか?",
@@ -463,44 +463,44 @@
"family": "ファミリー",
"free": "free",
"of": "of",
"watch_folders": "",
"watched_folders": "",
"no_folders_added": "",
"watch_folders_hint_1": "",
"watch_folders_hint_2": "",
"watch_folders_hint_3": "",
"watch_folders": "監視フォルダ",
"watched_folders": "監視中のフォルダ",
"no_folders_added": "フォルダはまだ追加されていません",
"watch_folders_hint_1": "ここで追加したフォルダは自動的に監視されます",
"watch_folders_hint_2": "Ente に新しいファイルをアップロード",
"watch_folders_hint_3": "削除されたファイルをEnteから削除",
"add_folder": "フォルダを追加",
"stop_watching": "ウォッチを停止",
"stop_watching_folder_title": "",
"stop_watching_folder_message": "",
"stop_watching_folder_title": "フォルダの監視を停止しますか?",
"stop_watching_folder_message": "既存のファイルは削除されませんが、Enteはこのフォルダ変更にリンクしたEnteアルバムへの自動更新を停止します。",
"yes_stop": "はい、停止します",
"change_folder": "フォルダを変更",
"view_logs": "",
"view_logs_message": "",
"weak_device_hint": "",
"drag_and_drop_hint": "",
"view_logs": "ログを表示",
"view_logs_message": "<p>デバッグログが表示されますので、問題を解決するためにメールでお問い合わせください。 <p></p><p>問題の特定追跡のため、ファイル名が含まれることに注意してください。</p>",
"weak_device_hint": "使用しているWebブラウザは、写真を暗号化するのに十分な強力ではありません。 パソコンでEnteにログインするか、Enteモバイル/デスクトップアプリをダウンロードしてください。",
"drag_and_drop_hint": "または、Ente ウィンドウにドラッグ&ドロップします",
"authenticate": "認証",
"uploaded_to_single_collection": "",
"uploaded_to_separate_collections": "",
"nevermind": "",
"uploaded_to_single_collection": "1つのコレクションにアップロードしました",
"uploaded_to_separate_collections": "別々のコレクションにアップロードしました",
"nevermind": "気にしない",
"update_available": "アップデートがあります",
"update_installable_message": "Enteの新しいバージョンをインストールする準備ができました。",
"install_now": "今すぐインストール",
"install_on_next_launch": "次回起動時にインストール",
"update_available_message": "",
"update_available_message": "Enteの新しいバージョンがリリースされましたが、自動的にダウンロードおよびインストールすることはできません。",
"download_and_install": "ダウンロードしてインストール",
"ignore_this_version": "このバージョンを無視する",
"TODAY": "今日",
"YESTERDAY": "昨日",
"today": "今日",
"yesterday": "昨日",
"enter_name": "名前を入力してください",
"uploader_name_hint": "友達がこの素晴らしい写真に感謝する相手を知るために、名前を追加してください!",
"name_placeholder": "名前…",
"root_level_file_with_folder_not_allowed": "",
"root_level_file_with_folder_not_allowed_message": "",
"root_level_file_with_folder_not_allowed": "ファイル/フォルダのミックスからアルバムを作成できません",
"root_level_file_with_folder_not_allowed_message": "<p>ファイルとフォルダが混在した状態でドラッグ&ドロップされました。</p> <p>別々のアルバムを作成するオプションを選択する場合は、ファイルのみ、またはフォルダのみを選択してください。</p>",
"more_details": "さらに詳細を表示",
"ml_search": "機械学習",
"ml_search_description": "",
"ml_search_footnote": "",
"ml_search_description": "Enteは顔認識、マジック検索、その他の高度な検索機能のため、あなたのデバイス上で機械学習をしています",
"ml_search_footnote": "マジック検索では、「車」、「赤い車」、「フェラーリ」などの写真に写っている内容で写真を検索できます",
"indexing": "インデックス中",
"processed": "処理完了",
"indexing_status_running": "実行中",
@@ -511,8 +511,8 @@
"ml_search_disable_confirm": "すべてのデバイスで機械学習を無効にしますか?",
"ml_consent": "機械学習を有効にする",
"ml_consent_title": "機械学習を有効にしますか?",
"ml_consent_description": "",
"ml_consent_confirmation": "",
"ml_consent_description": "<p>機械学習を有効にすると、Ente はファイル(共有されたものを含む)から顔の形状などの情報を抽出します。</p> <p>これはお使いのデバイス上で行われ、生成された生体情報はエンドツーエンドで暗号化されます。</p> <p><a>この機能の詳細については、プライバシーポリシーをご覧ください</a></p>",
"ml_consent_confirmation": "理解して、機械学習を有効にします",
"labs": "Labs",
"passphrase_strength_weak": "パスワード強度: 弱い",
"passphrase_strength_moderate": "パスワード強度: 普通",
@@ -521,7 +521,7 @@
"language": "言語",
"advanced": "アドバンスド",
"export_directory_does_not_exist": "無効なエクスポートディレクトリ",
"export_directory_does_not_exist_message": "",
"export_directory_does_not_exist_message": "<p>選択したエクスポートディレクトリは存在しません。</p><p>有効なディレクトリを選択してください。</p>",
"storage_unit": {
"b": "B",
"kb": "KB",
@@ -529,15 +529,15 @@
"gb": "GB",
"tb": "TB"
},
"STOP_EXPORT": "中止",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "準備しています…",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "削除ファイルをごみ箱に移動中…",
"TRASHING_DELETED_COLLECTIONS": "削除アルバムをごみ箱に移動中…",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "保留中アイテム",
"EXPORT_STARTING": "エクスポートを開始中…",
"stop": "中止",
"sync_continuously": "継続的に同期",
"export_starting": "エクスポートを開始中…",
"preparing": "準備しています…",
"renaming_album_folders": "アルバムフォルダの名前を変更しています…",
"trashing_deleted_files": "削除ファイルをごみ箱に移動中…",
"trashing_deleted_albums": "削除アルバムをごみ箱に移動中…",
"export_progress": "<a>{{progress.success, number}} / {{progress.total, number}}</a> アイテムが同期されました",
"pending_items": "保留中アイテム",
"delete_account_reason_label": "アカウントを削除する主な理由は何ですか?",
"delete_account_reason_placeholder": "理由を選択",
"delete_reason": {
@@ -551,13 +551,13 @@
"delete_account_confirm_checkbox_label": "はい、アカウントとすべてのアプリのデータを削除します",
"delete_account_confirm": "アカウントの削除に同意",
"delete_account_confirm_message": "<p>このアカウントは他のEnteアプリも使用している場合はそれらにも紐づけされています。</p>\n<p>すべてのEnteアプリでアップロードされたデータは削除され、アカウントは完全に削除されます。</p>",
"feedback_required": "",
"feedback_required_found_another_service": "",
"recover_two_factor": "",
"at": "",
"auth_next": "",
"auth_download_mobile_app": "",
"no_codes_added_yet": "",
"feedback_required": "よければ、情報をお寄せください",
"feedback_required_found_another_service": "他のサービスの方が良いのは何ですか?",
"recover_two_factor": "2要素認証を回復",
"at": "at",
"auth_next": "次へ",
"auth_download_mobile_app": "シークレットを管理するには、モバイルアプリをダウンロードしてください",
"no_codes_added_yet": "コードはまだ追加されていません",
"hide": "非表示",
"unhide": "再表示",
"sort_by": "Sort by",
@@ -565,21 +565,21 @@
"oldest_first": "古い順",
"pin_album": "アルバムをピン留め",
"unpin_album": "アルバムのピン留めを解除",
"unpreviewable_file_notification": "",
"unpreviewable_file_notification": "このファイルはプレビューできませんでした。オリジナルをダウンロードするにはここをクリックしてください。",
"download_complete": "ダウンロード完了",
"downloading_album": "{{name}} をダウンロード中",
"download_failed": "ダウンロードに失敗しました",
"download_progress": "{{count, number}} / {{total, number}}ファイル",
"CHRISTMAS": "クリスマス",
"CHRISTMAS_EVE": "クリスマスイブ",
"NEW_YEAR": "新年",
"NEW_YEAR_EVE": "大晦日",
"christmas": "クリスマス",
"christmas_eve": "クリスマスイブ",
"new_year": "新年",
"new_year_eve": "大晦日",
"image": "Image",
"video": "Video",
"live_photo": "",
"live_photo": "ライブフォト",
"photo_editor": "フォトエディター",
"confirm_editor_close": "",
"confirm_editor_close_message": "",
"confirm_editor_close": "エディタを閉じてもよろしいですか?",
"confirm_editor_close_message": "編集した画像をダウンロードするか、コピーをEnteに保存して変更を保持します。",
"brightness": "明るさ",
"contrast": "コントラスト",
"saturation": "彩度",
@@ -603,59 +603,59 @@
"colors": "色",
"invert_colors": "色を反転",
"reset": "リセット",
"faster_upload": "",
"faster_upload_description": "",
"open_ente_on_startup": "",
"cast_album_to_tv": "",
"enter_cast_pin_code": "",
"faster_upload": "アップロードを高速化",
"faster_upload_description": "近くのサーバー経由でのアップロード",
"open_ente_on_startup": "開始時に Ente を開く",
"cast_album_to_tv": "TVでアルバムを再生",
"enter_cast_pin_code": "このデバイスをペアリングするには、以下のテレビで表示されているコードを入力してください。",
"code": "コード",
"pair_device_to_tv": "デバイスを接続する",
"tv_not_found": "",
"cast_auto_pair": "",
"cast_auto_pair_description": "",
"choose_device_from_browser": "",
"cast_auto_pair_failed": "",
"pair_with_pin": "",
"pair_with_pin_description": "",
"visit_cast_url": "",
"tv_not_found": "TVが見つかりませんでした。入力したPINは正しいですか",
"cast_auto_pair": "オートペアリング",
"cast_auto_pair_description": "自動ペアリングは Chromecast をサポートするデバイスでのみ動作します。",
"choose_device_from_browser": "ブラウザポップアップからキャスト対応のデバイスを選択します。",
"cast_auto_pair_failed": "Chromecast の自動ペアリングに失敗しました。もう一度やり直してください。",
"pair_with_pin": "PINを使ってペアリングする",
"pair_with_pin_description": "PINを使ってペアリングすると、どんなスクリーンで動作します。",
"visit_cast_url": "ペアリングしたいデバイスで <a>{{url}}</a> にアクセスしてください。",
"passkeys": "パスキー",
"passkey_fetch_failed": "",
"passkey_fetch_failed": "パスキーを取得できません。",
"manage_passkey": "パスキーの管理",
"delete_passkey": "パスキーを削除",
"delete_passkey_confirmation": "このパスキーを削除してもよろしいですか?この操作は元に戻せません。",
"rename_passkey": "パスキーの名前を変更",
"add_passkey": "パスキーを追加",
"enter_passkey_name": "パスキーの名前を入力",
"passkeys_description": "",
"passkeys_description": "パスキーはEnteアカウントの最新かつ安全な第二要素です。利便性とセキュリティのためにデバイス上の生体認証を使用します。",
"created_at": "作成日",
"passkey_add_failed": "パスキーを追加できませんでした",
"passkey_login_failed": "パスキーログインに失敗しました",
"passkey_login_invalid_url": "ログインURLが不正です。",
"passkey_login_already_claimed_session": "",
"passkey_login_already_claimed_session": "このセッションはすでに検証されています。",
"passkey_login_generic_error": "パスキーでログイン中にエラーが発生しました。",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"passkey_login_credential_hint": "パスキーが別のデバイスにある場合は、そのデバイスでこのページを開いて検証できます。",
"passkeys_not_supported": "このブラウザではパスキーはサポートされていません",
"try_again": "再試行",
"check_status": "ステータスの確認",
"passkey_login_instructions": "",
"passkey_login": "",
"totp_login": "",
"passkey_login_instructions": "ログインを続けるには、ブラウザの手順に従ってください。",
"passkey_login": "パスキーでログイン",
"totp_login": "TOTPでログイン",
"passkey": "パスキー",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",
"redirect_again": "",
"passkey_verify_description": "アカウントにログインするにはパスキーを確認してください。",
"waiting_for_verification": "検証を待っています…",
"verification_still_pending": "検証はまだ保留中です",
"passkey_verified": "パスキーが確認されました",
"redirecting_back_to_app": "アプリにリダイレクト中...",
"redirect_close_instructions": "アプリを開いた後、このウィンドウを閉じることができます。",
"redirect_again": "再度リダイレクトする",
"autogenerated_first_album_name": "最初のアルバム",
"autogenerated_default_album_name": "新しいアルバム",
"developer_settings": "開発者向け設定",
"server_endpoint": "サーバーエンドポイント",
"more_information": "詳細情報",
"save": "保存",
"theme": "",
"system": "",
"light": "",
"dark": ""
"theme": "テーマ",
"system": "システム",
"light": "ライト",
"dark": "ダーク"
}

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "이름 입력",
"uploader_name_hint": "친구들이 이 멋진 사진에 대해 고마워할 수 있도록 이름을 추가하세요!",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

View File

@@ -490,8 +490,8 @@
"update_available_message": "Išleista nauja „Ente“ versija, bet jos negalima automatiškai atsisiųsti ir įdiegti.",
"download_and_install": "Atsisiųsti ir įdiegti",
"ignore_this_version": "Ignoruoti šią versiją",
"TODAY": "Šiandien",
"YESTERDAY": "Vakar",
"today": "Šiandien",
"yesterday": "Vakar",
"enter_name": "Įveskite vardą",
"uploader_name_hint": "Pridėkite vardą, kad draugai žinotų, kam padėkoti už šias puikias nuotraukas.",
"name_placeholder": "Pavadinimas...",
@@ -529,15 +529,15 @@
"gb": "GB",
"tb": "TB"
},
"STOP_EXPORT": "Stabdyti",
"EXPORT_PROGRESS": "<a>{{progress.success, number}} / {{progress.total, number}}</a> sinchronizuoti elementai",
"MIGRATING_EXPORT": "Ruošiama...",
"RENAMING_COLLECTION_FOLDERS": "Pervadinami albumų aplankai...",
"TRASHING_DELETED_FILES": "Ištuštiniami ištrinti failai...",
"TRASHING_DELETED_COLLECTIONS": "Ištuštiniami ištrinti albumai...",
"CONTINUOUS_EXPORT": "Sinchronizuoti nuolat",
"PENDING_ITEMS": "Laukiami elementai",
"EXPORT_STARTING": "Pradedamas eksportavimas...",
"stop": "Stabdyti",
"sync_continuously": "Sinchronizuoti nuolat",
"export_starting": "Pradedamas eksportavimas...",
"preparing": "Ruošiama...",
"renaming_album_folders": "Pervadinami albumų aplankai...",
"trashing_deleted_files": "Ištuštiniami ištrinti failai...",
"trashing_deleted_albums": "Ištuštiniami ištrinti albumai...",
"export_progress": "<a>{{progress.success, number}} / {{progress.total, number}}</a> sinchronizuoti elementai",
"pending_items": "Laukiami elementai",
"delete_account_reason_label": "Kokia yra pagrindinė priežastis, dėl kurios ištrinate savo paskyrą?",
"delete_account_reason_placeholder": "Pasirinkite priežastį",
"delete_reason": {
@@ -553,10 +553,10 @@
"delete_account_confirm_message": "<p>Ši paskyra susieta su kitomis „Ente“ programomis, jei jas naudojate.</p><p>Jūsų įkelti duomenys per visas „Ente“ programas bus planuojama ištrinti, o jūsų paskyra bus ištrinta negrįžtamai.</p>",
"feedback_required": "Maloniai padėkite mums su šia informacija",
"feedback_required_found_another_service": "Ką kita paslauga daro geriau?",
"recover_two_factor": "Atkurkite dvigubą tapatybės nustatymą",
"recover_two_factor": "Atkurti dvigubą tapatybės nustatymą",
"at": " ",
"auth_next": "sekantis",
"auth_download_mobile_app": "Atsisiųskite mūsų mobiliąją programą ir tvarkykite savo paslaptis",
"auth_download_mobile_app": "Atsisiųskite mūsų mobiliąją programą, kad tvarkytumėte savo paslaptis.",
"no_codes_added_yet": "Kol kas nėra pridėtų kodų.",
"hide": "Slėpti",
"unhide": "Neslėpti",
@@ -570,10 +570,10 @@
"downloading_album": "Atsisiunčiama {{name}}",
"download_failed": "Atsisiuntimas nepavyko.",
"download_progress": "{{count, number}} / {{total, number}} failai",
"CHRISTMAS": "Kalėdos",
"CHRISTMAS_EVE": "Kūčios",
"NEW_YEAR": "Naujieji metai",
"NEW_YEAR_EVE": "Naujųjų metų išvakarės",
"christmas": "Kalėdos",
"christmas_eve": "Kūčios",
"new_year": "Naujieji metai",
"new_year_eve": "Naujųjų metų išvakarės",
"image": "Vaizdas",
"video": "Vaizdo įrašas",
"live_photo": "Gyva nuotrauka",
@@ -604,7 +604,7 @@
"invert_colors": "Invertuoti spalvas",
"reset": "Atkurti",
"faster_upload": "Spartesni įkėlimai",
"faster_upload_description": "Nukreipkite įkėlimus per netoliese esančius serverius",
"faster_upload_description": "Nukreipkite įkėlimus per netoliese esančius serverius.",
"open_ente_on_startup": "Atverti „Ente“ paleidžiant",
"cast_album_to_tv": "Paleisti albumą televizoriuje",
"enter_cast_pin_code": "Įveskite žemiau esančiame televizoriuje matomą kodą, kad susietumėte šį įrenginį.",
@@ -632,7 +632,7 @@
"passkey_login_failed": "Nepavyko slaptarakčio prisijungimas",
"passkey_login_invalid_url": "Prisijungimo URL netinkamas.",
"passkey_login_already_claimed_session": "Šis seansas jau patvirtintas.",
"passkey_login_generic_error": "Įvyko klaida prisijungiant su slaptarakčiu.",
"passkey_login_generic_error": "Prisijungiant su slaptarakčiu įvyko klaida.",
"passkey_login_credential_hint": "Jei slaptarakčiai yra kitame įrenginyje, galite atverti šį puslapį tame įrenginyje ir patvirtinti.",
"passkeys_not_supported": "Šioje naršyklėje nepalaikomi slaptarakčiai.",
"try_again": "Bandyti dar kartą",

View File

@@ -490,8 +490,8 @@
"update_available_message": "",
"download_and_install": "",
"ignore_this_version": "",
"TODAY": "",
"YESTERDAY": "",
"today": "",
"yesterday": "",
"enter_name": "",
"uploader_name_hint": "",
"name_placeholder": "",
@@ -529,15 +529,15 @@
"gb": "",
"tb": ""
},
"STOP_EXPORT": "",
"EXPORT_PROGRESS": "",
"MIGRATING_EXPORT": "",
"RENAMING_COLLECTION_FOLDERS": "",
"TRASHING_DELETED_FILES": "",
"TRASHING_DELETED_COLLECTIONS": "",
"CONTINUOUS_EXPORT": "",
"PENDING_ITEMS": "",
"EXPORT_STARTING": "",
"stop": "",
"sync_continuously": "",
"export_starting": "",
"preparing": "",
"renaming_album_folders": "",
"trashing_deleted_files": "",
"trashing_deleted_albums": "",
"export_progress": "",
"pending_items": "",
"delete_account_reason_label": "",
"delete_account_reason_placeholder": "",
"delete_reason": {
@@ -570,10 +570,10 @@
"downloading_album": "",
"download_failed": "",
"download_progress": "",
"CHRISTMAS": "",
"CHRISTMAS_EVE": "",
"NEW_YEAR": "",
"NEW_YEAR_EVE": "",
"christmas": "",
"christmas_eve": "",
"new_year": "",
"new_year_eve": "",
"image": "",
"video": "",
"live_photo": "",

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