Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa09136ef | ||
|
|
82ebcf66a5 | ||
|
|
490a7221e7 | ||
|
|
1b98f782f1 | ||
|
|
a46a64e386 | ||
|
|
492ea61bb7 | ||
|
|
9447f1c767 | ||
|
|
76b2a73f9a | ||
|
|
4bbe1ae0d2 | ||
|
|
b15b707600 | ||
|
|
454363d772 | ||
|
|
196fa2c8a4 | ||
|
|
4c3ca8a565 | ||
|
|
15aea42b96 | ||
|
|
29c7f587f6 | ||
|
|
5f0bb21491 | ||
|
|
c882ce0f98 | ||
|
|
02dde7f6a2 | ||
|
|
d268e1f309 | ||
|
|
245e78ac42 | ||
|
|
fa55bd88a2 | ||
|
|
11538236c0 | ||
|
|
09996f77ea | ||
|
|
3f512bc959 | ||
|
|
8b0990bd6c | ||
|
|
70ff886252 | ||
|
|
4314d42ab4 | ||
|
|
8abe6957d7 | ||
|
|
d6ec6809c2 | ||
|
|
8fe9b9571a | ||
|
|
d667cc4f98 | ||
|
|
5806eb6e60 | ||
|
|
8830deb619 | ||
|
|
5e32e975df | ||
|
|
8633dabd92 | ||
|
|
c256f0a8c4 | ||
|
|
b150bbe15e | ||
|
|
7f69fa5d65 | ||
|
|
3968dd93e9 | ||
|
|
557bdd142b | ||
|
|
b89da99c96 | ||
|
|
e79050a3b6 | ||
|
|
5e4707b695 | ||
|
|
fd0c2866a2 | ||
|
|
69eee661d1 | ||
|
|
c78578fce5 | ||
|
|
a0f103be9b | ||
|
|
04ede4326a | ||
|
|
7cb9bc3eb7 | ||
|
|
488402156f | ||
|
|
e80e602786 | ||
|
|
5d553afea7 | ||
|
|
9a25356abf | ||
|
|
5625733429 | ||
|
|
d2dd08391a | ||
|
|
9d06db2b6b | ||
|
|
80049b11ba | ||
|
|
28160b04b9 | ||
|
|
02441239d5 | ||
|
|
760b50b417 | ||
|
|
d118e0e63e | ||
|
|
49f9caac90 | ||
|
|
ea875730dd | ||
|
|
e3b03db06f | ||
|
|
b030c4e182 | ||
|
|
1a39846d25 | ||
|
|
e44020f93a | ||
|
|
435621496c | ||
|
|
8379162716 | ||
|
|
e8d9f4f6cf | ||
|
|
6724527c27 | ||
|
|
6b65a974b5 | ||
|
|
1b90fa93ee | ||
|
|
f907303c8b | ||
|
|
4317f819d8 | ||
|
|
587da41f53 | ||
|
|
3214031a0c | ||
|
|
0b1eee6c8e | ||
|
|
898658f0ef | ||
|
|
7743a4af98 | ||
|
|
d2764fe7e1 | ||
|
|
82df23a3b2 | ||
|
|
00028e3a10 | ||
|
|
c5dab37dfa | ||
|
|
c37deecb96 | ||
|
|
d2a4634f02 | ||
|
|
2275a47438 | ||
|
|
80ab0a308f | ||
|
|
ddb867d21f | ||
|
|
c63cf362b6 | ||
|
|
3f358b9511 | ||
|
|
c480dd71f1 |
2
.github/workflows/auth-internal-release.yml
vendored
2
.github/workflows/auth-internal-release.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/auth-release.yml
vendored
2
.github/workflows/auth-release.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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. "
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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!),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
458
mobile/lib/ui/viewer/file/video_widget_media_kit_common.dart
Normal file
458
mobile/lib/ui/viewer/file/video_widget_media_kit_common.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
172
mobile/lib/ui/viewer/file/video_widget_media_kit_preview.dart
Normal file
172
mobile/lib/ui/viewer/file/video_widget_media_kit_preview.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
ALTER TABLE families
|
||||
ADD COLUMN storage_limit BIGINT;
|
||||
ADD COLUMN storage_limit BIGINT NULL;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package controller
|
||||
package collections
|
||||
|
||||
import (
|
||||
"github.com/ente-io/museum/ente"
|
||||
226
server/pkg/controller/collections/collection.go
Normal file
226
server/pkg/controller/collections/collection.go
Normal 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
|
||||
}
|
||||
24
server/pkg/controller/collections/diff.go
Normal file
24
server/pkg/controller/collections/diff.go
Normal 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
|
||||
}
|
||||
203
server/pkg/controller/collections/file_action.go
Normal file
203
server/pkg/controller/collections/file_action.go
Normal 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
|
||||
}
|
||||
111
server/pkg/controller/collections/files_diff.go
Normal file
111
server/pkg/controller/collections/files_diff.go
Normal 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]
|
||||
}
|
||||
269
server/pkg/controller/collections/share.go
Normal file
269
server/pkg/controller/collections/share.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
123
server/pkg/repo/collection_files.go
Normal file
123
server/pkg/repo/collection_files.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
};
|
||||
|
||||
@@ -903,6 +903,8 @@ const localeName = (locale: SupportedLocale) => {
|
||||
return "Українська";
|
||||
case "vi-VN":
|
||||
return "Tiếng Việt";
|
||||
case "ja-JP":
|
||||
return "日本語";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ζωντανή φωτογραφία",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "ダーク"
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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ą",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -490,8 +490,8 @@
|
||||
"update_available_message": "Er is een nieuwe versie van Ente vrijgegeven, maar deze kan niet automatisch worden gedownload en geïnstalleerd.",
|
||||
"download_and_install": "Downloaden en installeren",
|
||||
"ignore_this_version": "Negeer deze versie",
|
||||
"TODAY": "Vandaag",
|
||||
"YESTERDAY": "Gisteren",
|
||||
"today": "Vandaag",
|
||||
"yesterday": "Gisteren",
|
||||
"enter_name": "Naam invoeren",
|
||||
"uploader_name_hint": "Voeg een naam toe zodat je vrienden weten wie ze moeten bedanken voor deze geweldige foto's!",
|
||||
"name_placeholder": "Naam...",
|
||||
@@ -529,15 +529,15 @@
|
||||
"gb": "GB",
|
||||
"tb": "TB"
|
||||
},
|
||||
"STOP_EXPORT": "Stoppen",
|
||||
"EXPORT_PROGRESS": "<a>{{progress.success}} / {{progress.total}}</a> bestanden geëxporteerd",
|
||||
"MIGRATING_EXPORT": "Voorbereiden...",
|
||||
"RENAMING_COLLECTION_FOLDERS": "Albumnamen hernoemen...",
|
||||
"TRASHING_DELETED_FILES": "Verwijderde bestanden naar prullenbak...",
|
||||
"TRASHING_DELETED_COLLECTIONS": "Verwijderde albums naar prullenbak...",
|
||||
"CONTINUOUS_EXPORT": "Continue synchroniseren",
|
||||
"PENDING_ITEMS": "Bestanden in behandeling",
|
||||
"EXPORT_STARTING": "Exporteren begonnen...",
|
||||
"stop": "Stoppen",
|
||||
"sync_continuously": "Continue synchroniseren",
|
||||
"export_starting": "Exporteren begonnen...",
|
||||
"preparing": "Voorbereiden...",
|
||||
"renaming_album_folders": "Albumnamen hernoemen...",
|
||||
"trashing_deleted_files": "Verwijderde bestanden naar prullenbak...",
|
||||
"trashing_deleted_albums": "Verwijderde albums naar prullenbak...",
|
||||
"export_progress": "<a>{{progress.success}} / {{progress.total}}</a> bestanden geëxporteerd",
|
||||
"pending_items": "Bestanden in behandeling",
|
||||
"delete_account_reason_label": "Wat is de belangrijkste reden waarom je jouw account verwijdert?",
|
||||
"delete_account_reason_placeholder": "Kies een reden",
|
||||
"delete_reason": {
|
||||
@@ -570,10 +570,10 @@
|
||||
"downloading_album": "{{name}} downloaden",
|
||||
"download_failed": "Download mislukt",
|
||||
"download_progress": "{{count, number}} / {{total, number}} bestanden",
|
||||
"CHRISTMAS": "Kerst",
|
||||
"CHRISTMAS_EVE": "Kerstavond",
|
||||
"NEW_YEAR": "Nieuwjaar",
|
||||
"NEW_YEAR_EVE": "Oudjaarsavond",
|
||||
"christmas": "Kerst",
|
||||
"christmas_eve": "Kerstavond",
|
||||
"new_year": "Nieuwjaar",
|
||||
"new_year_eve": "Oudjaarsavond",
|
||||
"image": "Afbeelding",
|
||||
"video": "Video",
|
||||
"live_photo": "Live foto",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user