Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a9fd4cb7 | ||
|
|
bea840d891 |
37
.github/workflows/mobile-daily-internal.yml
vendored
@@ -27,38 +27,6 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
echo "Initial disk usage:"
|
||||
df -h /
|
||||
# Get available space in KB
|
||||
INITIAL=$(df / | awk 'NR==2 {print $4}')
|
||||
|
||||
echo -e "\n=== Removing .NET SDK (~20-25GB) ==="
|
||||
BEFORE=$(df / | awk 'NR==2 {print $4}')
|
||||
START=$(date +%s)
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
END=$(date +%s)
|
||||
AFTER=$(df / | awk 'NR==2 {print $4}')
|
||||
FREED=$(( (AFTER - BEFORE) / 1048576 )) # Convert KB to GB
|
||||
echo "Time: $((END-START))s | Freed: ${FREED}GB"
|
||||
|
||||
echo -e "\n=== Removing cached tools (~5-10GB) ==="
|
||||
BEFORE=$(df / | awk 'NR==2 {print $4}')
|
||||
START=$(date +%s)
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
END=$(date +%s)
|
||||
AFTER=$(df / | awk 'NR==2 {print $4}')
|
||||
FREED=$(( (AFTER - BEFORE) / 1048576 ))
|
||||
echo "Time: $((END-START))s | Freed: ${FREED}GB"
|
||||
|
||||
echo -e "\n=== Final Summary ==="
|
||||
FINAL=$(df / | awk 'NR==2 {print $4}')
|
||||
TOTAL_FREED=$(( (FINAL - INITIAL) / 1048576 ))
|
||||
echo "Total space freed: ${TOTAL_FREED}GB"
|
||||
echo "Final disk usage:"
|
||||
df -h /
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
@@ -71,6 +39,11 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install Rust ${{ env.RUST_VERSION }}
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
|
||||
- name: Install Flutter Rust Bridge
|
||||
run: cargo install flutter_rust_bridge_codegen
|
||||
|
||||
|
||||
77
.github/workflows/mobile-internal-release.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: "Old Internal release (photos)"
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Allow manually running the action
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.32.8"
|
||||
RUST_VERSION: "1.85.1"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile/apps/photos
|
||||
|
||||
steps:
|
||||
- name: Checkout code and submodules
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 17
|
||||
|
||||
- name: Install Flutter ${{ env.FLUTTER_VERSION }}
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install Rust ${{ env.RUST_VERSION }}
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ env.RUST_VERSION }}
|
||||
|
||||
- name: Install Flutter Rust Bridge
|
||||
run: cargo install flutter_rust_bridge_codegen
|
||||
|
||||
- name: Setup keys
|
||||
uses: timheuer/base64-to-file@v1
|
||||
with:
|
||||
fileName: "keystore/ente_photos_key.jks"
|
||||
encodedString: ${{ secrets.SIGNING_KEY_PHOTOS }}
|
||||
|
||||
- name: Build PlayStore AAB
|
||||
run: |
|
||||
flutter build appbundle --dart-define=cronetHttpNoPlay=true --release --flavor playstore
|
||||
env:
|
||||
SIGNING_KEY_PATH: "/home/runner/work/_temp/keystore/ente_photos_key.jks"
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS_PHOTOS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD_PHOTOS }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD_PHOTOS }}
|
||||
|
||||
- name: Upload AAB to PlayStore
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }}
|
||||
packageName: io.ente.photos
|
||||
releaseFiles: mobile/apps/photos/build/app/outputs/bundle/playstoreRelease/app-playstore-release.aab
|
||||
track: internal
|
||||
|
||||
- name: Notify Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_INTERNAL_RELEASE_WEBHOOK }}
|
||||
nodetail: true
|
||||
title: "🏆 Internal release Photos (Branch: ${{ github.ref_name }})"
|
||||
description: "[Download](https://play.google.com/store/apps/details?id=io.ente.photos)"
|
||||
color: 0x00ff00
|
||||
38
.github/workflows/mobile-release.yml
vendored
@@ -28,38 +28,6 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
echo "Initial disk usage:"
|
||||
df -h /
|
||||
# Get available space in KB
|
||||
INITIAL=$(df / | awk 'NR==2 {print $4}')
|
||||
|
||||
echo -e "\n=== Removing .NET SDK (~20-25GB) ==="
|
||||
BEFORE=$(df / | awk 'NR==2 {print $4}')
|
||||
START=$(date +%s)
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
END=$(date +%s)
|
||||
AFTER=$(df / | awk 'NR==2 {print $4}')
|
||||
FREED=$(( (AFTER - BEFORE) / 1048576 )) # Convert KB to GB
|
||||
echo "Time: $((END-START))s | Freed: ${FREED}GB"
|
||||
|
||||
echo -e "\n=== Removing cached tools (~5-10GB) ==="
|
||||
BEFORE=$(df / | awk 'NR==2 {print $4}')
|
||||
START=$(date +%s)
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
END=$(date +%s)
|
||||
AFTER=$(df / | awk 'NR==2 {print $4}')
|
||||
FREED=$(( (AFTER - BEFORE) / 1048576 ))
|
||||
echo "Time: $((END-START))s | Freed: ${FREED}GB"
|
||||
|
||||
echo -e "\n=== Final Summary ==="
|
||||
FINAL=$(df / | awk 'NR==2 {print $4}')
|
||||
TOTAL_FREED=$(( (FINAL - INITIAL) / 1048576 ))
|
||||
echo "Total space freed: ${TOTAL_FREED}GB"
|
||||
echo "Final disk usage:"
|
||||
df -h /
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
@@ -72,12 +40,6 @@ jobs:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install Flutter Rust Bridge
|
||||
run: cargo install flutter_rust_bridge_codegen
|
||||
|
||||
- name: Generate Rust bindings
|
||||
run: flutter_rust_bridge_codegen generate
|
||||
|
||||
- name: Setup keys
|
||||
uses: timheuer/base64-to-file@v1
|
||||
with:
|
||||
|
||||
2
mobile/apps/auth/.gitignore
vendored
@@ -33,6 +33,8 @@
|
||||
.pub/
|
||||
/build/
|
||||
macos/build/
|
||||
.gradle/
|
||||
settings.local.json
|
||||
|
||||
# Web related
|
||||
lib/generated_plugin_registrant.dart
|
||||
|
||||
@@ -1236,12 +1236,6 @@
|
||||
"title": "Parqet",
|
||||
"slug": "parqet"
|
||||
},
|
||||
{
|
||||
"title": "Parallels",
|
||||
"slug": "parallels",
|
||||
"hex": "#E61E25",
|
||||
"altNames": ["Parallels Desktop", "Parallels VM"]
|
||||
},
|
||||
{
|
||||
"title": "Parsec"
|
||||
},
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="20" y="10" width="10" height="80" rx="5" fill="#E61E25"/>
|
||||
<rect x="50" y="10" width="10" height="80" rx="5" fill="#E61E25"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 207 B |
576
mobile/apps/auth/integration_test/auth_flow_test.dart
Normal file
@@ -0,0 +1,576 @@
|
||||
import 'package:ente_auth/app/view/app.dart';
|
||||
import 'package:ente_auth/bootstrap.dart';
|
||||
import 'package:ente_auth/main.dart';
|
||||
import 'package:ente_auth/onboarding/view/common/add_chip.dart';
|
||||
import 'package:ente_auth/services/update_service.dart';
|
||||
import 'package:ente_lock_screen/local_authentication_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('Authentication Flow Integration Test with Persistence', () {
|
||||
testWidgets(
|
||||
'Complete auth flow: Use without backup -> Enter setup key -> Verify entry persists after restart',
|
||||
(WidgetTester tester) async {
|
||||
// Bootstrap the app
|
||||
await bootstrap(App.new);
|
||||
await init(false, via: 'integrationTest');
|
||||
await UpdateService.instance.init();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 1: Click on "Use without backup" option
|
||||
final useOfflineText = find.text('Use without backups');
|
||||
expect(
|
||||
useOfflineText,
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'ERROR: "Use without backups" button not found on initial screen. Check if app loaded correctly or text changed.',
|
||||
);
|
||||
await tester.tap(useOfflineText);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 2: Click OK button on the warning dialog
|
||||
final okButton = find.text('Ok');
|
||||
expect(
|
||||
okButton,
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'ERROR: "Ok" button not found in warning dialog. Check if dialog appeared or button text changed.',
|
||||
);
|
||||
await tester.tap(okButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Wait for navigation to complete
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Step 3: Navigate to manual entry screen
|
||||
bool foundManualEntry = false;
|
||||
|
||||
// Try FloatingActionButton approach first
|
||||
final fabFinder = find.byType(FloatingActionButton);
|
||||
if (fabFinder.evaluate().isNotEmpty) {
|
||||
await tester.tap(fabFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final manualEntryFinder = find.text('Enter a setup key');
|
||||
if (manualEntryFinder.evaluate().isNotEmpty) {
|
||||
await tester.tap(manualEntryFinder);
|
||||
await tester.pumpAndSettle();
|
||||
foundManualEntry = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative approaches if FAB didn't work
|
||||
if (!foundManualEntry) {
|
||||
final alternatives = [
|
||||
'Enter details manually',
|
||||
'Enter a setup key',
|
||||
];
|
||||
|
||||
for (final text in alternatives) {
|
||||
final finder = find.text(text);
|
||||
if (finder.evaluate().isNotEmpty) {
|
||||
await tester.tap(finder.first);
|
||||
await tester.pumpAndSettle();
|
||||
foundManualEntry = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
foundManualEntry,
|
||||
isTrue,
|
||||
reason:
|
||||
'ERROR: Could not find manual entry option. Tried FAB + "Enter details manually", "Enter a setup key", etc. Check UI navigation.',
|
||||
);
|
||||
|
||||
// Step 4: Fill in the form with test data
|
||||
final textFields = find.byType(TextFormField);
|
||||
expect(
|
||||
textFields.evaluate().length,
|
||||
greaterThanOrEqualTo(3),
|
||||
reason:
|
||||
'ERROR: Expected at least 3 text fields (issuer, secret, account) but found ${textFields.evaluate().length}. Check manual entry form.',
|
||||
);
|
||||
|
||||
// Fill issuer field
|
||||
await tester.tap(textFields.first);
|
||||
await tester.enterText(textFields.first, 'testIssuer');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill secret field
|
||||
await tester.tap(textFields.at(1));
|
||||
await tester.enterText(
|
||||
textFields.at(1),
|
||||
'JBSWY3DPEHPK3PXP',
|
||||
); // Valid base32 secret
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill account field
|
||||
await tester.tap(textFields.at(2));
|
||||
await tester.enterText(textFields.at(2), 'testAccount');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 5: Save the entry
|
||||
final saveButton = find.text('Save');
|
||||
expect(
|
||||
saveButton,
|
||||
findsOneWidget,
|
||||
reason:
|
||||
'ERROR: "Save" button not found on manual entry form. Check if button text changed or form layout changed.',
|
||||
);
|
||||
await tester.tap(saveButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Step 6: Verify entry was created successfully
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Check if coach mark overlay is present and dismiss it
|
||||
final coachMarkOverlay = find.text('Ok');
|
||||
if (coachMarkOverlay.evaluate().isNotEmpty) {
|
||||
print('🎯 Dismissing coach mark overlay...');
|
||||
await tester.tap(coachMarkOverlay);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
// Look for the created entry
|
||||
final issuerEntryFinder = find.textContaining('testIssuer');
|
||||
expect(
|
||||
issuerEntryFinder,
|
||||
findsAtLeastNWidgets(1),
|
||||
reason:
|
||||
'ERROR: testIssuer entry not found after saving. Entry creation may have failed or navigation issue occurred.',
|
||||
);
|
||||
|
||||
final accountEntryFinder = find.textContaining('testAccount');
|
||||
expect(
|
||||
accountEntryFinder,
|
||||
findsAtLeastNWidgets(1),
|
||||
reason:
|
||||
'ERROR: testAccount not found after saving. Account field may not have been saved properly.',
|
||||
);
|
||||
print('✅ Step 1 completed: Entry created successfully');
|
||||
print('- testIssuer entry is visible');
|
||||
print('- testAccount is visible');
|
||||
|
||||
// warning about clearing
|
||||
|
||||
// Step 8: Add second code entry
|
||||
print('🔄 Adding second code entry...');
|
||||
|
||||
// Wait a moment before adding second entry
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Click FAB to add second entry
|
||||
final fabFinder2 = find.byType(FloatingActionButton);
|
||||
expect(
|
||||
fabFinder2,
|
||||
findsOneWidget,
|
||||
reason: 'FAB not found for second entry',
|
||||
);
|
||||
await tester.tap(fabFinder2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Click "Enter details manually" (coach mark won't show second time)
|
||||
final manualEntryFinder = find.text('Enter details manually');
|
||||
expect(
|
||||
manualEntryFinder,
|
||||
findsOneWidget,
|
||||
reason: 'Manual entry option not found',
|
||||
);
|
||||
await tester.tap(manualEntryFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill second entry form
|
||||
final textFields2 = find.byType(TextFormField);
|
||||
expect(textFields2.evaluate().length, greaterThanOrEqualTo(3));
|
||||
|
||||
// Fill second issuer field
|
||||
await tester.tap(textFields2.first);
|
||||
await tester.enterText(textFields2.first, 'testIssuer2');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify issuer field was filled
|
||||
final issuerField = tester.widget<TextFormField>(textFields2.first);
|
||||
print(
|
||||
'✓ Issuer field controller text: "${issuerField.controller?.text ?? "null"}"');
|
||||
|
||||
// Fill second secret field
|
||||
await tester.tap(textFields2.at(1));
|
||||
await tester.enterText(textFields2.at(1), 'JBSWY3DPEHPK3PXP');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify secret field was filled
|
||||
final secretField = tester.widget<TextFormField>(textFields2.at(1));
|
||||
print(
|
||||
'✓ Secret field controller text: "${secretField.controller?.text ?? "null"}"');
|
||||
|
||||
// Fill second account field
|
||||
await tester.tap(textFields2.at(2));
|
||||
await tester.enterText(textFields2.at(2), 'testAccount2');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Save second entry
|
||||
final saveButton2 = find.text('Save');
|
||||
await tester.tap(saveButton2);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Verify both entries exist
|
||||
final issuer1Finder = find.textContaining('testIssuer');
|
||||
final issuer2Finder = find.textContaining('testIssuer2');
|
||||
final account1Finder = find.textContaining('testAccount');
|
||||
final account2Finder = find.textContaining('testAccount2');
|
||||
|
||||
expect(issuer1Finder, findsAtLeastNWidgets(1),
|
||||
reason: 'First issuer not found');
|
||||
expect(issuer2Finder, findsAtLeastNWidgets(1),
|
||||
reason: 'Second issuer not found');
|
||||
expect(
|
||||
account1Finder,
|
||||
findsAtLeastNWidgets(1),
|
||||
reason: 'First account not found',
|
||||
);
|
||||
expect(
|
||||
account2Finder,
|
||||
findsAtLeastNWidgets(1),
|
||||
reason: 'Second account not found',
|
||||
);
|
||||
|
||||
print('✅ Step 2 completed: Both entries created successfully');
|
||||
print('- testIssuer and testIssuer2 entries are visible');
|
||||
print('- testAccount and testAccount2 are visible');
|
||||
|
||||
// Step 9: Test search functionality
|
||||
print('🔍 Testing search functionality...');
|
||||
|
||||
// Click on search icon to activate search
|
||||
final searchIcon = find.byIcon(Icons.search);
|
||||
expect(searchIcon, findsOneWidget, reason: 'Search icon not found');
|
||||
await tester.tap(searchIcon);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Find the search text field
|
||||
final searchField = find.byType(TextField);
|
||||
expect(searchField, findsOneWidget,
|
||||
reason: 'Search text field not found');
|
||||
|
||||
// Enter search term "issuer2"
|
||||
await tester.tap(searchField);
|
||||
await tester.enterText(searchField, 'issuer2');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify only one result is shown (testIssuer2)
|
||||
final searchResults = find.textContaining('testIssuer');
|
||||
final issuer2Results = find.textContaining('testIssuer2');
|
||||
|
||||
// Should find testIssuer2 but not testIssuer when searching for "issuer2"
|
||||
expect(issuer2Results, findsAtLeastNWidgets(1),
|
||||
reason: 'testIssuer2 not found in search results');
|
||||
|
||||
// Verify total results - should only show the matching entry
|
||||
final allVisibleIssuers = find.textContaining('testIssuer');
|
||||
expect(allVisibleIssuers.evaluate().length, equals(1),
|
||||
reason: 'Search should show only one result for "issuer2"');
|
||||
|
||||
print('✅ Search results verified: only testIssuer2 is visible');
|
||||
|
||||
// Clear search bar
|
||||
final clearIcon = find.byIcon(Icons.clear);
|
||||
expect(clearIcon, findsOneWidget,
|
||||
reason: 'Clear search icon not found');
|
||||
await tester.tap(clearIcon);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify both entries are visible again after clearing search
|
||||
final allIssuer1 = find.textContaining('testIssuer');
|
||||
final allIssuer2 = find.textContaining('testIssuer2');
|
||||
expect(allIssuer1, findsAtLeastNWidgets(1),
|
||||
reason: 'testIssuer not visible after clearing search');
|
||||
expect(allIssuer2, findsAtLeastNWidgets(1),
|
||||
reason: 'testIssuer2 not visible after clearing search');
|
||||
|
||||
print('✅ Search cleared: both entries visible again');
|
||||
print('✅ Step 3 completed: Search functionality working correctly');
|
||||
|
||||
// Step 10: Long press on issuer2 to edit and add tags
|
||||
print('🏷️ Testing tag functionality...');
|
||||
|
||||
// Long press on testIssuer2 entry to bring up edit menu
|
||||
final issuer2Entry = find.textContaining('testIssuer2');
|
||||
expect(issuer2Entry, findsOneWidget,
|
||||
reason: 'testIssuer2 entry not found for long press');
|
||||
await tester.longPress(issuer2Entry);
|
||||
await tester.pumpAndSettle();
|
||||
LocalAuthenticationService.instance.lastAuthTime = DateTime.now()
|
||||
.add(const Duration(minutes: 10))
|
||||
.millisecondsSinceEpoch;
|
||||
|
||||
// Look for edit option and tap it
|
||||
final editOption = find.text('Edit');
|
||||
expect(editOption, findsOneWidget, reason: 'Edit option not found');
|
||||
await tester.tap(editOption);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Wait for edit page to load
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
// Look for AddChip widget to add first tag
|
||||
final addChip = find.byType(AddChip);
|
||||
expect(addChip, findsOneWidget, reason: 'AddChip widget not found');
|
||||
await tester.tap(addChip);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter first tag name "tag1"
|
||||
final tagInputField = find.byType(TextField).last;
|
||||
await tester.tap(tagInputField);
|
||||
await tester.enterText(tagInputField, 'tag1');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap create/save button for first tag
|
||||
final createButton = find.text('Create');
|
||||
expect(createButton, findsOneWidget,
|
||||
reason: 'Create button not found for first tag');
|
||||
await tester.tap(createButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Add second tag
|
||||
final addChip2 = find.byType(AddChip);
|
||||
await tester.tap(addChip2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Enter second tag name "tag2"
|
||||
final tagInputField2 = find.byType(TextField).last;
|
||||
await tester.tap(tagInputField2);
|
||||
await tester.enterText(tagInputField2, 'tag2');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap create button for second tag
|
||||
final createButton2 = find.text('Create');
|
||||
await tester.tap(createButton2);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify tags are selected/visible
|
||||
final tag1Chip = find.text('tag1');
|
||||
final tag2Chip = find.text('tag2');
|
||||
expect(tag1Chip, findsOneWidget,
|
||||
reason: 'tag1 not found after creation');
|
||||
expect(tag2Chip, findsOneWidget,
|
||||
reason: 'tag2 not found after creation');
|
||||
|
||||
print('✅ Tags created: tag1 and tag2 are visible');
|
||||
|
||||
// Save the edited entry
|
||||
final saveEditButton = find.text('Save');
|
||||
expect(saveEditButton, findsOneWidget,
|
||||
reason: 'Save button not found on edit page');
|
||||
await tester.tap(saveEditButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Wait for navigation back to home
|
||||
await tester.pumpAndSettle(const Duration(seconds: 2));
|
||||
|
||||
print('✅ Entry saved with tags');
|
||||
|
||||
// Step 11: Test tag filtering functionality
|
||||
print('🏷️ Testing tag filtering...');
|
||||
|
||||
// Click on tag1 to filter entries
|
||||
final tag1Filter = find.textContaining('tag1');
|
||||
if (tag1Filter.evaluate().isNotEmpty) {
|
||||
await tester.tap(tag1Filter.first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify only testIssuer2 is visible (the one with tag1)
|
||||
final filteredIssuer2 = find.textContaining('testIssuer2');
|
||||
final filteredIssuer1 = find.textContaining('testIssuer').evaluate().where(
|
||||
(element) => !element.widget.toString().contains('testIssuer2')
|
||||
).length;
|
||||
|
||||
expect(filteredIssuer2, findsAtLeastNWidgets(1),
|
||||
reason: 'testIssuer2 not visible when filtering by tag1');
|
||||
expect(filteredIssuer1, equals(0),
|
||||
reason: 'testIssuer should not be visible when filtering by tag1');
|
||||
|
||||
print('✅ Tag1 filtering verified: only testIssuer2 is visible');
|
||||
|
||||
// Click "All" to clear tag filter
|
||||
final allFilter = find.text('All');
|
||||
if (allFilter.evaluate().isNotEmpty) {
|
||||
await tester.tap(allFilter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify both entries are visible again
|
||||
final allEntriesIssuer1 = find.textContaining('testIssuer');
|
||||
final allEntriesIssuer2 = find.textContaining('testIssuer2');
|
||||
expect(allEntriesIssuer1, findsAtLeastNWidgets(1));
|
||||
expect(allEntriesIssuer2, findsAtLeastNWidgets(1));
|
||||
|
||||
print('✅ Tag filter cleared: both entries visible again');
|
||||
}
|
||||
}
|
||||
|
||||
print('✅ Step 4 completed: Tag functionality working correctly');
|
||||
|
||||
// Step 12: Test trash functionality
|
||||
print('🗑️ Testing trash functionality...');
|
||||
|
||||
// Long press on testIssuer2 entry to bring up context menu
|
||||
final issuer2EntryForTrash = find.textContaining('testIssuer2').first;
|
||||
await tester.longPress(issuer2EntryForTrash);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Look for trash/delete option and tap it
|
||||
final trashOption = find.text('Trash');
|
||||
if (trashOption.evaluate().isEmpty) {
|
||||
// Try alternative delete options
|
||||
final deleteOption = find.text('Delete');
|
||||
expect(deleteOption, findsOneWidget, reason: 'Delete/Trash option not found');
|
||||
await tester.tap(deleteOption);
|
||||
} else {
|
||||
await tester.tap(trashOption);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Confirm deletion if dialog appears
|
||||
final confirmButtons = [
|
||||
find.text('Yes'),
|
||||
find.text('OK'),
|
||||
find.text('Confirm'),
|
||||
find.text('Delete'),
|
||||
find.text('Trash'),
|
||||
];
|
||||
|
||||
for (final button in confirmButtons) {
|
||||
if (button.evaluate().isNotEmpty) {
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
print('✅ Issuer2 entry trashed');
|
||||
|
||||
// Step 13: Verify tags are no longer visible and trash tag appears
|
||||
print('🏷️ Verifying tag visibility after trash...');
|
||||
|
||||
// Wait for UI to update
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Verify tag1 and tag2 are no longer visible (since issuer2 was the only entry with these tags)
|
||||
final tag1Visible = find.textContaining('tag1');
|
||||
final tag2Visible = find.textContaining('tag2');
|
||||
|
||||
// Tags should not be visible anymore since the only entry with these tags was trashed
|
||||
expect(tag1Visible.evaluate().isEmpty, isTrue, reason: 'tag1 should not be visible after trashing issuer2');
|
||||
expect(tag2Visible.evaluate().isEmpty, isTrue, reason: 'tag2 should not be visible after trashing issuer2');
|
||||
|
||||
// Verify trash tag is now visible
|
||||
final trashTag = find.text('Trash');
|
||||
expect(trashTag, findsOneWidget, reason: 'Trash tag not visible after trashing entry');
|
||||
|
||||
print('✅ Tags hidden and Trash tag visible');
|
||||
|
||||
// Step 14: Test trash filtering
|
||||
print('🗑️ Testing trash tag filtering...');
|
||||
|
||||
// Click on Trash tag to show trashed items
|
||||
await tester.tap(trashTag);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify issuer2 is visible in trash
|
||||
final trashedIssuer2 = find.textContaining('testIssuer2');
|
||||
expect(trashedIssuer2, findsOneWidget, reason: 'testIssuer2 not visible in trash');
|
||||
|
||||
// Verify issuer1 is not visible (should be filtered out)
|
||||
final issuer1InTrash = find.textContaining('testIssuer').evaluate().where(
|
||||
(element) => !element.widget.toString().contains('testIssuer2')
|
||||
).length;
|
||||
expect(issuer1InTrash, equals(0), reason: 'testIssuer should not be visible in trash filter');
|
||||
|
||||
print('✅ Trash filtering working: only trashed items visible');
|
||||
|
||||
// Step 15: Test All filter (should not show trashed items)
|
||||
print('📋 Testing All filter excludes trash...');
|
||||
|
||||
// Click on "All" to show all non-trashed items
|
||||
final allTag = find.text('All');
|
||||
await tester.tap(allTag);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify issuer1 is visible
|
||||
final allFilterIssuer1 = find.textContaining('testIssuer').evaluate().where(
|
||||
(element) => !element.widget.toString().contains('testIssuer2')
|
||||
).length;
|
||||
expect(allFilterIssuer1, greaterThan(0), reason: 'testIssuer should be visible in All filter');
|
||||
|
||||
// Verify issuer2 is NOT visible in All
|
||||
final allFilterIssuer2 = find.textContaining('testIssuer2');
|
||||
expect(allFilterIssuer2.evaluate().isEmpty, isTrue, reason: 'testIssuer2 should not be visible in All filter');
|
||||
|
||||
print('✅ All filter working: trashed items excluded');
|
||||
|
||||
// Step 16: Test restore functionality
|
||||
print('♻️ Testing restore functionality...');
|
||||
|
||||
// Go back to trash view
|
||||
await tester.tap(trashTag);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Long press on trashed issuer2 entry
|
||||
final trashedEntryForRestore = find.textContaining('testIssuer2');
|
||||
await tester.longPress(trashedEntryForRestore);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Look for restore option
|
||||
final restoreOption = find.text('Restore');
|
||||
expect(restoreOption, findsOneWidget, reason: 'Restore option not found');
|
||||
await tester.tap(restoreOption);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
print('✅ Restore option tapped');
|
||||
|
||||
// Step 17: Verify restoration worked
|
||||
print('✅ Verifying restoration...');
|
||||
|
||||
// Wait for restoration to complete
|
||||
await tester.pumpAndSettle(const Duration(seconds: 1));
|
||||
|
||||
// Go to All view to check if issuer2 is restored
|
||||
await tester.tap(allTag);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify both entries are now visible in All
|
||||
final restoredIssuer1 = find.textContaining('testIssuer');
|
||||
final restoredIssuer2 = find.textContaining('testIssuer2');
|
||||
|
||||
expect(restoredIssuer1, findsAtLeastNWidgets(1), reason: 'testIssuer not visible after restore');
|
||||
expect(restoredIssuer2, findsAtLeastNWidgets(1), reason: 'testIssuer2 not visible after restore');
|
||||
|
||||
// Verify tags are visible again
|
||||
final restoredTag1 = find.textContaining('tag1');
|
||||
final restoredTag2 = find.textContaining('tag2');
|
||||
|
||||
if (restoredTag1.evaluate().isNotEmpty && restoredTag2.evaluate().isNotEmpty) {
|
||||
print('✅ Tags restored and visible again');
|
||||
}
|
||||
|
||||
print('✅ Step 5 completed: Trash and restore functionality working correctly');
|
||||
|
||||
print('✅ Integration test completed successfully!');
|
||||
print('- Both entries created and verified');
|
||||
print('- Search functionality tested and working');
|
||||
print('- Tag functionality tested and working');
|
||||
print('- Trash functionality tested and working');
|
||||
print('- Restore functionality tested and working');
|
||||
print('- Multiple TOTP codes are being generated');
|
||||
print('- Data persistence is working correctly');
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 3)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -68,6 +68,8 @@ PODS:
|
||||
- Flutter
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
- Flutter
|
||||
- local_auth_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -150,6 +152,7 @@ DEPENDENCIES:
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||
- move_to_background (from `.symlinks/plugins/move_to_background/ios`)
|
||||
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||
@@ -210,6 +213,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||
fluttertoast:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
integration_test:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
local_auth_darwin:
|
||||
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
||||
move_to_background:
|
||||
@@ -260,6 +265,7 @@ SPEC CHECKSUMS:
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
local_auth_darwin: fa4b06454df7df8e97c18d7ee55151c57e7af0de
|
||||
move_to_background: 39a5b79b26d577b0372cbe8a8c55e7aa9fcd3a2d
|
||||
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
|
||||
|
||||
@@ -99,7 +99,7 @@ Future<void> _runInForeground() async {
|
||||
return await _runWithLogs(() async {
|
||||
_logger.info("Starting app in foreground");
|
||||
try {
|
||||
await _init(false, via: 'mainMethod');
|
||||
await init(false, via: 'mainMethod');
|
||||
} catch (e, s) {
|
||||
_logger.severe("Failed to init", e, s);
|
||||
rethrow;
|
||||
@@ -160,7 +160,7 @@ void _registerWindowsProtocol() {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _init(bool bool, {String? via}) async {
|
||||
Future<void> init(bool bool, {String? via}) async {
|
||||
_registerWindowsProtocol();
|
||||
await CryptoUtil.init();
|
||||
|
||||
|
||||
@@ -605,6 +605,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
flutter_driver:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_email_sender:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -853,6 +858,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
fuchsia_remote_debug_protocol:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -965,6 +975,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.4"
|
||||
integration_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1358,6 +1373,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.8"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process
|
||||
sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.3"
|
||||
protobuf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1740,6 +1763,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
sync_http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sync_http
|
||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
synchronized:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1972,6 +2003,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webdriver
|
||||
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
win32:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -138,6 +138,8 @@ dev_dependencies:
|
||||
build_runner: ^2.1.11
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
json_serializable: ^6.2.0
|
||||
lints: ^5.1.1
|
||||
mocktail: ^1.0.3
|
||||
|
||||
@@ -7,7 +7,8 @@ import 'package:ente_events/event_bus.dart';
|
||||
import 'package:ente_events/models/signed_in_event.dart';
|
||||
import 'package:ente_events/models/signed_out_event.dart';
|
||||
import 'package:ente_strings/l10n/strings_localizations.dart';
|
||||
import "package:ente_ui/theme/ente_theme_data.dart";
|
||||
import 'package:ente_ui/theme/colors.dart';
|
||||
import 'package:ente_ui/theme/ente_theme_data.dart';
|
||||
import 'package:ente_ui/utils/window_listener_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import "package:flutter/material.dart";
|
||||
@@ -86,14 +87,37 @@ class _AppState extends State<App>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final schemes = ColorSchemeBuilder.fromCustomColors(
|
||||
primary700: const Color(0xFF1565C0), // Dark blue
|
||||
primary500: const Color(0xFF2196F3), // Material blue
|
||||
primary400: const Color(0xFF42A5F5), // Light blue
|
||||
primary300: const Color(0xFF90CAF9), // Very light blue
|
||||
iconButtonColor: const Color(0xFF1976D2), // Custom icon color
|
||||
gradientButtonBgColors: const [
|
||||
Color(0xFF1565C0),
|
||||
Color(0xFF2196F3),
|
||||
Color(0xFF42A5F5),
|
||||
],
|
||||
);
|
||||
|
||||
final lightTheme = createAppThemeData(
|
||||
brightness: Brightness.light,
|
||||
colorScheme: schemes.light,
|
||||
);
|
||||
|
||||
final darkTheme = createAppThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: schemes.dark,
|
||||
);
|
||||
|
||||
Widget buildApp() {
|
||||
if (Platform.isAndroid ||
|
||||
Platform.isWindows ||
|
||||
Platform.isLinux ||
|
||||
kDebugMode) {
|
||||
return AdaptiveTheme(
|
||||
light: lightThemeData,
|
||||
dark: darkThemeData,
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
initial: AdaptiveThemeMode.system,
|
||||
builder: (lightTheme, dartTheme) => MaterialApp(
|
||||
title: "ente",
|
||||
@@ -118,8 +142,8 @@ class _AppState extends State<App>
|
||||
return MaterialApp(
|
||||
title: "ente",
|
||||
themeMode: ThemeMode.system,
|
||||
theme: lightThemeData,
|
||||
darkTheme: darkThemeData,
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme,
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: locale,
|
||||
supportedLocales: appSupportedLocales,
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:ente_lock_screen/ui/lock_screen.dart';
|
||||
import 'package:ente_logging/logging.dart';
|
||||
import 'package:ente_network/network.dart';
|
||||
import "package:ente_strings/l10n/strings_localizations.dart";
|
||||
import "package:ente_ui/theme/ente_theme_data.dart";
|
||||
import "package:ente_ui/theme/theme_config.dart";
|
||||
import 'package:ente_ui/utils/window_listener_service.dart';
|
||||
import 'package:ente_utils/platform_util.dart';
|
||||
@@ -104,8 +103,6 @@ Future<void> _runInForeground() async {
|
||||
lockScreen: LockScreen(Configuration.instance),
|
||||
enabled: await LockScreenSettings.instance.shouldShowLockScreen(),
|
||||
locale: locale,
|
||||
lightTheme: lightThemeData,
|
||||
darkTheme: darkThemeData,
|
||||
savedThemeMode: savedThemeMode,
|
||||
supportedLocales: appSupportedLocales,
|
||||
localizationsDelegates: const [
|
||||
|
||||
@@ -135,17 +135,6 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name="${applicationId}.IconDuckyHuggingE"
|
||||
android:icon="@mipmap/icon_ducky_hugging_e"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 42 KiB |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_ducky_hugging_e_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,52 +0,0 @@
|
||||
<svg width="198" height="150" viewBox="0 0 198 150" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.2" d="M103.208 1.51891C70.0589 1.51891 17.0832 -6.28475 9.02179 30.1961C0.957739 66.6797 0.325369 122.498 30.8944 124.356C61.4634 126.213 136.678 126.856 160.318 124.356C183.957 121.855 185.312 83.2462 185.312 57.3881C185.312 31.53 174.179 -0.973365 123.125 0.817466C107.034 1.38341 103.21 1.51891 103.21 1.51891H103.208Z" fill="white"/>
|
||||
<path d="M141.928 142.014C168.856 142.014 190.684 139.1 190.684 135.504C190.684 131.909 168.856 128.995 141.928 128.995C115.001 128.995 93.1719 131.909 93.1719 135.504C93.1719 139.1 115.001 142.014 141.928 142.014Z" fill="#343434"/>
|
||||
<path d="M67.2538 149.392C104.397 149.392 134.508 145.779 134.508 141.321C134.508 136.864 104.397 133.251 67.2538 133.251C30.1105 133.251 0 136.864 0 141.321C0 145.779 30.1105 149.392 67.2538 149.392Z" fill="#343434"/>
|
||||
<path d="M111.039 120.086C101.394 128.801 101.925 135.497 113.988 136.559C126.051 137.622 134.713 135.151 131.923 127.605C129.159 120.086 111.039 120.086 111.039 120.086Z" fill="#FF814A"/>
|
||||
<path d="M118.989 137.665C117.408 137.665 115.71 137.585 113.909 137.428C108.141 136.921 104.71 135.165 103.722 132.21C102.595 128.852 104.923 124.436 110.452 119.438C110.612 119.294 110.822 119.212 111.037 119.212C111.802 119.212 129.801 119.305 132.739 127.303C133.675 129.829 133.483 131.982 132.173 133.698C130.186 136.299 125.584 137.662 118.989 137.662V137.665ZM111.369 120.963C106.642 125.299 104.514 129.088 105.375 131.657C106.113 133.86 109.118 135.258 114.063 135.691C122.635 136.445 128.733 135.335 130.789 132.643C131.727 131.416 131.828 129.869 131.105 127.908C128.778 121.574 113.803 121.011 111.372 120.963H111.369Z" fill="#1C1C1C"/>
|
||||
<path d="M157.878 120.086C167.523 128.801 166.992 135.497 154.929 136.559C142.866 137.622 134.204 135.151 136.994 127.605C139.757 120.086 157.878 120.086 157.878 120.086Z" fill="#FF814A"/>
|
||||
<path d="M149.927 137.665C143.332 137.665 138.73 136.302 136.743 133.701C135.433 131.984 135.241 129.832 136.177 127.303C139.115 119.305 157.114 119.215 157.879 119.215C158.094 119.215 158.304 119.295 158.464 119.441C163.993 124.436 166.321 128.852 165.194 132.213C164.203 135.168 160.775 136.921 155.007 137.431C153.205 137.591 151.508 137.668 149.927 137.668V137.665ZM157.547 120.963C155.116 121.011 140.141 121.574 137.813 127.906C137.088 129.867 137.192 131.416 138.13 132.641C140.186 135.332 146.281 136.446 154.855 135.688C159.8 135.253 162.803 133.858 163.544 131.655C164.405 129.088 162.277 125.297 157.55 120.961L157.547 120.963Z" fill="#1C1C1C"/>
|
||||
<path d="M100.616 46.8054C80.2899 35.6035 65.3176 38.7095 71.7396 52.8316L100.616 46.8054Z" fill="#F4D93B"/>
|
||||
<path d="M71.743 53.7029C71.4108 53.7029 71.0946 53.5142 70.9485 53.1927C68.0231 46.7628 69.6599 43.3299 71.5463 41.5842C76.3981 37.0912 87.9747 38.8422 101.039 46.0427C101.462 46.2739 101.616 46.8053 101.382 47.2277C101.148 47.6502 100.619 47.8016 100.197 47.5705C86.1015 39.8014 76.2466 39.6101 72.7314 42.8649C70.077 45.3227 71.1611 49.4463 72.5374 52.4727C72.7367 52.9111 72.5427 53.4292 72.1043 53.6285C71.9874 53.6816 71.8652 53.7082 71.743 53.7082V53.7029Z" fill="#232323"/>
|
||||
<path d="M174.283 115.362C168.265 123.375 156.484 129.659 133.976 129.659C131.848 129.659 129.815 129.604 127.876 129.495C123.46 129.247 119.517 128.729 116.001 127.985C101.956 125.015 94.7002 118.444 91.084 111.161C86.2907 101.506 87.8929 90.6011 88.5518 85.1861C89.6146 76.4552 90.7253 70.2882 91.7456 64.4747C92.0059 62.9814 92.261 61.5147 92.5081 60.0321C93.1299 56.3043 93.903 52.8635 94.8117 49.6883C101.154 27.4916 114.091 18.3488 127.926 16.3932C129.93 16.1089 131.952 15.9761 133.976 15.9761C138.254 15.9761 142.59 16.5022 146.775 17.7696C160.206 21.8375 172.086 33.5469 175.444 60.0321C176.372 67.3389 178.069 74.2153 179.403 85.1861C180.161 91.4062 182.161 104.867 174.283 115.362Z" fill="#F4D93B"/>
|
||||
<path d="M129.171 17.7694C129.171 17.7694 107.25 19.5257 97.8895 49.7174L95.2936 51.1947L94.8047 49.6882C101.147 27.4914 114.084 18.3486 127.919 16.3931L129.171 17.7694Z" fill="white"/>
|
||||
<path d="M174.284 115.362C175.424 110.515 177.432 104.298 176.872 97.9079C174.932 75.8546 175.277 30.1539 144.047 18.8191L146.776 17.7695C160.207 21.8374 172.086 33.5469 175.445 60.0321C176.372 67.3389 178.07 74.2152 179.404 85.1861C180.161 91.4062 182.162 104.867 174.284 115.362Z" fill="white"/>
|
||||
<path d="M121.074 101.628C120.253 107.898 118.297 117.926 116.001 127.986C101.956 125.015 94.7002 118.444 91.084 111.161C86.2907 101.506 87.8929 90.6013 88.5518 85.1863C89.6146 76.4553 90.7253 70.2884 91.7456 64.4748L94.368 54.9733C137.813 54.333 124.794 73.1978 121.074 101.628Z" fill="#E2B639"/>
|
||||
<path d="M133.974 130.53C115.279 130.53 101.954 126.178 94.3657 117.596C84.9758 106.973 86.7453 92.6493 87.5956 85.7676L87.6806 85.0795C88.685 76.8215 89.7132 70.9734 90.707 65.3166C91.0338 63.454 91.342 61.6951 91.6449 59.8883C94.2674 44.1561 99.7674 32.343 107.996 24.7784C114.979 18.3564 123.72 15.1016 133.974 15.1016C144.227 15.1016 153.445 18.205 160.382 24.3214C168.906 31.8381 174.265 43.816 176.306 59.9228C176.707 63.0953 177.267 66.2518 177.863 69.5944C178.636 73.9492 179.513 78.8833 180.264 85.0795L180.349 85.7676C181.2 92.652 182.972 106.973 173.579 117.596C165.994 126.178 152.666 130.53 133.971 130.53H133.974ZM133.974 16.8472C124.172 16.8472 115.829 19.948 109.176 26.0644C101.242 33.3606 95.9227 44.8363 93.364 60.1779C93.0611 61.9926 92.7529 63.7542 92.4234 65.6195C91.4323 71.2523 90.4094 77.0792 89.4103 85.292L89.3253 85.9855C88.5069 92.6148 86.8011 106.41 95.6703 116.446C102.911 124.637 115.797 128.79 133.971 128.79C152.145 128.79 165.032 124.637 172.272 116.446C181.141 106.41 179.438 92.6148 178.617 85.9855L178.532 85.2947C177.785 79.1437 176.914 74.2335 176.143 69.9026C175.546 66.5388 174.982 63.361 174.573 60.146C170.035 24.3613 149.988 16.8499 133.971 16.8499L133.974 16.8472Z" fill="#1C1C1C"/>
|
||||
<path d="M135.676 20.3044L126.961 19.5631C127.338 17.4136 127.864 15.5564 128.483 13.994C131.411 6.59157 136.401 5.82901 137.307 12.1208C137.307 12.1208 140.036 6.16113 147.21 7.88022C149.187 8.35583 150.143 9.60198 150.228 11.1324C150.454 15.1312 144.726 21.0776 135.676 20.3044Z" fill="#F4D93B"/>
|
||||
<path d="M150.229 11.1324C147.761 9.91816 140.659 9.25922 138.754 11.2706C136.508 13.6406 137.282 12.0863 136.928 11.244C136.575 10.3991 130.108 11.0235 127.969 18.3914L128.484 13.994C131.412 6.59157 136.402 5.82901 137.308 12.1208C137.308 12.1208 140.037 6.16113 147.211 7.88022C149.188 8.35583 150.144 9.60198 150.229 11.1324Z" fill="white"/>
|
||||
<path d="M126.453 19.475C126.299 15.4656 128.345 9.79818 131.853 7.54768C133.524 6.48222 135.604 6.64961 136.824 8.29697C137.621 9.36243 137.95 10.768 138.043 12.0115L136.619 11.8069C136.725 11.581 136.816 11.4216 136.922 11.2409C138.968 7.76025 142.871 6.13681 146.796 6.95251C152.516 7.97281 151.823 13.4516 148.488 16.7782C146.277 19.0287 143.31 20.4236 140.225 20.8966C138.692 21.1251 137.129 21.1011 135.623 20.8514C135.32 20.8009 135.115 20.5166 135.166 20.2137C135.211 19.9427 135.445 19.7514 135.71 19.7487C137.156 19.7434 138.58 19.6371 139.967 19.3714C142.704 18.8586 145.371 17.6523 147.308 15.641C148.873 14.0547 150.592 10.8185 148.26 9.20301C147.771 8.86557 147.075 8.67958 146.487 8.56533C145.812 8.43779 145.13 8.37136 144.457 8.38996C141.747 8.40591 139.242 10.0665 137.982 12.4286C137.663 13.0769 136.678 12.9255 136.558 12.224C136.21 10.6936 135.341 8.16146 133.375 9.0861C129.985 11.0231 128.956 16.2149 127.45 19.6478C127.253 20.1606 126.461 20.025 126.451 19.4724L126.453 19.475Z" fill="#1C1C1C"/>
|
||||
<path d="M121.04 44.7142C127.356 40.8907 130.499 40.6516 136.932 44.8629C138.415 45.8407 137.939 47.3233 136.634 47.8574C133.52 49.1036 124.715 49.1912 121.216 48.0062C119.614 47.4429 119.319 45.7823 121.038 44.7142H121.04Z" fill="#FF814A"/>
|
||||
<path d="M120.694 44.1455C122.944 42.8516 125.41 41.5815 128.054 41.2759C130.761 40.9624 133.434 42.0545 135.708 43.3405C136.096 43.545 136.466 43.7762 136.838 43.9994C137.263 44.2545 137.698 44.4989 138.031 44.9054C139.064 46.048 138.522 47.7644 137.22 48.4313C136.078 48.984 134.784 49.2045 133.546 49.3905C129.626 49.8581 125.622 49.9166 121.764 48.976C121.345 48.8352 120.744 48.6917 120.367 48.4286C118.541 47.2941 118.956 45.11 120.694 44.1455ZM121.39 45.2827C120.51 45.8274 120.205 46.8052 121.334 47.233C123.162 47.7963 125.115 47.8574 127.068 47.9504C129.967 48.0035 132.908 47.9902 135.738 47.3286C135.868 47.2808 136.378 47.1453 136.495 47.0736C137.446 46.5714 137.202 45.7982 136.365 45.3173C135.929 45.033 135.491 44.7248 135.044 44.4644C132.937 43.1731 130.636 42.1288 128.16 42.3706C125.67 42.6576 123.502 43.9675 121.387 45.2827H121.39Z" fill="#232323"/>
|
||||
<path d="M111.373 54.4653L109.935 65.7577L100.806 137.407C100.806 142.152 83.2321 146 61.5535 146C60.5518 146 59.5607 145.992 58.5776 145.976C44.0969 145.737 31.8108 143.781 25.9601 141.036C23.6112 139.934 22.3013 138.703 22.3013 137.407L11.7344 54.4653H111.373Z" fill="#9F9DA8"/>
|
||||
<path d="M109.936 65.7583L100.806 137.408C100.806 142.153 83.2326 146 61.554 146C60.5523 146 59.5612 145.992 58.5781 145.976C58.703 88.4253 69.0175 62.1793 109.936 65.7583Z" fill="#8A8899"/>
|
||||
<path d="M25.9601 141.036C23.6112 139.934 22.3013 138.703 22.3013 137.407L11.7344 54.4653H12.5766L15.5126 57.4545L25.9601 141.036Z" fill="white"/>
|
||||
<path d="M61.5596 146.871C51.0139 146.871 41.0899 145.97 33.6183 144.334C25.5915 142.577 21.4944 140.266 21.4359 137.468L10.8743 54.5742C10.8424 54.3244 10.9194 54.0747 11.0842 53.8887C11.2489 53.7027 11.488 53.5938 11.7378 53.5938H111.376C111.626 53.5938 111.865 53.7027 112.03 53.8887C112.194 54.0747 112.271 54.3271 112.24 54.5742L101.678 137.468C101.619 140.268 97.5223 142.577 89.4955 144.334C82.0213 145.97 72.1 146.871 61.5542 146.871H61.5596ZM12.7315 55.3368L23.1736 137.298C23.1789 137.335 23.1816 137.372 23.1816 137.407C23.1816 137.885 23.7023 140.377 33.993 142.63C41.3476 144.241 51.1387 145.125 61.5622 145.125C71.9857 145.125 81.7768 144.238 89.1314 142.63C99.4221 140.377 99.9429 137.885 99.9429 137.407C99.9429 137.37 99.9429 137.332 99.9508 137.298L110.393 55.3368H12.7342H12.7315Z" fill="#1C1C1C"/>
|
||||
<path d="M111.373 54.4654C111.373 57.6273 101.868 60.3986 87.6135 61.9396C80.0331 62.7606 71.1081 63.2336 61.5535 63.2336C47.0169 63.2336 33.9364 62.1389 24.8282 60.3906C16.6977 58.8283 11.7344 56.7505 11.7344 54.4654C11.7344 51.2451 21.5919 48.4313 36.2826 46.9062C43.6903 46.1383 52.3283 45.6973 61.5535 45.6973C69.3014 45.6973 76.6374 46.0081 83.1763 46.5634C99.8597 47.9796 111.373 50.9847 111.373 54.4654Z" fill="#232323"/>
|
||||
<path d="M61.5578 64.1055C47.5792 64.1055 34.1321 63.064 24.6677 61.2466C15.509 59.4876 10.8672 57.2052 10.8672 54.4658C10.8672 52.4306 13.2824 50.7141 18.2511 49.2156C22.5979 47.9057 28.8021 46.8057 36.1966 46.0404C43.8567 45.246 52.6249 44.8262 61.5578 44.8262C69.1622 44.8262 76.461 45.1184 83.255 45.695C91.6273 46.4071 98.7215 47.5151 103.767 48.8994C109.475 50.467 112.251 52.2871 112.251 54.4658C112.251 56.4692 109.913 58.1618 105.101 59.6444C100.903 60.9383 94.8901 62.0304 87.7135 62.8062C79.8673 63.6565 70.8228 64.1055 61.5605 64.1055H61.5578ZM61.5578 46.5692C52.686 46.5692 43.979 46.9863 36.3772 47.7728C17.5788 49.7257 12.6102 53.0045 12.6102 54.4658C12.6102 55.7067 15.8544 57.7791 24.9972 59.5354C34.3579 61.3316 47.6829 62.3625 61.5578 62.3625C70.7591 62.3625 79.7371 61.9161 87.5249 61.0738C105.704 59.1077 110.505 55.8927 110.505 54.4658C110.505 52.574 103.209 49.1385 83.1062 47.4327C76.3627 46.8588 69.1117 46.5692 61.5578 46.5692Z" fill="#232323"/>
|
||||
<path d="M65.278 73.9174C50.4545 73.9174 32.1025 71.7679 12.7567 64.3973C12.483 64.2937 12.3449 63.9855 12.4485 63.7118C12.5521 63.4381 12.8603 63.3 13.134 63.4036C39.8503 73.5826 64.6696 73.7288 80.7844 72.0575C98.257 70.2454 109.555 65.9676 109.666 65.9251C109.94 65.8188 110.248 65.957 110.352 66.2306C110.458 66.5043 110.32 66.8125 110.046 66.9162C109.932 66.9587 98.5333 71.279 80.9305 73.1097C76.3578 73.5853 71.0836 73.9147 65.2727 73.9147L65.278 73.9174Z" fill="#1C1C1C"/>
|
||||
<path d="M59.9036 138.648C48.4173 138.648 34.9515 137.13 21.3981 132.231C21.1217 132.13 20.9782 131.827 21.0792 131.551C21.1802 131.274 21.4831 131.131 21.7594 131.232C42.2317 138.632 62.5234 138.241 75.9413 136.61C90.4965 134.84 100.33 131.269 100.428 131.232C100.705 131.131 101.01 131.272 101.111 131.548C101.212 131.824 101.071 132.13 100.795 132.231C100.697 132.268 90.7702 135.874 76.1008 137.662C71.4351 138.23 65.943 138.65 59.9036 138.65V138.648Z" fill="#1C1C1C"/>
|
||||
<path d="M28.3625 125.363C27.1482 125.363 26.1093 124.443 25.9844 123.208L21.34 76.6222C21.2098 75.3069 22.1689 74.1352 23.4815 74.005C24.7941 73.8748 25.9658 74.834 26.0987 76.1466L30.7432 122.732C30.8733 124.047 29.9142 125.219 28.6016 125.349C28.5219 125.357 28.4422 125.363 28.3625 125.363Z" fill="#232323"/>
|
||||
<path d="M43.4128 127.828C42.1215 127.828 41.056 126.797 41.0242 125.498L39.8524 80.3419C39.8179 79.0214 40.8594 77.924 42.1799 77.8895C43.4952 77.855 44.5978 78.8965 44.6324 80.2171L45.8041 125.373C45.8387 126.694 44.7971 127.791 43.4766 127.826C43.4553 127.826 43.4341 127.826 43.4128 127.826V127.828Z" fill="#232323"/>
|
||||
<path d="M60.6807 128.995C60.6807 128.995 60.6568 128.995 60.6435 128.995C59.323 128.976 58.2681 127.89 58.2894 126.569L58.9643 81.4077C58.9829 80.0872 60.0749 79.0297 61.3901 79.0536C62.7107 79.0722 63.7655 80.1589 63.7442 81.4795L63.0694 126.641C63.0508 127.948 61.9826 128.998 60.678 128.998L60.6807 128.995Z" fill="#232323"/>
|
||||
<path d="M93.2992 125.362C93.2195 125.362 93.1398 125.36 93.0601 125.349C91.7449 125.219 90.7857 124.047 90.9185 122.732L95.563 76.1465C95.6932 74.8313 96.8676 73.8721 98.1802 74.005C99.4954 74.1352 100.455 75.3069 100.322 76.6221L95.6773 123.208C95.5551 124.441 94.5135 125.362 93.2992 125.362Z" fill="#232323"/>
|
||||
<path d="M78.2515 127.828C78.2303 127.828 78.209 127.828 78.1877 127.828C76.8672 127.794 75.8257 126.696 75.8602 125.376L77.0319 80.2199C77.0665 78.9206 78.1293 77.8896 79.4206 77.8896C79.4419 77.8896 79.4631 77.8896 79.4844 77.8896C80.8049 77.9242 81.8465 79.0215 81.8119 80.3421L80.6402 125.498C80.6056 126.797 79.5428 127.828 78.2515 127.828Z" fill="#232323"/>
|
||||
<path d="M92.3543 60.3824L87.6115 61.9394C80.031 62.7605 71.1061 63.2334 61.5514 63.2334C49.0342 63.2334 37.5984 62.423 28.8462 61.0786C27.4353 60.8633 26.0908 60.6322 24.8261 60.3904L24.7969 60.3824C24.7969 60.3824 24.7995 60.3718 24.7995 60.3665C25.7587 55.1507 30.815 50.5116 36.2805 46.906C40.9622 43.8186 45.9414 41.4963 48.8987 40.2316C50.5381 39.5301 51.5584 39.1528 51.5584 39.1528L74.5708 41.313C78.0409 42.7265 80.8733 44.5731 83.1743 46.5633C90.3801 52.7833 92.3543 60.3824 92.3543 60.3824Z" fill="#20D34F"/>
|
||||
<path d="M50.7588 41.2815C50.7588 41.2815 31.785 53.0202 28.8411 61.0789C28.6816 61.52 28.5674 61.9478 28.5089 62.3623L24.6562 61.2463L24.821 60.3907L24.7944 60.3668C25.7536 55.1511 30.8099 50.5119 36.2754 46.9064C40.9571 43.8189 45.9363 41.4967 48.8936 40.2319L50.7588 41.2815Z" fill="white"/>
|
||||
<path d="M61.5571 64.1048C47.5785 64.1048 34.1313 63.0633 24.667 61.2459C24.6457 61.2406 24.6245 61.2379 24.6032 61.2326L24.574 61.2246C24.1356 61.1077 23.8619 60.672 23.9443 60.2256C24.7999 55.5306 28.7907 50.8038 35.8052 46.1779C43.0828 41.3794 51.1814 38.3636 51.2611 38.3344C51.3833 38.2892 51.5135 38.2733 51.6437 38.2839L74.6562 40.4441C74.7412 40.4521 74.8236 40.4733 74.9033 40.5052C78.1687 41.8364 81.1446 43.6511 83.7485 45.9043C91.0765 52.228 93.1198 59.843 93.2022 60.1645C93.3191 60.6109 93.0667 61.0679 92.6309 61.2113L87.8881 62.7684C87.8297 62.787 87.7712 62.8002 87.7101 62.8056C79.8639 63.6558 70.8194 64.1048 61.5571 64.1048ZM25.8547 59.6942C35.1622 61.392 48.1019 62.3618 61.5597 62.3618C70.7264 62.3618 79.67 61.9208 87.4338 61.0838L91.2705 59.8244C90.5584 57.7705 88.191 52.0393 82.6113 47.2248C80.1828 45.1231 77.4088 43.4253 74.3665 42.1711L51.6836 40.0429C50.456 40.5132 43.2715 43.3482 36.7671 47.6366C30.6054 51.7019 26.9388 55.7538 25.8547 59.6968V59.6942Z" fill="#232323"/>
|
||||
<path d="M73.0661 37.8245L52.6549 36.9291C52.2006 36.201 51.8684 35.5527 51.6399 34.9762C49.8704 30.4938 54.2491 30.2865 54.2491 30.2865C55.676 24.7387 60.1185 25.8094 63.469 27.5764C65.7354 28.772 67.5023 30.2865 67.5023 30.2865C79.2251 24.4411 73.0661 37.8245 73.0661 37.8245Z" fill="#20D34F"/>
|
||||
<path d="M65.7886 29.5904C65.5229 29.5319 56.7547 27.5631 55.5272 31.9764C55.5272 31.9764 53.0535 32.545 53.9834 36.6209L51.6399 34.9762C49.8704 30.4938 54.2491 30.2865 54.2491 30.2865C55.676 24.7387 60.1185 25.8094 63.469 27.5764L65.7886 29.5904Z" fill="white"/>
|
||||
<path d="M73.0599 38.696C73.0599 38.696 73.0334 38.696 73.0227 38.696L52.6115 37.8006C52.3245 37.7873 52.0615 37.6358 51.91 37.3914C50.353 34.8991 49.9545 32.8877 50.725 31.4158C51.3972 30.1298 52.755 29.6701 53.5601 29.5054C54.227 27.3904 55.4093 26.0539 57.0833 25.5251C60.9253 24.3135 66.0746 28.0413 67.6183 29.2583C70.9768 27.672 73.2459 27.5152 74.5479 28.7747C75.6479 29.8428 75.8418 31.7957 75.1218 34.5829C74.6302 36.4907 73.8836 38.1194 73.8517 38.1885C73.7082 38.4994 73.4 38.696 73.0599 38.696ZM53.1535 36.0788L72.5019 36.9264C73.5249 34.4899 74.3751 31.0358 73.3336 30.0261C72.6082 29.322 70.6234 29.702 67.8867 31.065C67.5732 31.2218 67.1959 31.174 66.9302 30.9481C65.336 29.5851 60.5533 26.2558 57.604 27.1884C56.3845 27.5737 55.5608 28.6578 55.0852 30.5044C54.9895 30.8737 54.6654 31.1394 54.2828 31.158C54.2721 31.158 52.7683 31.2563 52.2634 32.2315C51.8489 33.0312 52.1651 34.389 53.1509 36.0788H53.1535Z" fill="#232323"/>
|
||||
<path d="M74.6803 37.9618L51.0823 36.4317C49.9841 36.3605 49.036 37.1931 48.9648 38.2913C48.8936 39.3896 49.7262 40.3376 50.8244 40.4089L74.4224 41.939C75.5206 42.0102 76.4687 41.1776 76.5399 40.0794C76.6111 38.9811 75.7785 38.033 74.6803 37.9618Z" fill="#9F9DA8"/>
|
||||
<path d="M74.5503 42.8173C74.4866 42.8173 74.4254 42.8173 74.3617 42.812L50.7647 41.2816C50.0021 41.2311 49.3033 40.8883 48.7985 40.3144C48.2937 39.7405 48.0439 39.0019 48.0917 38.2393C48.1422 37.4767 48.485 36.7779 49.0589 36.2731C49.6355 35.7683 50.3715 35.5185 51.134 35.5663L74.731 37.0968C75.4936 37.1473 76.1924 37.49 76.6972 38.0639C77.202 38.6378 77.4518 39.3765 77.404 40.1391C77.3535 40.9016 77.0107 41.6004 76.4368 42.1053C75.9107 42.5676 75.2465 42.8173 74.5503 42.8173ZM74.4733 41.0717C74.7735 41.0903 75.0605 40.992 75.2863 40.7953C75.5122 40.5987 75.645 40.3251 75.6663 40.0248C75.6849 39.7246 75.5866 39.4376 75.3899 39.2118C75.1933 38.9859 74.9197 38.8531 74.6194 38.8318L51.0224 37.3014C50.7222 37.2828 50.4352 37.3811 50.2094 37.5777C49.9835 37.7743 49.8507 38.048 49.8294 38.3482C49.8108 38.6485 49.9091 38.9354 50.1057 39.1613C50.3024 39.3871 50.576 39.52 50.8763 39.5412L74.4733 41.0717Z" fill="#232323"/>
|
||||
<path d="M111.235 40.3833C111.062 40.3833 110.889 40.3541 110.719 40.2877C109.964 40.0034 109.582 39.1584 109.869 38.4038C111.349 34.4874 114.266 33.3449 116.506 33.5813C119.158 33.863 121.299 35.9673 121.714 38.7014C121.836 39.4985 121.286 40.2452 120.489 40.3647C119.692 40.4843 118.945 39.9369 118.826 39.1398C118.61 37.7183 117.532 36.6289 116.201 36.4881C114.705 36.3287 113.36 37.4314 112.606 39.4374C112.385 40.022 111.83 40.3833 111.237 40.3833H111.235Z" fill="#1C1C1C"/>
|
||||
<path d="M147.634 40.3833C147.044 40.3833 146.489 40.022 146.266 39.4374C145.509 37.434 144.164 36.3314 142.671 36.4881C141.34 36.6289 140.261 37.7183 140.046 39.1398C139.924 39.9369 139.18 40.4869 138.383 40.3647C137.585 40.2425 137.035 39.4985 137.158 38.7014C137.572 35.9673 139.714 33.863 142.365 33.5813C144.603 33.3449 147.523 34.4874 149.003 38.4038C149.287 39.1584 148.907 40.0034 148.152 40.2877C147.982 40.3514 147.807 40.3833 147.637 40.3833H147.634Z" fill="#1C1C1C"/>
|
||||
<path d="M193.199 114.384C187.58 108.953 173.386 102.09 156.054 96.898C138.723 91.7061 123.094 89.6337 115.415 91.0791L111.839 90.0083L110.51 93.9938C108.879 99.4381 126.625 109.561 150.148 116.61C173.67 123.657 194.06 124.959 195.692 119.515L197.02 115.529L193.197 114.384H193.199Z" fill="#B7B6BF"/>
|
||||
<path d="M184.999 123.75C183.675 123.75 182.344 123.699 181.072 123.617C172.404 123.064 161.333 120.872 149.897 117.445C138.458 114.017 128.006 109.761 120.462 105.456C114.891 102.278 108.498 97.6658 109.672 93.7414C109.672 93.7334 109.677 93.7255 109.68 93.7148L111.009 89.7293C111.157 89.2829 111.633 89.0332 112.085 89.1687L115.459 90.181C123.571 88.7462 139.55 91.0419 156.3 96.061C173.047 101.077 187.658 107.948 193.645 113.605L197.266 114.689C197.492 114.756 197.681 114.913 197.79 115.122C197.898 115.332 197.917 115.577 197.843 115.8L196.517 119.775C195.563 122.908 190.326 123.744 184.993 123.744L184.999 123.75ZM111.341 94.2569C110.605 96.7784 116.014 100.913 121.328 103.944C128.76 108.185 139.083 112.386 150.399 115.776C161.715 119.166 172.646 121.332 181.186 121.877C187.3 122.267 194.102 121.786 194.859 119.265C194.859 119.257 194.864 119.249 194.867 119.238L195.911 116.106L192.951 115.218C192.818 115.178 192.696 115.106 192.595 115.011C186.893 109.503 172.455 102.722 155.806 97.7323C139.157 92.745 123.369 90.468 115.579 91.9346C115.443 91.9612 115.302 91.9532 115.167 91.9134L112.401 91.0844L111.343 94.2542L111.341 94.2569Z" fill="#232323"/>
|
||||
<path d="M197.02 115.529C195.981 119.004 187.295 119.73 175.033 117.968C168.085 116.972 159.984 115.173 151.476 112.625C127.956 105.578 110.207 95.4524 111.838 90.0082C113.47 84.5639 133.86 85.8659 157.382 92.9123C164.179 94.9502 170.492 97.2432 175.997 99.6053C189.543 105.422 198.181 111.66 197.02 115.529Z" fill="#9F9DA8"/>
|
||||
<path d="M197.017 115.529C195.979 119.004 187.293 119.73 175.031 117.968C175.326 117.979 175.636 117.995 175.961 118.011C200.565 119.249 197.589 109.599 173.492 99.4966L175.995 99.6055C189.541 105.422 198.179 111.66 197.017 115.529Z" fill="white"/>
|
||||
<path d="M186.335 119.764C185.011 119.764 183.68 119.714 182.408 119.631C173.74 119.078 162.668 116.886 151.233 113.459C139.794 110.031 129.342 105.775 121.798 101.47C116.226 98.2926 109.834 93.68 111.008 89.7556C112.182 85.8338 120.063 85.4964 126.461 85.9029C135.128 86.4556 146.2 88.6476 157.636 92.0752C178.688 98.383 199.939 108.833 197.858 115.776C196.917 118.919 191.67 119.761 186.332 119.761L186.335 119.764ZM122.696 87.5264C117.783 87.5264 113.282 88.2358 112.677 90.2578C111.922 92.7793 117.34 96.9216 122.659 99.9559C130.091 104.197 140.413 108.397 151.73 111.788C163.043 115.178 173.977 117.343 182.516 117.888C188.63 118.279 195.432 117.798 196.189 115.276C196.944 112.755 191.526 108.612 186.207 105.578C178.775 101.338 168.453 97.1368 157.137 93.7465C145.82 90.3561 134.887 88.1906 126.35 87.6459C125.141 87.5689 123.905 87.5264 122.696 87.5264Z" fill="#232323"/>
|
||||
<path d="M164.6 106.976C164.412 106.976 164.22 106.949 164.029 106.894C162.974 106.58 162.374 105.469 162.69 104.415C163.928 100.264 161.157 98.4788 154.99 95.6544C154.06 95.2293 153.098 94.7882 152.203 94.3339C149.086 92.7503 146.803 92.2773 145.228 92.8964C143.897 93.4172 142.791 94.8467 141.941 97.1423C141.558 98.1732 140.413 98.702 139.38 98.3194C138.349 97.9368 137.82 96.7916 138.203 95.758C139.465 92.3491 141.341 90.1358 143.777 89.1846C147.369 87.779 151.339 89.4237 154.009 90.7814C154.833 91.1986 155.715 91.6051 156.65 92.0329C161.808 94.395 168.87 97.6312 166.508 105.554C166.25 106.418 165.458 106.979 164.6 106.979V106.976Z" fill="#232323"/>
|
||||
<path d="M153.389 70.5537C139.259 91.3847 144.509 114.857 171.688 90.1492L153.389 70.5537Z" fill="#F4D93B"/>
|
||||
<path d="M152.855 101.628C150.517 101.628 148.944 100.804 147.929 99.922C145.285 97.629 144.331 93.2529 145.243 87.6014C146.144 82.0243 148.779 75.7962 152.667 70.0624C152.938 69.6638 153.48 69.5602 153.878 69.8312C154.277 70.1022 154.38 70.6443 154.109 71.0428C150.36 76.5694 147.823 82.5477 146.962 87.8804C146.162 92.841 146.93 96.7495 149.069 98.6067C150.918 100.212 156.766 102.534 171.098 89.5065C171.454 89.1823 172.007 89.2089 172.328 89.5649C172.653 89.921 172.626 90.4736 172.27 90.7978C162.957 99.263 156.811 101.63 152.852 101.63L152.855 101.628Z" fill="#232323"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 50 KiB |
@@ -1,3 +1,3 @@
|
||||
=description: This file stores settings for Dart & Flutter DevTools.
|
||||
description: This file stores settings for Dart & Flutter DevTools.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ente es una aplicación simple para hacer copias de seguridad y compartir tus fotos y vídeos.
|
||||
ente es una aplicación simple para hacer copias de seguridad y compartir tus fotos y videos.
|
||||
|
||||
Si has estado buscando una alternativa a Google Photos que sea amigable con la privacidad, has llegado al lugar correcto. Con Ente, se almacenan cifradas de extremo a extremo (e2ee). Esto significa que solo tú puedes verlas.
|
||||
|
||||
|
||||
@@ -6,23 +6,23 @@ Kami menyediakan app untuk Android, iOS, web, serta desktop, dan fotomu akan ter
|
||||
|
||||
ente juga dapat memudahkan kamu untuk membagikan album ke orang tersayang, meski mereka tidak punya akun ente. Kamu dapat membagikan link berbagi publik, di mana mereka bisa melihat album kamu dan berkolaborasi dengan menambahkan foto, tanpa akun atau app.
|
||||
|
||||
Data terenkripsi kamu tersimpan di 3 lokasi berbeda, termasuk di salah satu tempat pengungsian di Paris. Kami menanggapi keturunan anda dengan serius dan memudahkan untuk memastikan kenangan anda tetap ada setelah anda.
|
||||
Data terenkripsi kamu tersimpan di 3 lokasi berbeda, termasuk di salah satu tempat pengungsian di Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
|
||||
Kami ingin membuat app foto yang paling aman sepanjang masa–jadi, bergabunglah dengan kami!
|
||||
|
||||
FITUR
|
||||
- Pencadangan kualitas asli, karena setiap piksel berarti
|
||||
- Paket keluarga, sehingga kamu bisa bagikan kuota penyimpananmu dengan keluarga
|
||||
- Album kolaboratif, sehingga anda dapat mengumpulkan foto bersama setelah sebuah perjalanan
|
||||
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
|
||||
- Collaborative albums, so you can pool together photos after a trip
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Link album, yang bisa dilindungi dengan sandi
|
||||
Kemampuan untuk membebaskan kapasitas, dengan menghilangkan files yang sudah di back-up dengan aman
|
||||
- Dukungan manusia, karena anda layak mendapatkannya
|
||||
- Deskripsi, sehingga anda dapat memberi keterangan pada memori anda dan menemukannya dengan mudah
|
||||
- Human support, because you're worth it
|
||||
- Descriptions, so you can caption your memories and find them easily
|
||||
- Editor gambar, untuk menyempurnakan fotomu
|
||||
- Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- Pengimporan mudah dari Google, Apple, hard drive-mu, dan lainnya
|
||||
- Tema gelap, karena foto anda terlihat bagus di dalamnya
|
||||
- Dark theme, because your photos look good in it
|
||||
- Autentikasi dua atau tiga faktor dan autentikasi biometrik
|
||||
- dan BANYAK LAGI!
|
||||
|
||||
|
||||
@@ -6,20 +6,20 @@ Kami menyediakan app untuk semua platform, dan fotomu akan tersinkron di semua p
|
||||
|
||||
Ente juga memudahkan kamu untuk membagikan album ke kerabatmu. Kamu bisa membagikannya secara langsung ke pengguna Ente lain, terenkripsi ujung ke ujung; atau dengan link yang dapat dilihat publik.
|
||||
|
||||
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. Kami menanggapi keturunan anda dengan serius dan memudahkan untuk memastikan kenangan anda tetap ada setelah anda.
|
||||
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
|
||||
Kami ingin membuat app foto yang paling aman sepanjang masa–jadi, bergabunglah dengan kami!
|
||||
|
||||
FITUR
|
||||
- Pencadangan kualitas asli, karena setiap piksel berarti
|
||||
- Paket keluarga, sehingga kamu bisa bagikan kuota penyimpananmu dengan keluarga
|
||||
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Link album, yang bisa dilindungi dengan sandi dan diatur waktu kedaluwarsanya
|
||||
- Kemampuan untuk mengosongkan ruang, dengan menghapus file yang telah dicadangkan dengan aman
|
||||
- Ability to free up space, by removing files that have been safely backed up
|
||||
- Editor gambar, untuk menyempurnakan fotomu
|
||||
Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
|
||||
Impor sekali klik dari semua penyedia penyimpanan utama
|
||||
Tema gelap, karena foto anda terlihat bagus di dalamnya
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- One-click import from all major storage providers
|
||||
- Dark theme, because your photos look good in it
|
||||
- Autentikasi dua atau tiga faktor dan autentikasi biometrik
|
||||
- dan BANYAK LAGI!
|
||||
|
||||
@@ -27,7 +27,7 @@ HARGA
|
||||
Kami tidak menyediakan paket yang gratis seumur hidup, karena penting bagi kami untuk tetap berdiri dan bertahan hingga masa depan. Namun, kami menyediakan paket yang terjangkau, yang bisa kamu bagikan dengan keluargamu. Kamu bisa menemukan informasi lebih lanjut di ente.io.
|
||||
|
||||
DUKUNGAN
|
||||
Kami bangga dapat menawarkan dukungan manusia. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
|
||||
We take pride in offering human support. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
|
||||
|
||||
KETENTUAN
|
||||
https://ente.io/terms
|
||||
|
||||
@@ -6,20 +6,20 @@ Kami menyediakan app untuk Android, iOS, Web, serta Desktop, dan fotomu akan ter
|
||||
|
||||
Ente juga memudahkan kamu untuk membagikan album ke kerabatmu. Kamu bisa membagikannya secara langsung ke pengguna Ente lain, terenkripsi ujung ke ujung; atau dengan link yang dapat dilihat publik.
|
||||
|
||||
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. Kami menanggapi keturunan anda dengan serius dan memudahkan untuk memastikan kenangan anda tetap ada setelah anda.
|
||||
Data terenkripsi kamu tersimpan di berbagai lokasi, termasuk di salah satu tempat pengungsian di Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
|
||||
Kami ingin membuat app foto yang paling aman sepanjang masa–jadi, bergabunglah dengan kami!
|
||||
|
||||
✨ FITUR
|
||||
- Pencadangan kualitas asli, karena setiap piksel berarti
|
||||
- Paket keluarga, sehingga kamu bisa bagikan kuota penyimpananmu dengan keluarga
|
||||
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Link album, yang bisa dilindungi dengan sandi dan diatur waktu kedaluwarsanya
|
||||
- Kemampuan untuk mengosongkan ruang, dengan menghapus file yang telah dicadangkan dengan aman
|
||||
- Ability to free up space, by removing files that have been safely backed up
|
||||
- Editor gambar, untuk menyempurnakan fotomu
|
||||
- Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- Pengimporan mudah dari Google, Apple, hard drive-mu, dan lainnya
|
||||
- Tema gelap, karena foto anda terlihat bagus di dalamnya
|
||||
- Dark theme, because your photos look good in it
|
||||
- Autentikasi dua atau tiga faktor dan autentikasi biometrik
|
||||
- dan BANYAK LAGI!
|
||||
|
||||
@@ -27,4 +27,4 @@ Kami ingin membuat app foto yang paling aman sepanjang masa–jadi, bergabunglah
|
||||
Kami tidak menyediakan paket yang gratis seumur hidup, karena penting bagi kami untuk tetap berdiri dan bertahan hingga masa depan. Namun, kami menyediakan paket yang terjangkau, yang bisa kamu bagikan dengan keluargamu. Kamu bisa menemukan informasi lebih lanjut di ente.io.
|
||||
|
||||
🙋 DUKUNGAN
|
||||
Kami bangga dapat menawarkan dukungan manusia. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
|
||||
We take pride in offering human support. Jika kamu adalah pelanggan berbayar, kamu bisa menghubungi team@ente.io dan menunggu balasan dari tim kami dalam 24 jam.
|
||||
@@ -181,8 +181,6 @@ PODS:
|
||||
- PromisesObjC (2.4.0)
|
||||
- receive_sharing_intent (1.8.1):
|
||||
- Flutter
|
||||
- rive_common (0.0.1):
|
||||
- Flutter
|
||||
- rust_lib_photos (0.0.1):
|
||||
- Flutter
|
||||
- SDWebImage (5.21.1):
|
||||
@@ -293,7 +291,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`)
|
||||
- rive_common (from `.symlinks/plugins/rive_common/ios`)
|
||||
- rust_lib_photos (from `.symlinks/plugins/rust_lib_photos/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
@@ -312,7 +309,7 @@ DEPENDENCIES:
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios.git:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios:
|
||||
- ffmpeg_kit_custom
|
||||
trunk:
|
||||
- Firebase
|
||||
@@ -421,8 +418,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/privacy_screen/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
rive_common:
|
||||
:path: ".symlinks/plugins/rive_common/ios"
|
||||
rust_lib_photos:
|
||||
:path: ".symlinks/plugins/rust_lib_photos/ios"
|
||||
sentry_flutter:
|
||||
@@ -457,85 +452,84 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6
|
||||
battery_info: b6c551049266af31556b93c9d9b9452cfec0219f
|
||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
|
||||
dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
emoji_picker_flutter: fe2e6151c5b548e975d546e6eeb567daf0962a58
|
||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
||||
battery_info: 83f3aae7be2fccefab1d2bf06b8aa96f11c8bcdd
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
emoji_picker_flutter: ed468d9746c21711e66b2788880519a9de5de211
|
||||
ffmpeg_kit_custom: 682b4f2f1ff1f8abae5a92f6c3540f2441d5be99
|
||||
ffmpeg_kit_flutter: 9dce4803991478c78c6fb9f972703301101095fe
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e
|
||||
firebase_core: cf4d42a8ac915e51c0c2dc103442f3036d941a2d
|
||||
firebase_messaging: fee490327c1aae28a0da1e65fca856547deca493
|
||||
firebase_core: ece862f94b2bc72ee0edbeec7ab5c7cb09fe1ab5
|
||||
firebase_messaging: e1a5fae495603115be1d0183bc849da748734e2b
|
||||
FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e
|
||||
FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4
|
||||
FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843
|
||||
FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_email_sender: e03bdda7637bcd3539bfe718fddd980e9508efaa
|
||||
flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e
|
||||
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
|
||||
flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||
flutter_sodium: a00383520fc689c688b66fd3092984174712493e
|
||||
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
|
||||
fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
|
||||
flutter_email_sender: aa1e9772696691d02cd91fea829856c11efb8e58
|
||||
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
flutter_sodium: 7e4621538491834eba53bd524547854bcbbd6987
|
||||
flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544
|
||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
in_app_purchase_storekit: a1ce04056e23eecc666b086040239da7619cd783
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
launcher_icon_switcher: 8e0ad2131a20c51c1dd939896ee32e70cd845b37
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
|
||||
local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
maps_launcher: 2e5b6a2d664ec6c27f82ffa81b74228d770ab203
|
||||
media_extension: 6618f07abd762cdbfaadf1b0c56a287e820f0c84
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91
|
||||
motionphoto: 8b65ce50c7d7ff3c767534fc3768b2eed9ac24e4
|
||||
move_to_background: cd3091014529ec7829e342ad2d75c0a11f4378a5
|
||||
maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45
|
||||
media_extension: 671e2567880d96c95c65c9a82ccceed8f2e309fd
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1
|
||||
motionphoto: 23e2aeb5c6380112f69468d71f970fa7438e5ed1
|
||||
move_to_background: 7e3467dd2a1d1013e98c9c1cb93fd53cd7ef9d84
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
native_video_player: 29ab24a926804ac8c4a57eb6d744c7d927c2bc3e
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997
|
||||
native_video_player: 6809dec117e8997161dbfb42a6f90d6df71a504d
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2
|
||||
onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c
|
||||
onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b
|
||||
open_mail_app: 70273c53f768beefdafbe310c3d9086e4da3cb02
|
||||
open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: 81954a1bf804b6e882d0453b3b6bc7fad7b47d3d
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
||||
rive_common: 4743dbfd2911c99066547a3c6454681e0fa907df
|
||||
rust_lib_photos: 8813b31af48ff02ca75520cbc81a363a13d51a84
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
rust_lib_photos: 04d3901908d2972192944083310b65abf410774c
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5
|
||||
sqlite3_flutter_libs: 2c48c4ee7217fd653251975e43412250d5bcbbe2
|
||||
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
|
||||
thermal: a9261044101ae8f532fa29cab4e8270b51b3f55c
|
||||
ua_client_hints: aeabd123262c087f0ce151ef96fa3ab77bfc8b38
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
vibration: 7d883d141656a1c1a6d8d238616b2042a51a1241
|
||||
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
|
||||
video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1
|
||||
volume_controller: 2e3de73d6e7e81a0067310d17fb70f2f86d71ac7
|
||||
wakelock_plus: 76957ab028e12bfa4e66813c99e46637f367fc7e
|
||||
workmanager: 05afacf221f5086e18450250dce57f59bb23e6b0
|
||||
sqlite3_flutter_libs: e7fc8c9ea2200ff3271f08f127842131746b70e2
|
||||
system_info_plus: 555ce7047fbbf29154726db942ae785c29211740
|
||||
thermal: d4c48be750d1ddbab36b0e2dcb2471531bc8df41
|
||||
ua_client_hints: 92fe0d139619b73ec9fcb46cc7e079a26178f586
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
|
||||
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
|
||||
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
|
||||
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
workmanager: b89e4e4445d8b57ee2fdbf1c3925696ebe5b8990
|
||||
|
||||
PODFILE CHECKSUM: cce2cd3351d3488dca65b151118552b680e23635
|
||||
|
||||
|
||||
@@ -565,7 +565,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}/rive_common/rive_common.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/rust_lib_photos/rust_lib_photos.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/sentry_flutter/sentry_flutter.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework",
|
||||
@@ -663,7 +662,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}/rive_common.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/rust_lib_photos.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sentry_flutter.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "IconDuckyHuggingEAny.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "IconDuckyHuggingEDark.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "IconDuckyHuggingETinted.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 104 KiB |
@@ -142,7 +142,7 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: EasyLoading.init(),
|
||||
locale: locale,
|
||||
supportedLocales: appSupportedLocales,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localeListResolutionCallback: localResolutionCallBack,
|
||||
localizationsDelegates: const [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
@@ -164,7 +164,7 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: EasyLoading.init(),
|
||||
locale: locale,
|
||||
supportedLocales: appSupportedLocales,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localeListResolutionCallback: localResolutionCallBack,
|
||||
localizationsDelegates: const [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
|
||||
@@ -29,10 +29,6 @@ class LRUMap<K, V> {
|
||||
}
|
||||
}
|
||||
|
||||
bool containsKey(K key) {
|
||||
return _map.containsKey(key);
|
||||
}
|
||||
|
||||
void remove(K key) {
|
||||
_map.remove(key);
|
||||
}
|
||||
|
||||
39
mobile/apps/photos/lib/core/cache/thumbnail_in_memory_cache.dart
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:photos/core/cache/lru_map.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
|
||||
class ThumbnailInMemoryLruCache {
|
||||
static final LRUMap<String, Uint8List?> _map = LRUMap(1000);
|
||||
|
||||
static Uint8List? get(EnteFile enteFile, [int? size]) {
|
||||
return _map.get(
|
||||
enteFile.cacheKey() +
|
||||
"_" +
|
||||
(size != null ? size.toString() : thumbnailLargeSize.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
static void put(
|
||||
EnteFile enteFile,
|
||||
Uint8List? imageData, [
|
||||
int? size,
|
||||
]) {
|
||||
_map.put(
|
||||
enteFile.cacheKey() +
|
||||
"_" +
|
||||
(size != null ? size.toString() : thumbnailLargeSize.toString()),
|
||||
imageData,
|
||||
);
|
||||
}
|
||||
|
||||
static void clearCache(EnteFile enteFile) {
|
||||
_map.remove(
|
||||
enteFile.cacheKey() + "_" + thumbnailLargeSize.toString(),
|
||||
);
|
||||
_map.remove(
|
||||
enteFile.cacheKey() + "_" + thumbnailSmallSize.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/core/error-reporting/super_logging.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/collections_db.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import "package:photos/db/memories_db.dart";
|
||||
import "package:photos/db/ml/db.dart";
|
||||
import 'package:photos/db/trash_db.dart';
|
||||
import 'package:photos/db/upload_locks_db.dart';
|
||||
import "package:photos/events/endpoint_updated_event.dart";
|
||||
import 'package:photos/events/signed_in_event.dart';
|
||||
@@ -21,16 +23,14 @@ import 'package:photos/events/user_logged_out_event.dart';
|
||||
import 'package:photos/models/api/user/key_attributes.dart';
|
||||
import 'package:photos/models/api/user/key_gen_result.dart';
|
||||
import 'package:photos/models/api/user/private_key_attributes.dart';
|
||||
import 'package:photos/module/upload/service/file_uploader.dart';
|
||||
import "package:photos/service_locator.dart";
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/favorites_service.dart';
|
||||
import "package:photos/services/home_widget_service.dart";
|
||||
import 'package:photos/services/ignored_files_service.dart';
|
||||
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
|
||||
import "package:photos/services/machine_learning/similar_images_service.dart";
|
||||
import 'package:photos/services/search_service.dart';
|
||||
import 'package:photos/services/sync/sync_service.dart';
|
||||
import 'package:photos/utils/file_uploader.dart';
|
||||
import "package:photos/utils/lock_screen_settings.dart";
|
||||
import 'package:photos/utils/validator_util.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -193,13 +193,13 @@ class Configuration {
|
||||
_cachedToken = null;
|
||||
_secretKey = null;
|
||||
await FilesDB.instance.clearTable();
|
||||
// await CollectionsDB.instance.clearTable();
|
||||
await CollectionsDB.instance.clearTable();
|
||||
await MemoriesDB.instance.clearTable();
|
||||
await MLDataDB.instance.clearTable();
|
||||
await remoteDB.clearAllTables();
|
||||
await SimilarImagesService.instance.clearCache();
|
||||
|
||||
await UploadLocksDB.instance.clearTable();
|
||||
await IgnoredFilesService.instance.reset();
|
||||
await TrashDB.instance.clearTable();
|
||||
unawaited(HomeWidgetService.instance.clearWidget(autoLogout));
|
||||
if (!autoLogout) {
|
||||
// Following services won't be initialized if it's the case of autoLogout
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
const int thumbnailSmallSize = 256;
|
||||
const int thumbnailQuality = 50;
|
||||
// thumbnailSmallSize Thumbnail sizes in pixels 256px
|
||||
const int thumbnailSmall256 = 256;
|
||||
// thumbnailMediumSize Thumbnail sizes in pixels 512px
|
||||
const int thumbnailLarge512 = 512; // 512px
|
||||
const int compressThumb1080 = 1080;
|
||||
const int thumbnailDataMaxSize = 100 * 1024;
|
||||
const int thumbnailLargeSize = 512;
|
||||
const int compressedThumbnailResolution = 1080;
|
||||
const int thumbnailDataLimit = 100 * 1024;
|
||||
const String sentryDSN =
|
||||
"https://2235e5c99219488ea93da34b9ac1cb68@sentry.ente.io/4";
|
||||
const String sentryDebugDSN =
|
||||
@@ -111,4 +109,4 @@ final tempDirCleanUpInterval = kDebugMode
|
||||
const kFilterChipHeight = 32.0;
|
||||
const kMaxAppbarFilters = 14;
|
||||
|
||||
const kHashSeprator = ':';
|
||||
const kLivePhotoHashSeparator = ':';
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/// Common runtime exceptions that can occur during normal app operation.
|
||||
/// These are recoverable conditions that should be caught and handled.
|
||||
|
||||
class WidgetUnmountedException implements Exception {
|
||||
final String? message;
|
||||
|
||||
WidgetUnmountedException([this.message]);
|
||||
|
||||
@override
|
||||
String toString() => message != null
|
||||
? 'WidgetUnmountedException: $message'
|
||||
: 'WidgetUnmountedException';
|
||||
}
|
||||
322
mobile/apps/photos/lib/db/collections_db.dart
Normal file
@@ -0,0 +1,322 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import 'package:photos/models/collection/collection.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:sqflite_migration/sqflite_migration.dart';
|
||||
|
||||
class CollectionsDB {
|
||||
static const _databaseName = "ente.collections.db";
|
||||
static const table = 'collections';
|
||||
static const tempTable = 'temp_collections';
|
||||
static const _sqlBoolTrue = 1;
|
||||
static const _sqlBoolFalse = 0;
|
||||
|
||||
static const columnID = 'collection_id';
|
||||
static const columnOwner = 'owner';
|
||||
static const columnEncryptedKey = 'encrypted_key';
|
||||
static const columnKeyDecryptionNonce = 'key_decryption_nonce';
|
||||
static const columnName = 'name';
|
||||
static const columnEncryptedName = 'encrypted_name';
|
||||
static const columnNameDecryptionNonce = 'name_decryption_nonce';
|
||||
static const columnType = 'type';
|
||||
static const columnEncryptedPath = 'encrypted_path';
|
||||
static const columnPathDecryptionNonce = 'path_decryption_nonce';
|
||||
static const columnVersion = 'version';
|
||||
static const columnSharees = 'sharees';
|
||||
static const columnPublicURLs = 'public_urls';
|
||||
// MMD -> Magic Metadata
|
||||
static const columnMMdEncodedJson = 'mmd_encoded_json';
|
||||
static const columnMMdVersion = 'mmd_ver';
|
||||
|
||||
static const columnPubMMdEncodedJson = 'pub_mmd_encoded_json';
|
||||
static const columnPubMMdVersion = 'pub_mmd_ver';
|
||||
|
||||
static const columnSharedMMdJson = 'shared_mmd_json';
|
||||
static const columnSharedMMdVersion = 'shared_mmd_ver';
|
||||
|
||||
static const columnUpdationTime = 'updation_time';
|
||||
static const columnIsDeleted = 'is_deleted';
|
||||
|
||||
static final intitialScript = [...createTable(table)];
|
||||
static final migrationScripts = [
|
||||
...alterNameToAllowNULL(),
|
||||
...addEncryptedName(),
|
||||
...addVersion(),
|
||||
...addIsDeleted(),
|
||||
...addPublicURLs(),
|
||||
...addPrivateMetadata(),
|
||||
...addPublicMetadata(),
|
||||
...addShareeMetadata(),
|
||||
];
|
||||
|
||||
final dbConfig = MigrationConfig(
|
||||
initializationScript: intitialScript,
|
||||
migrationScripts: migrationScripts,
|
||||
);
|
||||
|
||||
CollectionsDB._privateConstructor();
|
||||
|
||||
static final CollectionsDB instance = CollectionsDB._privateConstructor();
|
||||
|
||||
static Future<Database>? _dbFuture;
|
||||
|
||||
Future<Database> get database async {
|
||||
_dbFuture ??= _initDatabase();
|
||||
return _dbFuture!;
|
||||
}
|
||||
|
||||
Future<Database> _initDatabase() async {
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
return await openDatabaseWithMigration(path, dbConfig);
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await instance.database;
|
||||
await db.delete(table);
|
||||
}
|
||||
|
||||
static List<String> createTable(String tableName) {
|
||||
return [
|
||||
'''
|
||||
CREATE TABLE $tableName (
|
||||
$columnID INTEGER PRIMARY KEY NOT NULL,
|
||||
$columnOwner TEXT NOT NULL,
|
||||
$columnEncryptedKey TEXT NOT NULL,
|
||||
$columnKeyDecryptionNonce TEXT,
|
||||
$columnName TEXT,
|
||||
$columnType TEXT NOT NULL,
|
||||
$columnEncryptedPath TEXT,
|
||||
$columnPathDecryptionNonce TEXT,
|
||||
$columnSharees TEXT,
|
||||
$columnUpdationTime TEXT NOT NULL
|
||||
);
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> alterNameToAllowNULL() {
|
||||
return [
|
||||
...createTable(tempTable),
|
||||
'''
|
||||
INSERT INTO $tempTable
|
||||
SELECT *
|
||||
FROM $table;
|
||||
|
||||
DROP TABLE $table;
|
||||
|
||||
ALTER TABLE $tempTable
|
||||
RENAME TO $table;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addEncryptedName() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnEncryptedName TEXT;
|
||||
''',
|
||||
'''ALTER TABLE $table
|
||||
ADD COLUMN $columnNameDecryptionNonce TEXT;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addVersion() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addIsDeleted() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnIsDeleted INTEGER DEFAULT $_sqlBoolFalse;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addPublicURLs() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table
|
||||
ADD COLUMN $columnPublicURLs TEXT;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addPrivateMetadata() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnMMdEncodedJson TEXT DEFAULT '{}';
|
||||
''',
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnMMdVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addPublicMetadata() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnPubMMdEncodedJson TEXT DEFAULT '
|
||||
{}';
|
||||
''',
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnPubMMdVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> addShareeMetadata() {
|
||||
return [
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnSharedMMdJson TEXT DEFAULT '
|
||||
{}';
|
||||
''',
|
||||
'''
|
||||
ALTER TABLE $table ADD COLUMN $columnSharedMMdVersion INTEGER DEFAULT 0;
|
||||
'''
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> insert(List<Collection> collections) async {
|
||||
final db = await instance.database;
|
||||
var batch = db.batch();
|
||||
int batchCounter = 0;
|
||||
for (final collection in collections) {
|
||||
if (batchCounter == 400) {
|
||||
await batch.commit(noResult: true);
|
||||
batch = db.batch();
|
||||
batchCounter = 0;
|
||||
}
|
||||
batch.insert(
|
||||
table,
|
||||
_getRowForCollection(collection),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
batchCounter++;
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
Future<List<Collection>> getAllCollections() async {
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(table);
|
||||
final collections = <Collection>[];
|
||||
for (final row in rows) {
|
||||
collections.add(_convertToCollection(row));
|
||||
}
|
||||
return collections;
|
||||
}
|
||||
|
||||
// getActiveCollectionIDsAndUpdationTime returns map of collectionID to
|
||||
// updationTime for non-deleted collections
|
||||
Future<Map<int, int>> getActiveIDsAndRemoteUpdateTime() async {
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(
|
||||
table,
|
||||
where: '($columnIsDeleted = ? OR $columnIsDeleted IS NULL)',
|
||||
whereArgs: [_sqlBoolFalse],
|
||||
columns: [columnID, columnUpdationTime],
|
||||
);
|
||||
final collectionIDsAndUpdationTime = <int, int>{};
|
||||
for (final row in rows) {
|
||||
collectionIDsAndUpdationTime[row[columnID] as int] =
|
||||
int.parse(row[columnUpdationTime] as String);
|
||||
}
|
||||
return collectionIDsAndUpdationTime;
|
||||
}
|
||||
|
||||
Future<int> deleteCollection(int collectionID) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
table,
|
||||
where: '$columnID = ?',
|
||||
whereArgs: [collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getRowForCollection(Collection collection) {
|
||||
final row = <String, dynamic>{};
|
||||
row[columnID] = collection.id;
|
||||
row[columnOwner] = collection.owner.toJson();
|
||||
row[columnEncryptedKey] = collection.encryptedKey;
|
||||
row[columnKeyDecryptionNonce] = collection.keyDecryptionNonce;
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
row[columnName] = collection.name;
|
||||
row[columnEncryptedName] = collection.encryptedName;
|
||||
row[columnNameDecryptionNonce] = collection.nameDecryptionNonce;
|
||||
row[columnType] = typeToString(collection.type);
|
||||
row[columnEncryptedPath] = collection.attributes.encryptedPath;
|
||||
row[columnPathDecryptionNonce] = collection.attributes.pathDecryptionNonce;
|
||||
row[columnVersion] = collection.attributes.version;
|
||||
row[columnSharees] =
|
||||
json.encode(collection.sharees.map((x) => x.toMap()).toList());
|
||||
row[columnPublicURLs] =
|
||||
json.encode(collection.publicURLs.map((x) => x.toMap()).toList());
|
||||
row[columnUpdationTime] = collection.updationTime;
|
||||
if (collection.isDeleted) {
|
||||
row[columnIsDeleted] = _sqlBoolTrue;
|
||||
} else {
|
||||
row[columnIsDeleted] = _sqlBoolFalse;
|
||||
}
|
||||
row[columnMMdVersion] = collection.mMdVersion;
|
||||
row[columnMMdEncodedJson] = collection.mMdEncodedJson ?? '{}';
|
||||
row[columnPubMMdVersion] = collection.mMbPubVersion;
|
||||
row[columnPubMMdEncodedJson] = collection.mMdPubEncodedJson ?? '{}';
|
||||
|
||||
row[columnSharedMMdVersion] = collection.sharedMmdVersion;
|
||||
row[columnSharedMMdJson] = collection.sharedMmdJson ?? '{}';
|
||||
return row;
|
||||
}
|
||||
|
||||
Collection _convertToCollection(Map<String, dynamic> row) {
|
||||
final Collection result = Collection(
|
||||
row[columnID],
|
||||
User.fromJson(row[columnOwner]),
|
||||
row[columnEncryptedKey],
|
||||
row[columnKeyDecryptionNonce],
|
||||
row[columnName],
|
||||
row[columnEncryptedName],
|
||||
row[columnNameDecryptionNonce],
|
||||
typeFromString(row[columnType]),
|
||||
CollectionAttributes(
|
||||
encryptedPath: row[columnEncryptedPath],
|
||||
pathDecryptionNonce: row[columnPathDecryptionNonce],
|
||||
version: row[columnVersion],
|
||||
),
|
||||
List<User>.from(
|
||||
(json.decode(row[columnSharees]) as List).map((x) => User.fromMap(x)),
|
||||
),
|
||||
row[columnPublicURLs] == null
|
||||
? []
|
||||
: List<PublicURL>.from(
|
||||
(json.decode(row[columnPublicURLs]) as List)
|
||||
.map((x) => PublicURL.fromMap(x)),
|
||||
),
|
||||
int.parse(row[columnUpdationTime]),
|
||||
// default to False is columnIsDeleted is not set
|
||||
isDeleted: (row[columnIsDeleted] ?? _sqlBoolFalse) == _sqlBoolTrue,
|
||||
);
|
||||
result.mMdVersion = row[columnMMdVersion] ?? 0;
|
||||
result.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
|
||||
result.mMbPubVersion = row[columnPubMMdVersion] ?? 0;
|
||||
result.mMdPubEncodedJson = row[columnPubMMdEncodedJson] ?? '{}';
|
||||
|
||||
result.sharedMmdVersion = row[columnSharedMMdVersion] ?? 0;
|
||||
result.sharedMmdJson = row[columnSharedMMdJson] ?? '{}';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import "package:flutter/foundation.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
mixin SqlDbBase {
|
||||
static final _params = {};
|
||||
static const _params = {};
|
||||
|
||||
String getParams(int count) {
|
||||
static String getParams(int count) {
|
||||
if (!_params.containsKey(count)) {
|
||||
final params = List.generate(count, (_) => "?").join(", ");
|
||||
_params[count] = params;
|
||||
@@ -14,13 +14,9 @@ mixin SqlDbBase {
|
||||
|
||||
Future<void> migrate(
|
||||
SqliteDatabase database,
|
||||
List<String> migrationScripts, {
|
||||
bool onForeignKey = false,
|
||||
}) async {
|
||||
List<String> migrationScripts,
|
||||
) async {
|
||||
final result = await database.execute('PRAGMA user_version');
|
||||
if (onForeignKey) {
|
||||
await database.execute("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
final currentVersion = result[0]['user_version'] as int;
|
||||
final toVersion = migrationScripts.length;
|
||||
|
||||
|
||||
492
mobile/apps/photos/lib/db/device_files_db.dart
Normal file
@@ -0,0 +1,492 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/models/backup_status.dart';
|
||||
import 'package:photos/models/device_collection.dart';
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/file_load_result.dart';
|
||||
import 'package:photos/models/upload_strategy.dart';
|
||||
import "package:photos/services/sync/import/model.dart";
|
||||
import 'package:sqflite/sqlite_api.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
extension DeviceFiles on FilesDB {
|
||||
static final Logger _logger = Logger("DeviceFilesDB");
|
||||
static const _sqlBoolTrue = 1;
|
||||
static const _sqlBoolFalse = 0;
|
||||
|
||||
Future<void> insertPathIDToLocalIDMapping(
|
||||
Map<String, Set<String>> mappingToAdd, {
|
||||
ConflictAlgorithm conflictAlgorithm = ConflictAlgorithm.ignore,
|
||||
}) async {
|
||||
debugPrint("Inserting missing PathIDToLocalIDMapping");
|
||||
final parameterSets = <List<Object?>>[];
|
||||
int batchCounter = 0;
|
||||
for (MapEntry e in mappingToAdd.entries) {
|
||||
final String pathID = e.key;
|
||||
for (String localID in e.value) {
|
||||
parameterSets.add([localID, pathID]);
|
||||
batchCounter++;
|
||||
|
||||
if (batchCounter == 400) {
|
||||
await _insertBatch(parameterSets, conflictAlgorithm);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
await _insertBatch(parameterSets, conflictAlgorithm);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
|
||||
Future<void> deletePathIDToLocalIDMapping(
|
||||
Map<String, Set<String>> mappingsToRemove,
|
||||
) async {
|
||||
debugPrint("removing PathIDToLocalIDMapping");
|
||||
final parameterSets = <List<Object?>>[];
|
||||
int batchCounter = 0;
|
||||
for (MapEntry e in mappingsToRemove.entries) {
|
||||
final String pathID = e.key;
|
||||
|
||||
for (String localID in e.value) {
|
||||
parameterSets.add([localID, pathID]);
|
||||
batchCounter++;
|
||||
|
||||
if (batchCounter == 400) {
|
||||
await _deleteBatch(parameterSets);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
await _deleteBatch(parameterSets);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getDevicePathIDToImportedFileCount() async {
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
'''
|
||||
SELECT count(*) as count, path_id
|
||||
FROM device_files
|
||||
GROUP BY path_id
|
||||
''',
|
||||
);
|
||||
final result = <String, int>{};
|
||||
for (final row in rows) {
|
||||
result[row['path_id'] as String] = row["count"] as int;
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
_logger.severe("failed to getDevicePathIDToImportedFileCount", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, Set<String>>> getDevicePathIDToLocalIDMap() async {
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
''' SELECT id, path_id FROM device_files; ''',
|
||||
);
|
||||
final result = <String, Set<String>>{};
|
||||
for (final row in rows) {
|
||||
final String pathID = row['path_id'] as String;
|
||||
if (!result.containsKey(pathID)) {
|
||||
result[pathID] = <String>{};
|
||||
}
|
||||
result[pathID]!.add(row['id'] as String);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
_logger.severe("failed to getDevicePathIDToLocalIDMap", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> getDevicePathIDs() async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
'''
|
||||
SELECT id FROM device_collections
|
||||
''',
|
||||
);
|
||||
final Set<String> result = <String>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as String);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> insertLocalAssets(
|
||||
List<LocalPathAsset> localPathAssets, {
|
||||
bool shouldAutoBackup = false,
|
||||
}) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final Map<String, Set<String>> pathIDToLocalIDsMap = {};
|
||||
try {
|
||||
final Set<String> existingPathIds = await getDevicePathIDs();
|
||||
final parameterSetsForUpdate = <List<Object?>>[];
|
||||
final parameterSetsForInsert = <List<Object?>>[];
|
||||
for (LocalPathAsset localPathAsset in localPathAssets) {
|
||||
if (localPathAsset.localIDs.isNotEmpty) {
|
||||
pathIDToLocalIDsMap[localPathAsset.pathID] = localPathAsset.localIDs;
|
||||
}
|
||||
if (existingPathIds.contains(localPathAsset.pathID)) {
|
||||
parameterSetsForUpdate
|
||||
.add([localPathAsset.pathName, localPathAsset.pathID]);
|
||||
} else if (localPathAsset.localIDs.isNotEmpty) {
|
||||
parameterSetsForInsert.add([
|
||||
localPathAsset.pathID,
|
||||
localPathAsset.pathName,
|
||||
shouldAutoBackup ? _sqlBoolTrue : _sqlBoolFalse,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
await db.executeBatch(
|
||||
'''
|
||||
INSERT OR IGNORE INTO device_collections (id, name, should_backup) VALUES (?, ?, ?);
|
||||
''',
|
||||
parameterSetsForInsert,
|
||||
);
|
||||
|
||||
await db.executeBatch(
|
||||
'''
|
||||
UPDATE device_collections SET name = ? WHERE id = ?;
|
||||
''',
|
||||
parameterSetsForUpdate,
|
||||
);
|
||||
|
||||
// add the mappings for localIDs
|
||||
if (pathIDToLocalIDsMap.isNotEmpty) {
|
||||
await insertPathIDToLocalIDMapping(pathIDToLocalIDsMap);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe("failed to save path names", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateDeviceCoverWithCount(
|
||||
List<Tuple2<AssetPathEntity, String>> devicePathInfo, {
|
||||
bool shouldBackup = false,
|
||||
}) async {
|
||||
bool hasUpdated = false;
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final Set<String> existingPathIds = await getDevicePathIDs();
|
||||
for (Tuple2<AssetPathEntity, String> tup in devicePathInfo) {
|
||||
final AssetPathEntity pathEntity = tup.item1;
|
||||
final assetCount = await pathEntity.assetCountAsync;
|
||||
final String localID = tup.item2;
|
||||
final bool shouldUpdate = existingPathIds.contains(pathEntity.id);
|
||||
if (shouldUpdate) {
|
||||
final rowUpdated = await db.writeTransaction((tx) async {
|
||||
await tx.execute(
|
||||
"UPDATE device_collections SET name = ?, cover_id = ?, count"
|
||||
" = ? where id = ? AND (name != ? OR cover_id != ? OR count != ?)",
|
||||
[
|
||||
pathEntity.name,
|
||||
localID,
|
||||
assetCount,
|
||||
pathEntity.id,
|
||||
pathEntity.name,
|
||||
localID,
|
||||
assetCount,
|
||||
],
|
||||
);
|
||||
final result = await tx.get("SELECT changes();");
|
||||
return result["changes()"] as int;
|
||||
});
|
||||
|
||||
if (rowUpdated > 0) {
|
||||
_logger.info("Updated $rowUpdated rows for ${pathEntity.name}");
|
||||
hasUpdated = true;
|
||||
}
|
||||
} else {
|
||||
hasUpdated = true;
|
||||
await db.execute(
|
||||
'''
|
||||
INSERT INTO device_collections (id, name, count, cover_id, should_backup)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
''',
|
||||
[
|
||||
pathEntity.id,
|
||||
pathEntity.name,
|
||||
assetCount,
|
||||
localID,
|
||||
shouldBackup ? _sqlBoolTrue : _sqlBoolFalse,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
// delete existing pathIDs which are missing on device
|
||||
existingPathIds.removeAll(devicePathInfo.map((e) => e.item1.id).toSet());
|
||||
if (existingPathIds.isNotEmpty) {
|
||||
hasUpdated = true;
|
||||
_logger.info(
|
||||
'Deleting non-backed up pathIds from local '
|
||||
'$existingPathIds',
|
||||
);
|
||||
for (String pathID in existingPathIds) {
|
||||
// do not delete device collection entries for paths which are
|
||||
// marked for backup. This is to handle "Free up space"
|
||||
// feature, where we delete files which are backed up. Deleting such
|
||||
// entries here result in us losing out on the information that
|
||||
// those folders were marked for automatic backup.
|
||||
await db.execute(
|
||||
'''
|
||||
DELETE FROM device_collections WHERE id = ? AND should_backup = $_sqlBoolFalse;
|
||||
''',
|
||||
[pathID],
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
DELETE FROM device_files WHERE path_id = ?;
|
||||
''',
|
||||
[pathID],
|
||||
);
|
||||
}
|
||||
}
|
||||
return hasUpdated;
|
||||
} catch (e) {
|
||||
_logger.severe("failed to save path names", e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// getDeviceSyncCollectionIDs returns the collectionIDs for the
|
||||
// deviceCollections which are marked for auto-backup
|
||||
Future<Set<int>> getDeviceSyncCollectionIDs() async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final rows = await db.getAll(
|
||||
'''
|
||||
SELECT collection_id FROM device_collections where should_backup =
|
||||
$_sqlBoolTrue
|
||||
and collection_id != -1;
|
||||
''',
|
||||
);
|
||||
final Set<int> result = <int>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['collection_id'] as int);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> updateDevicePathSyncStatus(
|
||||
Map<String, bool> syncStatus,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
int batchCounter = 0;
|
||||
final parameterSets = <List<Object?>>[];
|
||||
for (MapEntry e in syncStatus.entries) {
|
||||
final String pathID = e.key;
|
||||
parameterSets.add([e.value ? _sqlBoolTrue : _sqlBoolFalse, pathID]);
|
||||
batchCounter++;
|
||||
|
||||
if (batchCounter == 400) {
|
||||
await db.executeBatch(
|
||||
'''
|
||||
UPDATE device_collections SET should_backup = ? WHERE id = ?;
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
parameterSets.clear();
|
||||
batchCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await db.executeBatch(
|
||||
'''
|
||||
UPDATE device_collections SET should_backup = ? WHERE id = ?;
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateDeviceCollection(
|
||||
String pathID,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
await db.execute(
|
||||
'''
|
||||
UPDATE device_collections SET collection_id = ? WHERE id = ?;
|
||||
''',
|
||||
[collectionID, pathID],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<FileLoadResult> getFilesInDeviceCollection(
|
||||
DeviceCollection deviceCollection,
|
||||
int? ownerID,
|
||||
int startTime,
|
||||
int endTime, {
|
||||
int? limit,
|
||||
bool? asc,
|
||||
}) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
final order = (asc ?? false ? 'ASC' : 'DESC');
|
||||
final String rawQuery = '''
|
||||
SELECT *
|
||||
FROM ${FilesDB.filesTable}
|
||||
WHERE ${FilesDB.columnLocalID} IS NOT NULL AND
|
||||
${FilesDB.columnCreationTime} >= $startTime AND
|
||||
${FilesDB.columnCreationTime} <= $endTime AND
|
||||
(${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} =
|
||||
$ownerID ) AND
|
||||
${FilesDB.columnLocalID} IN
|
||||
(SELECT id FROM device_files where path_id = '${deviceCollection.id}' )
|
||||
ORDER BY ${FilesDB.columnCreationTime} $order , ${FilesDB.columnModificationTime} $order
|
||||
''' +
|
||||
(limit != null ? ' limit $limit;' : ';');
|
||||
final results = await db.getAll(rawQuery);
|
||||
final files = convertToFiles(results);
|
||||
final dedupe = deduplicateByLocalID(files);
|
||||
return FileLoadResult(dedupe, files.length == limit);
|
||||
}
|
||||
|
||||
Future<BackedUpFileIDs> getBackedUpForDeviceCollection(
|
||||
String pathID,
|
||||
int ownerID,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
const String rawQuery = '''
|
||||
SELECT ${FilesDB.columnLocalID}, ${FilesDB.columnUploadedFileID},
|
||||
${FilesDB.columnFileSize}
|
||||
FROM ${FilesDB.filesTable}
|
||||
WHERE ${FilesDB.columnLocalID} IS NOT NULL AND
|
||||
(${FilesDB.columnOwnerID} IS NULL OR ${FilesDB.columnOwnerID} = ?)
|
||||
AND (${FilesDB.columnUploadedFileID} IS NOT NULL AND ${FilesDB.columnUploadedFileID} IS NOT -1)
|
||||
AND
|
||||
${FilesDB.columnLocalID} IN
|
||||
(SELECT id FROM device_files where path_id = ?)
|
||||
''';
|
||||
final results = await db.getAll(rawQuery, [ownerID, pathID]);
|
||||
final localIDs = <String>{};
|
||||
final uploadedIDs = <int>{};
|
||||
int localSize = 0;
|
||||
for (final result in results) {
|
||||
final String localID = result[FilesDB.columnLocalID] as String;
|
||||
final int? fileSize = result[FilesDB.columnFileSize] as int?;
|
||||
if (!localIDs.contains(localID) && fileSize != null) {
|
||||
localSize += fileSize;
|
||||
}
|
||||
localIDs.add(localID);
|
||||
uploadedIDs.add(result[FilesDB.columnUploadedFileID] as int);
|
||||
}
|
||||
return BackedUpFileIDs(localIDs.toList(), uploadedIDs.toList(), localSize);
|
||||
}
|
||||
|
||||
Future<List<DeviceCollection>> getDeviceCollections({
|
||||
bool includeCoverThumbnail = false,
|
||||
}) async {
|
||||
debugPrint(
|
||||
"Fetching DeviceCollections From DB with thumbnail = "
|
||||
"$includeCoverThumbnail",
|
||||
);
|
||||
try {
|
||||
final db = await sqliteAsyncDB;
|
||||
final coverFiles = <EnteFile>[];
|
||||
if (includeCoverThumbnail) {
|
||||
final fileRows = await db.getAll(
|
||||
'''SELECT * FROM FILES where local_id in (select cover_id from device_collections) group by local_id;
|
||||
''',
|
||||
);
|
||||
final files = convertToFiles(fileRows);
|
||||
coverFiles.addAll(files);
|
||||
}
|
||||
final deviceCollectionRows = await db.getAll(
|
||||
'''SELECT * from device_collections''',
|
||||
);
|
||||
final List<DeviceCollection> deviceCollections = [];
|
||||
for (var row in deviceCollectionRows) {
|
||||
final DeviceCollection deviceCollection = DeviceCollection(
|
||||
row["id"] as String,
|
||||
(row['name'] ?? '') as String,
|
||||
count: row['count'] as int,
|
||||
collectionID: (row["collection_id"] ?? -1) as int,
|
||||
coverId: row["cover_id"] as String?,
|
||||
shouldBackup: (row["should_backup"] ?? _sqlBoolFalse) == _sqlBoolTrue,
|
||||
uploadStrategy: getUploadType((row["upload_strategy"] ?? 0) as int),
|
||||
);
|
||||
if (includeCoverThumbnail) {
|
||||
deviceCollection.thumbnail = coverFiles.firstWhereOrNull(
|
||||
(element) => element.localID == deviceCollection.coverId,
|
||||
);
|
||||
if (deviceCollection.thumbnail == null) {
|
||||
final EnteFile? result =
|
||||
await getDeviceCollectionThumbnail(deviceCollection.id);
|
||||
if (result == null) {
|
||||
_logger.info(
|
||||
'Failed to find coverThumbnail for deviceFolder',
|
||||
);
|
||||
continue;
|
||||
} else {
|
||||
deviceCollection.thumbnail = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
deviceCollections.add(deviceCollection);
|
||||
}
|
||||
if (includeCoverThumbnail) {
|
||||
deviceCollections.sort(
|
||||
(a, b) =>
|
||||
b.thumbnail!.creationTime!.compareTo(a.thumbnail!.creationTime!),
|
||||
);
|
||||
}
|
||||
return deviceCollections;
|
||||
} catch (e) {
|
||||
_logger.severe('Failed to getDeviceCollections', e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<EnteFile?> getDeviceCollectionThumbnail(String pathID) async {
|
||||
debugPrint("Call fallback method to get potential thumbnail");
|
||||
final db = await sqliteAsyncDB;
|
||||
final fileRows = await db.getAll(
|
||||
'''SELECT * FROM FILES f JOIN device_files df on f.local_id = df.id
|
||||
and df.path_id= ? order by f.creation_time DESC limit 1;
|
||||
''',
|
||||
[pathID],
|
||||
);
|
||||
final files = convertToFiles(fileRows);
|
||||
if (files.isNotEmpty) {
|
||||
return files.first;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _insertBatch(
|
||||
List<List<Object?>> parameterSets,
|
||||
ConflictAlgorithm conflictAlgorithm,
|
||||
) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
await db.executeBatch(
|
||||
'''
|
||||
INSERT OR ${conflictAlgorithm.name.toUpperCase()}
|
||||
INTO device_files (id, path_id) VALUES (?, ?);
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteBatch(List<List<Object?>> parameterSets) async {
|
||||
final db = await sqliteAsyncDB;
|
||||
await db.executeBatch(
|
||||
'''
|
||||
DELETE FROM device_files WHERE id = ? AND path_id = ?;
|
||||
''',
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/db/common/base.dart";
|
||||
import "package:photos/db/local/mappers.dart";
|
||||
import "package:photos/db/local/schema.dart";
|
||||
import "package:photos/log/devlog.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import "package:photos/models/file/mapping/local_mapping.dart";
|
||||
import "package:photos/models/local/local_metadata.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
class LocalDB with SqlDbBase {
|
||||
static const _databaseName = "local_6.db";
|
||||
static const batchInsertMaxCount = 1000;
|
||||
static const _smallTableBatchInsertMaxCount = 5000;
|
||||
late final SqliteDatabase _sqliteDB;
|
||||
SqliteDatabase get sqliteDB => _sqliteDB;
|
||||
|
||||
Future<void> init() async {
|
||||
devLog("LocalDB init");
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
|
||||
final db = SqliteDatabase(path: path);
|
||||
await migrate(db, LocalDBMigration.migrationScripts, onForeignKey: true);
|
||||
_sqliteDB = db;
|
||||
debugPrint("LocalDB init complete $path");
|
||||
}
|
||||
|
||||
Future<void> insertAssets(List<AssetEntity> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(entries.slices(batchInsertMaxCount), (slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => LocalDBMappers.assetsRow(e)).toList();
|
||||
await _sqliteDB.executeBatch(
|
||||
'INSERT INTO assets ($assetColumns) values(${getParams(16)}) ON CONFLICT(id) DO UPDATE SET $updateAssetColumns',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertAssets complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} assets',
|
||||
);
|
||||
}
|
||||
|
||||
// Store time and location metadata inside edited_assets
|
||||
Future<void> trackEdit(
|
||||
String id,
|
||||
int createdAt,
|
||||
int modifiedAt,
|
||||
double? lat,
|
||||
double? lng,
|
||||
) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'INSERT INTO edited_assets (id, created_at, modified_at, latitude, longitude) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET created_at = ?, modified_at = ?, latitude = ?, longitude = ?',
|
||||
[id, createdAt, modifiedAt, lat, lng, createdAt, modifiedAt, lat, lng],
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType editCopy complete in ${stopwatch.elapsed.inMilliseconds}ms for $id',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateMetadata(
|
||||
String id, {
|
||||
DroidMetadata? droid,
|
||||
IOSMetadata? ios,
|
||||
}) async {
|
||||
if (droid != null) {
|
||||
await _sqliteDB.execute(
|
||||
'UPDATE assets SET size = ?, hash = ?, latitude = ?, longitude = ?, created_at = ?, modified_at = ?, scan_state = 1 WHERE id = ?',
|
||||
[
|
||||
droid.size,
|
||||
droid.hash,
|
||||
droid.location?.latitude,
|
||||
droid.location?.longitude,
|
||||
droid.creationTime,
|
||||
droid.modificationTime,
|
||||
id,
|
||||
],
|
||||
);
|
||||
} else if (ios != null) {
|
||||
// await _sqliteDB.execute(
|
||||
// 'UPDATE assets SET size = ?, hash = ?, latitude = ?, longitude = ?, created_at = ?, modified_at = ? WHERE id = ?',
|
||||
// [
|
||||
// ios.size,
|
||||
// ios.hash,
|
||||
// ios.location.latitude,
|
||||
// ios.location.longitude,
|
||||
// ios.creationTime.millisecondsSinceEpoch,
|
||||
// ios.modificationTime.millisecondsSinceEpoch,
|
||||
// ios.id,
|
||||
// ],
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, LocalAssetInfo>> getLocalAssetsInfo(
|
||||
List<String> ids,
|
||||
) async {
|
||||
if (ids.isEmpty) return {};
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await _sqliteDB.getAll(
|
||||
'SELECT id, hash, title, relative_path, scan_state FROM assets WHERE id IN (${List.filled(ids.length, "?").join(",")})',
|
||||
ids,
|
||||
);
|
||||
debugPrint(
|
||||
"getLocalAssetsInfo complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} ids",
|
||||
);
|
||||
return Map.fromEntries(
|
||||
result.map(
|
||||
(row) => MapEntry(row['id'] as String, LocalAssetInfo.fromRow(row)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getAssets({LocalAssertsParam? params}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT * FROM assets ${params != null ? params.whereClause(addWhere: true) : ""}",
|
||||
);
|
||||
debugPrint(
|
||||
"getAssets complete in ${stopwatch.elapsed.inMilliseconds}ms, params: ${params?.whereClause()}",
|
||||
);
|
||||
// if time is greater than 1000ms, print explain analyze out
|
||||
if (kDebugMode && stopwatch.elapsed.inMilliseconds > 1000) {
|
||||
final explain = await _sqliteDB.execute(
|
||||
"EXPLAIN QUERY PLAN SELECT * FROM assets ${params != null ? params.whereClause(addWhere: true) : ""}",
|
||||
);
|
||||
debugPrint("getAssets: Explain Query Plan: $explain");
|
||||
}
|
||||
stopwatch.reset();
|
||||
stopwatch.start();
|
||||
final r =
|
||||
result.map((row) => LocalDBMappers.assetRowToEnteFile(row)).toList();
|
||||
debugPrint(
|
||||
"getAssets mapping completed in ${stopwatch.elapsed.inMilliseconds}ms",
|
||||
);
|
||||
return r;
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getPathAssets(
|
||||
String pathID, {
|
||||
LocalAssertsParam? params,
|
||||
}) async {
|
||||
final String query =
|
||||
"SELECT * FROM assets WHERE id IN (SELECT asset_id FROM device_path_assets WHERE path_id = ?) ${params != null ? 'AND ${params.whereClause()}' : "order by created_at desc"}";
|
||||
debugPrint(query);
|
||||
final result = await _sqliteDB.getAll(
|
||||
query,
|
||||
[pathID],
|
||||
);
|
||||
return result.map((row) => LocalDBMappers.assetRowToEnteFile(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> insertDBPaths(List<AssetPathEntity> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(entries.slices(_smallTableBatchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => LocalDBMappers.devicePathRow(e)).toList();
|
||||
await _sqliteDB.executeBatch(
|
||||
'INSERT INTO device_path ($devicePathColumns) values(${getParams(5)}) ON CONFLICT(path_id) DO UPDATE SET $updateDevicePathColumns',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertDBPaths complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} paths',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<AssetPathEntity>> getAssetPaths() async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT * FROM device_path",
|
||||
);
|
||||
return result.map((row) => LocalDBMappers.assetPath(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> insertPathToAssetIDs(
|
||||
Map<String, Set<String>> pathToAssetIDs, {
|
||||
bool clearOldMappingsIdsInInput = false,
|
||||
}) async {
|
||||
if (pathToAssetIDs.isEmpty) return;
|
||||
final List<List<String>> allValues = [];
|
||||
pathToAssetIDs.forEach((pathID, assetIDs) {
|
||||
allValues.addAll(assetIDs.map((assetID) => [pathID, assetID]));
|
||||
});
|
||||
if (allValues.isEmpty && !clearOldMappingsIdsInInput) {
|
||||
return;
|
||||
}
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
await _sqliteDB.writeTransaction((tx) async {
|
||||
if (clearOldMappingsIdsInInput) {
|
||||
await tx.execute(
|
||||
"DELETE FROM device_path_assets WHERE path_id IN (${List.generate(pathToAssetIDs.keys.length, (index) => '?').join(',')})",
|
||||
pathToAssetIDs.keys.toList(),
|
||||
);
|
||||
}
|
||||
const int batchSize = 15000;
|
||||
for (int i = 0; i < allValues.length; i += batchSize) {
|
||||
await tx.executeBatch(
|
||||
'INSERT OR REPLACE INTO device_path_assets (path_id, asset_id) VALUES (?, ?)',
|
||||
allValues.sublist(
|
||||
i,
|
||||
i + batchSize > allValues.length ? allValues.length : i + batchSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint(
|
||||
'$runtimeType insertPathToAssetIDs ${allValues.length} complete in '
|
||||
'${stopwatch.elapsed.inMilliseconds}ms for '
|
||||
'${pathToAssetIDs.length} paths (replaced $clearOldMappingsIdsInInput}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<String>> getAssetsIDs({bool pendingScan = false}) async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT id FROM assets ${pendingScan ? 'WHERE scan_state != $finalState ORDER BY created_at DESC' : ''}",
|
||||
);
|
||||
final ids = <String>{};
|
||||
for (var row in result) {
|
||||
ids.add(row["id"] as String);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
Future<Set<String>> getAssetsIDsForPath(
|
||||
String pathID,
|
||||
) async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT asset_id FROM device_path_assets WHERE path_id = ? ",
|
||||
[pathID],
|
||||
);
|
||||
final ids = <String>{};
|
||||
for (var row in result) {
|
||||
ids.add(row["asset_id"] as String);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getIDToCreationTime() async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
"SELECT id, created_at FROM assets",
|
||||
);
|
||||
final idToCreationTime = <String, int>{};
|
||||
for (var row in result) {
|
||||
idToCreationTime[row["id"] as String] = row["created_at"] as int;
|
||||
}
|
||||
return idToCreationTime;
|
||||
}
|
||||
|
||||
Future<Map<String, Set<String>>> pathToAssetIDs() async {
|
||||
final result = await _sqliteDB
|
||||
.getAll("SELECT path_id, asset_id FROM device_path_assets");
|
||||
final pathToAssetIDs = <String, Set<String>>{};
|
||||
for (var row in result) {
|
||||
final pathID = row["path_id"] as String;
|
||||
final assetID = row["asset_id"] as String;
|
||||
if (pathToAssetIDs.containsKey(pathID)) {
|
||||
pathToAssetIDs[pathID]!.add(assetID);
|
||||
} else {
|
||||
pathToAssetIDs[pathID] = {assetID};
|
||||
}
|
||||
}
|
||||
return pathToAssetIDs;
|
||||
}
|
||||
|
||||
Future<void> deleteAssets(Set<String> ids) async {
|
||||
if (ids.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM assets WHERE id IN (${List.filled(ids.length, "?").join(",")})',
|
||||
ids.toList(),
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} assets entries',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deletePaths(Set<String> pathIds) async {
|
||||
if (pathIds.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM device_path WHERE path_id IN (${List.filled(pathIds.length, "?").join(",")})',
|
||||
pathIds.toList(),
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathIds.length} path entries',
|
||||
);
|
||||
}
|
||||
|
||||
// returns true if either asset queue or shared_assets has any entry for given ownerID
|
||||
Future<bool> hasAssetQueueOrSharedAsset(int ownerID) async {
|
||||
final result = await _sqliteDB.getAll(
|
||||
'''
|
||||
SELECT 1 FROM asset_upload_queue WHERE owner_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM shared_assets WHERE owner_id = ?
|
||||
LIMIT 1
|
||||
''',
|
||||
[ownerID, ownerID],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<(int, int)> getUniqueQueueAndSharedAssetsCount(
|
||||
int ownerID,
|
||||
) async {
|
||||
final queuedAssets = await _sqliteDB.getAll(
|
||||
'SELECT COUNT(distinct asset_id) as count FROM asset_upload_queue WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final sharedAssets = await _sqliteDB.getAll(
|
||||
'SELECT COUNT(*) as count FROM shared_assets WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final queuedCount =
|
||||
queuedAssets.isNotEmpty ? (queuedAssets.first['count'] as int) : 0;
|
||||
final sharedCount =
|
||||
sharedAssets.isNotEmpty ? (sharedAssets.first['count'] as int) : 0;
|
||||
return (queuedCount, sharedCount);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
class LocalDBMappers {
|
||||
const LocalDBMappers._();
|
||||
|
||||
static List<Object?> assetsRow(AssetEntity entity) {
|
||||
return [
|
||||
entity.id,
|
||||
entity.type.index,
|
||||
entity.subtype,
|
||||
entity.width,
|
||||
entity.height,
|
||||
entity.duration,
|
||||
entity.orientation,
|
||||
entity.isFavorite ? 1 : 0,
|
||||
entity.title,
|
||||
entity.relativePath,
|
||||
entity.createDateTime.microsecondsSinceEpoch,
|
||||
entity.modifiedDateTime.microsecondsSinceEpoch,
|
||||
entity.mimeType,
|
||||
entity.latitude,
|
||||
entity.longitude,
|
||||
0, // scan_state
|
||||
];
|
||||
}
|
||||
|
||||
static AssetEntity asset(Map<String, dynamic> row) {
|
||||
return AssetEntity(
|
||||
id: row['id'] as String,
|
||||
typeInt: row['type'] as int,
|
||||
subtype: row['sub_type'] as int,
|
||||
width: row['width'] as int,
|
||||
height: row['height'] as int,
|
||||
duration: row['duration_in_sec'] as int,
|
||||
orientation: row['orientation'] as int,
|
||||
isFavorite: (row['is_fav'] as int) == 1,
|
||||
title: row['title'] as String?,
|
||||
relativePath: row['relative_path'] as String?,
|
||||
createDateSecond: (row['created_at'] as int) ~/ 1000000,
|
||||
modifiedDateSecond: (row['modified_at'] as int) ~/ 1000000,
|
||||
mimeType: row['mime_type'] as String?,
|
||||
latitude: row['latitude'] as double?,
|
||||
longitude: row['longitude'] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
static EnteFile assetRowToEnteFile(Map<String, dynamic> row) {
|
||||
final asset = AssetEntity(
|
||||
id: row['id'] as String,
|
||||
typeInt: row['type'] as int,
|
||||
subtype: row['sub_type'] as int,
|
||||
width: row['width'] as int,
|
||||
height: row['height'] as int,
|
||||
duration: row['duration_in_sec'] as int,
|
||||
orientation: row['orientation'] as int,
|
||||
isFavorite: (row['is_fav'] as int) == 1,
|
||||
title: row['title'] as String?,
|
||||
relativePath: row['relative_path'] as String?,
|
||||
createDateSecond: (row['created_at'] as int) ~/ 1000000,
|
||||
modifiedDateSecond: (row['modified_at'] as int) ~/ 1000000,
|
||||
mimeType: row['mime_type'] as String?,
|
||||
latitude: row['latitude'] as double?,
|
||||
longitude: row['longitude'] as double?,
|
||||
);
|
||||
return EnteFile.fromAssetSync(asset);
|
||||
}
|
||||
|
||||
static List<Object?> devicePathRow(AssetPathEntity entity) {
|
||||
return [
|
||||
entity.id,
|
||||
entity.name,
|
||||
entity.albumType,
|
||||
entity.albumTypeEx?.darwin?.type?.index,
|
||||
entity.albumTypeEx?.darwin?.subtype?.index,
|
||||
];
|
||||
}
|
||||
|
||||
static AssetPathEntity assetPath(Map<String, dynamic> row) {
|
||||
return AssetPathEntity(
|
||||
id: row['path_id'] as String,
|
||||
name: row['name'] as String,
|
||||
albumType: row['album_type'] as int,
|
||||
albumTypeEx: AlbumType(
|
||||
darwin: !Platform.isAndroid
|
||||
? DarwinAlbumType(
|
||||
type: PMDarwinAssetCollectionTypeExt.fromValue(
|
||||
row['ios_album_type'] as int?,
|
||||
),
|
||||
subtype: PMDarwinAssetCollectionSubtypeExt.fromValue(
|
||||
row['darwin_subtype'] as int?,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
const assetColumns =
|
||||
"id, type, sub_type, width, height, duration_in_sec, orientation, is_fav, title, relative_path, created_at, modified_at, mime_type, latitude, longitude, scan_state";
|
||||
|
||||
const assetUploadQueueColumns =
|
||||
"dest_collection_id, asset_id, path_id, owner_id, manual";
|
||||
const androidAssetState = 1;
|
||||
const androidHashState = 1 << 2;
|
||||
const androidMediaType = 1 << 3;
|
||||
const iOSAssetState = 1;
|
||||
const iOSCloudIdState = 1 << 2;
|
||||
const iOSAssetHashState = 1 << 3;
|
||||
|
||||
final finalState = Platform.isAndroid
|
||||
? (androidAssetState ^ androidHashState ^ androidMediaType)
|
||||
: (iOSAssetState ^ iOSCloudIdState ^ iOSAssetHashState);
|
||||
// Generate the update clause dynamically (excludes 'id')
|
||||
final String updateAssetColumns = assetColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const devicePathColumns =
|
||||
"path_id, name, album_type, ios_album_type, ios_album_subtype";
|
||||
|
||||
final String updateDevicePathColumns = devicePathColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'path_id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const String deviceCollectionWithOneAssetQuery = '''
|
||||
WITH latest_per_path AS (
|
||||
SELECT
|
||||
dpa.path_id,
|
||||
MAX(a.created_at) as max_created,
|
||||
count(*) as asset_count
|
||||
FROM
|
||||
device_path_assets dpa
|
||||
JOIN
|
||||
assets a ON dpa.asset_id = a.id
|
||||
|
||||
GROUP BY
|
||||
dpa.path_id
|
||||
),
|
||||
ranked_assets AS (
|
||||
SELECT
|
||||
dpa.path_id,
|
||||
a.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY dpa.path_id ORDER BY a.id) as rn,
|
||||
lpp.asset_count
|
||||
FROM
|
||||
device_path_assets dpa
|
||||
JOIN
|
||||
assets a ON dpa.asset_id = a.id
|
||||
JOIN
|
||||
latest_per_path lpp ON dpa.path_id = lpp.path_id AND a.created_at = lpp.max_created
|
||||
)
|
||||
SELECT
|
||||
dp.*,
|
||||
ra.*,
|
||||
pc.*
|
||||
FROM
|
||||
device_path dp
|
||||
JOIN
|
||||
ranked_assets ra ON dp.path_id = ra.path_id AND ra.rn = 1
|
||||
LEFT JOIN path_backup_config pc
|
||||
on dp.path_id = pc.device_path_id
|
||||
''';
|
||||
|
||||
class LocalAssertsParam {
|
||||
int? limit;
|
||||
int? offset;
|
||||
String? orderByColumn;
|
||||
bool isAsc;
|
||||
(int?, int?)? createAtRange;
|
||||
|
||||
LocalAssertsParam({
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.orderByColumn = "created_at",
|
||||
this.isAsc = false,
|
||||
this.createAtRange,
|
||||
});
|
||||
|
||||
String get orderBy => orderByColumn == null
|
||||
? ""
|
||||
: "ORDER BY $orderByColumn ${isAsc ? "ASC" : "DESC"}";
|
||||
|
||||
String get limitOffset => (limit != null && offset != null)
|
||||
? "LIMIT $limit + OFFSET $offset)"
|
||||
: (limit != null)
|
||||
? "LIMIT $limit"
|
||||
: "";
|
||||
|
||||
String get createAtRangeStr => (createAtRange == null ||
|
||||
createAtRange!.$1 == null)
|
||||
? ""
|
||||
: "(created_at BETWEEN ${createAtRange!.$1} AND ${createAtRange!.$2})";
|
||||
|
||||
String whereClause({bool addWhere = false}) {
|
||||
final where = <String>[];
|
||||
if (createAtRangeStr.isNotEmpty) {
|
||||
where.add(createAtRangeStr);
|
||||
}
|
||||
|
||||
return (where.isEmpty
|
||||
? ""
|
||||
: '${addWhere ? "Where" : ""} ${where.join(" AND ")}') +
|
||||
" " +
|
||||
orderBy +
|
||||
" " +
|
||||
limitOffset;
|
||||
}
|
||||
}
|
||||
|
||||
class LocalDBMigration {
|
||||
static const migrationScripts = [
|
||||
'''
|
||||
CREATE TABLE assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
type INTEGER NOT NULL,
|
||||
sub_type INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
duration_in_sec INTEGER NOT NULL,
|
||||
orientation INTEGER NOT NULL,
|
||||
is_fav INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
relative_path TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
mime_type TEXT,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
scan_state INTEGER DEFAULT 0,
|
||||
hash TEXT,
|
||||
size INTEGER,
|
||||
os_metadata TEXT DEFAULT '{}'
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS assets_created_at ON assets(created_at);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE shared_assets (
|
||||
dest_collection_id INTEGER NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
duration_in_sec INTEGER DEFAULT 0,
|
||||
owner_id INTEGER NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
PRIMARY KEY (dest_collection_id, id)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS sa_collection_owner ON shared_assets(dest_collection_id, owner_id);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE device_path (
|
||||
path_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
album_type INTEGER NOT NULL,
|
||||
ios_album_type INTEGER,
|
||||
ios_album_subtype INTEGER
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE device_path_assets (
|
||||
path_id TEXT NOT NULL,
|
||||
asset_id TEXT NOT NULL,
|
||||
PRIMARY KEY (path_id, asset_id),
|
||||
FOREIGN KEY (path_id) REFERENCES device_path(path_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE queue (
|
||||
id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (id, name)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE path_backup_config(
|
||||
device_path_id TEXT PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
collection_id INTEGER,
|
||||
should_backup INTEGER NOT NULL DEFAULT 0,
|
||||
upload_strategy INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE asset_upload_queue (
|
||||
dest_collection_id INTEGER NOT NULL,
|
||||
asset_id TEXT NOT NULL,
|
||||
path_id TEXT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (dest_collection_id, asset_id),
|
||||
FOREIGN KEY(asset_id) REFERENCES assets(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_upload_queue_owner_id
|
||||
ON asset_upload_queue(owner_id)
|
||||
WHERE owner_id IS NOT NULL;
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS assets_created_at_desc ON assets(created_at DESC);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE edited_assets (
|
||||
id String NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
modified_at INTEGER NOT NULL,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
PRIMARY KEY (id)
|
||||
FOREIGN KEY (id) REFERENCES assets(id) ON DELETE CASCADE
|
||||
);
|
||||
''',
|
||||
];
|
||||
|
||||
static Future<void> migrate(
|
||||
SqliteDatabase database,
|
||||
) async {
|
||||
final result = await database.execute('PRAGMA user_version');
|
||||
await database.execute("PRAGMA foreign_keys = ON");
|
||||
final currentVersion = result[0]['user_version'] as int;
|
||||
final toVersion = migrationScripts.length;
|
||||
|
||||
if (currentVersion < toVersion) {
|
||||
debugPrint("Migrating Local DB from $currentVersion to $toVersion");
|
||||
await database.writeTransaction((tx) async {
|
||||
for (int i = currentVersion + 1; i <= toVersion; i++) {
|
||||
await tx.execute(migrationScripts[i - 1]);
|
||||
}
|
||||
await tx.execute('PRAGMA user_version = $toVersion');
|
||||
});
|
||||
} else if (currentVersion > toVersion) {
|
||||
throw AssertionError(
|
||||
"currentVersion($currentVersion) cannot be greater than toVersion($toVersion)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/db/local/mappers.dart";
|
||||
import "package:photos/db/local/schema.dart";
|
||||
import "package:photos/models/device_collection.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/upload_strategy.dart";
|
||||
|
||||
extension DeviceAlbums on LocalDB {
|
||||
Future<List<DeviceCollection>> getDeviceCollections() async {
|
||||
final List<DeviceCollection> collections = [];
|
||||
final rows = await sqliteDB.getAll(deviceCollectionWithOneAssetQuery);
|
||||
for (final row in rows) {
|
||||
final path = LocalDBMappers.assetPath(row);
|
||||
AssetEntity? asset;
|
||||
if (row['id'] != null) {
|
||||
asset = LocalDBMappers.asset(row);
|
||||
}
|
||||
collections.add(
|
||||
DeviceCollection(
|
||||
path,
|
||||
count: row['asset_count'] as int,
|
||||
thumbnail: asset != null ? EnteFile.fromAssetSync(asset) : null,
|
||||
shouldBackup: (row['should_backup'] ?? 0) as int == 1,
|
||||
uploadStrategy:
|
||||
UploadStrategy.values[(row['upload_strategy'] ?? 0) as int],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return collections;
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/log/devlog.dart";
|
||||
import "package:photos/models/local/path_config.dart";
|
||||
import "package:photos/models/upload_strategy.dart";
|
||||
|
||||
extension PathBackupConfigTable on LocalDB {
|
||||
Future<void> insertOrUpdatePathConfigs(
|
||||
Map<String, bool> pathConfigs,
|
||||
int ownerID,
|
||||
) async {
|
||||
if (pathConfigs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(
|
||||
pathConfigs.entries.slices(LocalDB.batchInsertMaxCount), (slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => [e.key, e.value ? 1 : 0, ownerID]).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO path_backup_config (device_path_id, should_backup, owner_id) VALUES (?, ?, ?) ON CONFLICT(device_path_id) DO UPDATE SET should_backup = ?, owner_id = ?',
|
||||
values.map((e) => [e[0], e[1], e[2], e[1], e[2]]).toList(),
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertOrUpdatePathConfigs complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathConfigs.length} paths',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<String>> getBackedUpPathIDs(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT device_path_id FROM path_backup_config WHERE should_backup = 1 AND owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final paths = result.map((row) => row['device_path_id'] as String).toSet();
|
||||
devLog(
|
||||
'$runtimeType getPathsWithBackupEnabled complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
name: 'getPathsWithBackupEnabled',
|
||||
);
|
||||
return paths;
|
||||
}
|
||||
|
||||
// destCollectionWithBackup returns the non-null collection ids
|
||||
// for given ownerID for paths that have backup enabled.
|
||||
Future<Set<int>> destCollectionWithBackup(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT collection_id FROM path_backup_config WHERE should_backup = 1 AND owner_id = ? AND collection_id IS NOT NULL',
|
||||
[ownerID],
|
||||
);
|
||||
final Set<int> collectionIDs =
|
||||
result.map((row) => row['collection_id'] as int).whereNotNull().toSet();
|
||||
devLog(
|
||||
'$runtimeType destCollectionWithBackup complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
name: 'destCollectionWithBackup',
|
||||
);
|
||||
return collectionIDs;
|
||||
}
|
||||
|
||||
Future<void> updateDestConnection(
|
||||
String pathID,
|
||||
int destCollection,
|
||||
int ownerID,
|
||||
) async {
|
||||
await sqliteDB.execute(
|
||||
'UPDATE path_backup_config SET collection_id = ? WHERE device_path_id = ? AND owner_id = ?',
|
||||
[destCollection, pathID, ownerID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<PathConfig>> getPathConfigs(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM path_backup_config WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final configs = result.map((row) {
|
||||
return PathConfig(
|
||||
row['device_path_id'] as String,
|
||||
row['owner_id'] as int,
|
||||
row['collection_id'] as int?,
|
||||
(row['should_backup'] as int) == 1,
|
||||
getUploadType(row['upload_strategy'] as int),
|
||||
);
|
||||
}).toList();
|
||||
devLog(
|
||||
'$runtimeType getPathConfigs complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
name: 'getPathConfigs',
|
||||
);
|
||||
return configs;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/models/local/shared_asset.dart";
|
||||
|
||||
extension SharedAssetsTable on LocalDB {
|
||||
Future<Set<String>> getSharedAssetsID() async {
|
||||
final result = await sqliteDB.getAll('SELECT id FROM shared_assets');
|
||||
return Set.unmodifiable(result.map<String>((row) => row['id'] as String));
|
||||
}
|
||||
|
||||
Future<void> insertSharedAssets(List<SharedAsset> assets) async {
|
||||
if (assets.isEmpty) return;
|
||||
await Future.forEach(
|
||||
assets.slices(LocalDB.batchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => e.rowProps).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO shared_assets (id, name, type, creation_time, duration_in_seconds, dest_collection_id, owner_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
values,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SharedAsset>> getSharedAssets() async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM shared_assets ORDER BY creation_time DESC',
|
||||
);
|
||||
return result.map((row) => SharedAsset.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
Future<List<SharedAsset>> getSharedAssetsByCollection(
|
||||
int collectionID,
|
||||
) async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM shared_assets WHERE dest_collection_id = ? ORDER BY creation_time DESC',
|
||||
[collectionID],
|
||||
);
|
||||
return result.map((row) => SharedAsset.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
Future<void> deleteSharedAssetsByCollection(int collectionID) async {
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM shared_assets WHERE dest_collection_id = ?',
|
||||
[collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSharedAsset(String assetID) async {
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM shared_assets WHERE id = ?',
|
||||
[assetID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSharedAssets(Set<String> assetIDs) async {
|
||||
if (assetIDs.isEmpty) return;
|
||||
await Future.forEach(
|
||||
assetIDs.slices(LocalDB.batchInsertMaxCount),
|
||||
(slice) async {
|
||||
await sqliteDB.executeBatch(
|
||||
'DELETE FROM shared_assets WHERE id = ?',
|
||||
slice.map((id) => [id]).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/local/db.dart";
|
||||
import "package:photos/db/local/mappers.dart";
|
||||
import "package:photos/db/local/schema.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/local/asset_upload_queue.dart";
|
||||
|
||||
extension UploadQueueTable on LocalDB {
|
||||
Future<Set<String>> getQueueAssetIDs(int ownerID) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT asset_id FROM asset_upload_queue WHERE owner_id = ?',
|
||||
[ownerID],
|
||||
);
|
||||
final assetIDs = result.map((row) => row['asset_id'] as String).toSet();
|
||||
debugPrint(
|
||||
'$runtimeType getQueueAssetIDs complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
);
|
||||
return assetIDs;
|
||||
}
|
||||
|
||||
Future<void> clearMappingsWithDiffPath(
|
||||
int ownerID,
|
||||
Set<String> pathIDs,
|
||||
) async {
|
||||
if (pathIDs.isEmpty) {
|
||||
// delete all mapping with path ids
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM asset_upload_queue WHERE owner_id = ? AND path_id IS NOT NULL',
|
||||
[ownerID],
|
||||
);
|
||||
} else {
|
||||
// delete mappings where path_id is not null and not in pathIDs
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM asset_upload_queue WHERE owner_id = ? AND path_id IS NOT NULL AND path_id NOT IN (${pathIDs.map((_) => '?').join(',')})',
|
||||
[ownerID, ...pathIDs],
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType clearMappingsWithDiffPath complete in ${stopwatch.elapsed.inMilliseconds}ms for ${pathIDs.length} paths',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> existsQueueEntry(AssetUploadQueue entry) async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT 1 FROM asset_upload_queue WHERE asset_id = ? AND owner_id = ? AND dest_collection_id = ?',
|
||||
[entry.id, entry.ownerId, entry.destCollectionId],
|
||||
);
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<int> delete(AssetUploadQueue entry) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.execute(
|
||||
'DELETE FROM asset_upload_queue WHERE asset_id = ? AND owner_id = ? and dest_collection_id = ?',
|
||||
[entry.id, entry.ownerId, entry.destCollectionId],
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType delete complete in ${stopwatch.elapsed.inMilliseconds}ms for entry: $entry',
|
||||
);
|
||||
return result.isNotEmpty ? result[0]['changes'] as int : 0;
|
||||
}
|
||||
|
||||
Future<List<(AssetUploadQueue, EnteFile)>> getQueueEntriesWithFiles(
|
||||
int ownerID, {
|
||||
int? destCollection,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT asset_upload_queue.*, assets.* FROM asset_upload_queue JOIN assets ON assets.id = asset_upload_queue.asset_id WHERE owner_id = ? ${destCollection != null ? 'AND dest_collection_id = ?' : ''} ORDER BY created_at DESC',
|
||||
destCollection != null ? [ownerID, destCollection] : [ownerID],
|
||||
);
|
||||
final entries = result
|
||||
.map(
|
||||
(row) => (
|
||||
AssetUploadQueue(
|
||||
id: row['asset_id'] as String,
|
||||
pathId: row['path_id'] as String?,
|
||||
destCollectionId: row['dest_collection_id'] as int,
|
||||
ownerId: row['owner_id'] as int,
|
||||
manual: (row['manual'] as int) == 1,
|
||||
),
|
||||
EnteFile.fromAssetSync(LocalDBMappers.asset(row)),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
debugPrint(
|
||||
'$runtimeType getQueueEntriesWithFiles complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries',
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<List<AssetUploadQueue>> getQueueEntries(
|
||||
int ownerID, {
|
||||
int? destCollection,
|
||||
}) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT asset_upload_queue.*, assets.* FROM asset_upload_queue JOIN assets ON assets.id = asset_upload_queue.asset_id WHERE owner_id = ? ${destCollection != null ? 'AND dest_collection_id = ?' : ''} ORDER BY created_at DESC',
|
||||
destCollection != null ? [ownerID, destCollection] : [ownerID],
|
||||
);
|
||||
final entries = result
|
||||
.map(
|
||||
(row) => AssetUploadQueue(
|
||||
id: row['asset_id'] as String,
|
||||
pathId: row['path_id'] as String?,
|
||||
destCollectionId: row['dest_collection_id'] as int,
|
||||
ownerId: row['owner_id'] as int,
|
||||
manual: (row['manual'] as int) == 1,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
debugPrint(
|
||||
'$runtimeType getQueueEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries',
|
||||
);
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<void> insertOrUpdateQueue(
|
||||
Set<String> assetIDs,
|
||||
int destCollection,
|
||||
int ownerID, {
|
||||
String? path,
|
||||
bool manual = false,
|
||||
}) async {
|
||||
if (assetIDs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(
|
||||
assetIDs.slices(LocalDB.batchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values = slice
|
||||
.map((e) => [destCollection, e, path, ownerID, manual])
|
||||
.toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO asset_upload_queue ($assetUploadQueueColumns) VALUES(?,?,?,?,?) ON CONFLICT DO UPDATE SET manual = ?, path_id = ?',
|
||||
values
|
||||
.map((e) => [e[0], e[1], e[2], e[3], e[4], manual, path])
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType insertOrUpdateQueue complete in ${stopwatch.elapsed.inMilliseconds}ms for ${assetIDs.length} items',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import "package:photos/services/machine_learning/face_ml/face_clustering/face_db
|
||||
abstract class IMLDataDB<T> {
|
||||
Future<void> bulkInsertFaces(List<Face> faces);
|
||||
Future<void> updateFaceIdToClusterId(Map<String, String> faceIDToClusterID);
|
||||
Future<Map<T, int>> faceIndexedFileIds({int minimumMlVersion});
|
||||
Future<Map<int, int>> faceIndexedFileIds({int minimumMlVersion});
|
||||
Future<int> getFaceIndexedFileCount({int minimumMlVersion});
|
||||
Future<Map<String, int>> clusterIdToFaceCount();
|
||||
Future<Set<String>> getPersonIgnoredClusters(String personID);
|
||||
@@ -52,7 +52,7 @@ abstract class IMLDataDB<T> {
|
||||
Future<void> forceUpdateClusterIds(Map<String, String> faceIDToClusterID);
|
||||
Future<void> removeFaceIdToClusterId(Map<String, String> faceIDToClusterID);
|
||||
Future<void> removePerson(String personID);
|
||||
Future<List<FaceDbInfoForClustering<T>>> getFaceInfoForClustering({
|
||||
Future<List<FaceDbInfoForClustering>> getFaceInfoForClustering({
|
||||
int maxFaces,
|
||||
int offset,
|
||||
int batchSize,
|
||||
@@ -112,9 +112,9 @@ abstract class IMLDataDB<T> {
|
||||
});
|
||||
|
||||
Future<List<EmbeddingVector>> getAllClipVectors();
|
||||
Future<Map<T, int>> clipIndexedFileWithVersion();
|
||||
Future<Map<int, int>> clipIndexedFileWithVersion();
|
||||
Future<int> getClipIndexedFileCount({int minimumMlVersion});
|
||||
Future<void> putClip<T>(List<ClipEmbedding<T>> embeddings);
|
||||
Future<void> putClip(List<ClipEmbedding> embeddings);
|
||||
Future<void> deleteClipEmbeddings(List<T> fileIDs);
|
||||
Future<void> deleteClipIndexes();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "dart:io" show File;
|
||||
import "dart:typed_data" show Float32List;
|
||||
|
||||
import "package:flutter_rust_bridge/flutter_rust_bridge.dart" show Uint64List;
|
||||
@@ -13,8 +12,8 @@ import "package:shared_preferences/shared_preferences.dart";
|
||||
class ClipVectorDB {
|
||||
static final Logger _logger = Logger("ClipVectorDB");
|
||||
|
||||
static const _databaseName = "ente.ml.vectordb.clip.usearch";
|
||||
static const _kMigrationKey = "clip_vectordb_migration";
|
||||
static const _databaseName = "ente.ml.vectordb.clip";
|
||||
static const _kMigrationKey = "clip_vector_migration";
|
||||
|
||||
static final BigInt _embeddingDimension = BigInt.from(512);
|
||||
|
||||
@@ -37,10 +36,11 @@ class ClipVectorDB {
|
||||
|
||||
Future<VectorDb> _initVectorDB() async {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final String dbPath = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("Opening vectorDB access: DB path " + dbPath);
|
||||
final String databaseDirectory =
|
||||
join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("Opening vectorDB access: DB path " + databaseDirectory);
|
||||
final vectorDB = VectorDb(
|
||||
filePath: dbPath,
|
||||
filePath: databaseDirectory,
|
||||
dimensions: _embeddingDimension,
|
||||
);
|
||||
final stats = await getIndexStats(vectorDB);
|
||||
@@ -72,26 +72,25 @@ class ClipVectorDB {
|
||||
_migrationDone = true;
|
||||
}
|
||||
|
||||
Future<void> insertEmbedding<T>({
|
||||
required T fileID,
|
||||
Future<void> insertEmbedding({
|
||||
required int fileID,
|
||||
required List<double> embedding,
|
||||
}) async {
|
||||
final db = await _vectorDB;
|
||||
try {
|
||||
final id = fileID as int;
|
||||
await db.addVector(key: BigInt.from(id), vector: embedding);
|
||||
await db.addVector(key: BigInt.from(fileID), vector: embedding);
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error inserting embedding", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> bulkInsertEmbeddings<T>({
|
||||
required List<T> fileIDs,
|
||||
Future<void> bulkInsertEmbeddings({
|
||||
required List<int> fileIDs,
|
||||
required List<Float32List> embeddings,
|
||||
}) async {
|
||||
final db = await _vectorDB;
|
||||
final bigKeys = Uint64List.fromList(fileIDs.map((e) => e as int).toList());
|
||||
final bigKeys = Uint64List.fromList(fileIDs);
|
||||
try {
|
||||
await db.bulkAddVectors(keys: bigKeys, vectors: embeddings);
|
||||
} catch (e, s) {
|
||||
@@ -142,6 +141,17 @@ class ClipVectorDB {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteIndex() async {
|
||||
final db = await _vectorDB;
|
||||
try {
|
||||
await db.deleteIndex();
|
||||
_vectorDbFuture = null;
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error deleting index", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<VectorDbStats> getIndexStats([VectorDb? db]) async {
|
||||
db ??= await _vectorDB;
|
||||
try {
|
||||
@@ -268,40 +278,6 @@ class ClipVectorDB {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteIndex() async {
|
||||
final db = await _vectorDB;
|
||||
try {
|
||||
await db.deleteIndex();
|
||||
_vectorDbFuture = null;
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error deleting index", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteIndexFile({bool undoMigration = false}) async {
|
||||
try {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final String dbPath = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("Delete index file: DB path " + dbPath);
|
||||
final file = File(dbPath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
_logger.info("Deleted index file on disk");
|
||||
_vectorDbFuture = null;
|
||||
if (undoMigration) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kMigrationKey, false);
|
||||
_migrationDone = false;
|
||||
_logger.info("Undid migration flag");
|
||||
}
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error deleting index file on disk", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VectorDbStats {
|
||||
|
||||
@@ -53,13 +53,13 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
static final MLDataDB instance = MLDataDB._privateConstructor();
|
||||
|
||||
static final _migrationScripts = [
|
||||
getCreateFacesTable(false),
|
||||
createFacesTable,
|
||||
createFaceClustersTable,
|
||||
createClusterPersonTable,
|
||||
createClusterSummaryTable,
|
||||
createNotPersonFeedbackTable,
|
||||
fcClusterIDIndex,
|
||||
getCreateClipEmbeddingsTable(false),
|
||||
createClipEmbeddingsTable,
|
||||
createFileDataTable,
|
||||
createFaceCacheTable,
|
||||
];
|
||||
@@ -80,10 +80,10 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final asyncDBConnection =
|
||||
SqliteDatabase(path: databaseDirectory, maxReaders: 2);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
_logger.info("$runtimeType: Starting migration");
|
||||
_logger.info("MLDataDB: Starting migration");
|
||||
await migrate(asyncDBConnection, _migrationScripts);
|
||||
_logger.info(
|
||||
"$runtimeType Migration took ${stopwatch.elapsedMilliseconds} ms",
|
||||
"MLDataDB Migration took ${stopwatch.elapsedMilliseconds} ms",
|
||||
);
|
||||
stopwatch.stop();
|
||||
|
||||
@@ -260,9 +260,6 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
await db.execute(deleteNotPersonFeedbackTable);
|
||||
await db.execute(deleteClipEmbeddingsTable);
|
||||
await db.execute(deleteFileDataTable);
|
||||
if (await ClipVectorDB.instance.checkIfMigrationDone()) {
|
||||
await ClipVectorDB.instance.deleteIndexFile();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -360,10 +357,10 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
(element) => (element[fileIDColumn] as int) == avatarFileId,
|
||||
);
|
||||
if (row != null) {
|
||||
return mapRowToFace<int>(row);
|
||||
return mapRowToFace(row);
|
||||
}
|
||||
}
|
||||
return mapRowToFace<int>(faceMaps.first);
|
||||
return mapRowToFace(faceMaps.first);
|
||||
}
|
||||
}
|
||||
if (clusterID != null) {
|
||||
@@ -411,7 +408,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
if (maps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return maps.map((e) => mapRowToFace<int>(e)).toList();
|
||||
return maps.map((e) => mapRowToFace(e)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -428,7 +425,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
final result = <int, List<FaceWithoutEmbedding>>{};
|
||||
for (final map in maps) {
|
||||
final face = mapRowToFaceWithoutEmbedding<int>(map);
|
||||
final face = mapRowToFaceWithoutEmbedding(map);
|
||||
final fileID = map[fileIDColumn] as int;
|
||||
result.putIfAbsent(fileID, () => <FaceWithoutEmbedding>[]).add(face);
|
||||
}
|
||||
@@ -726,7 +723,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FaceDbInfoForClustering<int>>> getFaceInfoForClustering({
|
||||
Future<List<FaceDbInfoForClustering>> getFaceInfoForClustering({
|
||||
int maxFaces = 20000,
|
||||
int offset = 0,
|
||||
int batchSize = 10000,
|
||||
@@ -738,8 +735,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
);
|
||||
final db = await instance.asyncDB;
|
||||
|
||||
final List<FaceDbInfoForClustering<int>> result =
|
||||
<FaceDbInfoForClustering<int>>[];
|
||||
final List<FaceDbInfoForClustering> result = <FaceDbInfoForClustering>[];
|
||||
while (true) {
|
||||
// Query a batch of rows
|
||||
final List<Map<String, dynamic>> maps = await db.getAll(
|
||||
@@ -759,7 +755,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final faceIdToClusterId = await getFaceIdsToClusterIds(faceIds);
|
||||
for (final map in maps) {
|
||||
final faceID = map[faceIDColumn] as String;
|
||||
final faceInfo = FaceDbInfoForClustering<int>(
|
||||
final faceInfo = FaceDbInfoForClustering(
|
||||
faceID: faceID,
|
||||
clusterId: faceIdToClusterId[faceID],
|
||||
embeddingBytes: map[embeddingColumn] as Uint8List,
|
||||
@@ -1136,7 +1132,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final db = await instance.asyncDB;
|
||||
if (faces) {
|
||||
await db.execute(deleteFacesTable);
|
||||
await db.execute(getCreateFacesTable(false));
|
||||
await db.execute(createFacesTable);
|
||||
await db.execute(deleteFaceClustersTable);
|
||||
await db.execute(createFaceClustersTable);
|
||||
await db.execute(fcClusterIDIndex);
|
||||
@@ -1293,11 +1289,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
int processedCount = 0;
|
||||
int weirdCount = 0;
|
||||
int whileCount = 0;
|
||||
const String migrationKey = "clip_vector_db_migration_in_progress";
|
||||
final stopwatch = Stopwatch()..start();
|
||||
try {
|
||||
// Make sure no other heavy compute is running
|
||||
computeController.blockCompute(blocker: migrationKey);
|
||||
while (true) {
|
||||
whileCount++;
|
||||
_logger.info("$whileCount st round of while loop");
|
||||
@@ -1327,17 +1320,14 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
embeddings.add(Float32List.view(result[embeddingColumn].buffer));
|
||||
} else {
|
||||
weirdCount++;
|
||||
_logger.warning(
|
||||
"Weird clip embedding length ${embedding.length} for fileID ${result[fileIDColumn]}, skipping",
|
||||
);
|
||||
}
|
||||
}
|
||||
_logger.info(
|
||||
"Got ${fileIDs.length} valid embeddings, $weirdCount weird embeddings",
|
||||
);
|
||||
|
||||
await ClipVectorDB.instance.bulkInsertEmbeddings<int>(
|
||||
fileIDs: fileIDs, embeddings: embeddings);
|
||||
await ClipVectorDB.instance
|
||||
.bulkInsertEmbeddings(fileIDs: fileIDs, embeddings: embeddings);
|
||||
_logger.info("Inserted ${fileIDs.length} embeddings to ClipVectorDB");
|
||||
processedCount += fileIDs.length;
|
||||
offset += batchSize;
|
||||
@@ -1356,7 +1346,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
"migrated all $totalCount embeddings to ClipVectorDB in ${stopwatch.elapsed.inMilliseconds} ms, with $weirdCount weird embeddings not migrated",
|
||||
);
|
||||
await ClipVectorDB.instance.setMigrationDone();
|
||||
_logger.info("ClipVectorDB migration done");
|
||||
_logger.info("ClipVectorDB migration done, flag file created");
|
||||
} catch (e, s) {
|
||||
_logger.severe(
|
||||
"Error migrating ClipVectorDB after ${stopwatch.elapsed.inMilliseconds} ms, clearing out DB again",
|
||||
@@ -1367,8 +1357,6 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
rethrow;
|
||||
} finally {
|
||||
stopwatch.stop();
|
||||
// Make sure compute can run again
|
||||
computeController.unblockCompute(blocker: migrationKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1397,7 +1385,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putClip<int>(List<ClipEmbedding<int>> embeddings) async {
|
||||
Future<void> putClip(List<ClipEmbedding> embeddings) async {
|
||||
if (embeddings.isEmpty) return;
|
||||
final db = await instance.asyncDB;
|
||||
if (embeddings.length == 1) {
|
||||
@@ -1407,9 +1395,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
);
|
||||
if (flagService.enableVectorDb &&
|
||||
await ClipVectorDB.instance.checkIfMigrationDone()) {
|
||||
final e = embeddings.first.fileID;
|
||||
await ClipVectorDB.instance.insertEmbedding(
|
||||
fileID: e,
|
||||
fileID: embeddings.first.fileID,
|
||||
embedding: embeddings.first.embedding,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import "package:photos/models/ml/face/face.dart";
|
||||
import "package:photos/models/ml/face/face_with_embedding.dart";
|
||||
import "package:photos/models/ml/ml_versions.dart";
|
||||
|
||||
Map<String, dynamic> mapRemoteToFaceDB<T>(Face<T> face) {
|
||||
Map<String, dynamic> mapRemoteToFaceDB(Face face) {
|
||||
return {
|
||||
faceIDColumn: face.faceID,
|
||||
fileIDColumn: face.fileID,
|
||||
@@ -24,10 +24,10 @@ Map<String, dynamic> mapRemoteToFaceDB<T>(Face<T> face) {
|
||||
};
|
||||
}
|
||||
|
||||
Face mapRowToFace<T>(Map<String, dynamic> row) {
|
||||
Face mapRowToFace(Map<String, dynamic> row) {
|
||||
return Face(
|
||||
row[faceIDColumn] as String,
|
||||
row[fileIDColumn] as T,
|
||||
row[fileIDColumn] as int,
|
||||
EVector.fromBuffer(row[embeddingColumn] as List<int>).values,
|
||||
row[faceScore] as double,
|
||||
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
|
||||
@@ -39,12 +39,10 @@ Face mapRowToFace<T>(Map<String, dynamic> row) {
|
||||
);
|
||||
}
|
||||
|
||||
FaceWithoutEmbedding<T> mapRowToFaceWithoutEmbedding<T>(
|
||||
Map<String, dynamic> row,
|
||||
) {
|
||||
return FaceWithoutEmbedding<T>(
|
||||
FaceWithoutEmbedding mapRowToFaceWithoutEmbedding(Map<String, dynamic> row) {
|
||||
return FaceWithoutEmbedding(
|
||||
row[faceIDColumn] as String,
|
||||
row[fileIDColumn] as T,
|
||||
row[fileIDColumn] as int,
|
||||
row[faceScore] as double,
|
||||
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
|
||||
row[faceBlur] as double,
|
||||
|
||||
@@ -17,9 +17,8 @@ const personIdColumn = 'person_id';
|
||||
const clusterIDColumn = 'cluster_id';
|
||||
const personOrClusterIdColumn = 'person_or_cluster_id';
|
||||
|
||||
String getCreateFacesTable(bool isOfflineDB) {
|
||||
return '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
$fileIDColumn ${isOfflineDB ? 'TEXT' : 'INTEGER'} NOT NULL,
|
||||
const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
$faceIDColumn TEXT NOT NULL UNIQUE,
|
||||
$faceDetectionColumn TEXT NOT NULL,
|
||||
$embeddingColumn BLOB NOT NULL,
|
||||
@@ -32,7 +31,6 @@ String getCreateFacesTable(bool isOfflineDB) {
|
||||
PRIMARY KEY($fileIDColumn, $faceIDColumn)
|
||||
);
|
||||
''';
|
||||
}
|
||||
|
||||
const deleteFacesTable = 'DELETE FROM $facesTable';
|
||||
// End of Faces Table Fields & Schema Queries
|
||||
@@ -100,20 +98,18 @@ const deleteNotPersonFeedbackTable = 'DELETE FROM $notPersonFeedback';
|
||||
// ## CLIP EMBEDDINGS TABLE
|
||||
const clipTable = 'clip';
|
||||
|
||||
String getCreateClipEmbeddingsTable(bool isOfflineDB) {
|
||||
return '''CREATE TABLE IF NOT EXISTS $clipTable (
|
||||
$fileIDColumn ${isOfflineDB ? 'TEXT' : 'INTEGER'} NOT NULL,
|
||||
const createClipEmbeddingsTable = '''
|
||||
CREATE TABLE IF NOT EXISTS $clipTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
$embeddingColumn BLOB NOT NULL,
|
||||
$mlVersionColumn INTEGER NOT NULL,
|
||||
PRIMARY KEY($fileIDColumn)
|
||||
PRIMARY KEY ($fileIDColumn)
|
||||
);
|
||||
''';
|
||||
}
|
||||
''';
|
||||
|
||||
const deleteClipEmbeddingsTable = 'DELETE FROM $clipTable';
|
||||
|
||||
const fileDataTable = 'filedata';
|
||||
|
||||
const createFileDataTable = '''
|
||||
CREATE TABLE IF NOT EXISTS $fileDataTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import "dart:io";
|
||||
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:path/path.dart";
|
||||
import "package:path_provider/path_provider.dart";
|
||||
import "package:photos/db/common/base.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/log/devlog.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/collection/collection.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
import "package:sqlite_async/sqlite_async.dart";
|
||||
|
||||
// ignore: constant_identifier_names
|
||||
enum RemoteTable { collections, collection_files, files, entities, trash }
|
||||
|
||||
class RemoteDB with SqlDbBase {
|
||||
static const _databaseName = "remotex6.db";
|
||||
static const _batchInsertMaxCount = 1000;
|
||||
late final SqliteDatabase _sqliteDB;
|
||||
|
||||
Future<void> init() async {
|
||||
devLog("Starting RemoteDB init");
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
|
||||
final db = SqliteDatabase(path: path);
|
||||
await migrate(db, RemoteDBMigration.migrationScripts, onForeignKey: true);
|
||||
_sqliteDB = db;
|
||||
debugPrint("RemoteDB init complete $path");
|
||||
}
|
||||
|
||||
SqliteDatabase get sqliteDB => _sqliteDB;
|
||||
|
||||
Future<List<Collection>> getAllCollections() async {
|
||||
final result = <Collection>[];
|
||||
final cursor = await _sqliteDB.getAll("SELECT * FROM collections");
|
||||
for (final row in cursor) {
|
||||
result.add(Collection.fromRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> clearAllTables() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.wait([
|
||||
_sqliteDB.execute('DELETE FROM collections'),
|
||||
_sqliteDB.execute('DELETE FROM collection_files'),
|
||||
_sqliteDB.execute('DELETE FROM files'),
|
||||
_sqliteDB.execute('DELETE FROM files_metadata'),
|
||||
_sqliteDB.execute('DELETE FROM trash'),
|
||||
_sqliteDB.execute('DELETE FROM upload_mapping'),
|
||||
]);
|
||||
debugPrint(
|
||||
'$runtimeType clearAllTables complete in ${stopwatch.elapsed.inMilliseconds}ms',
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIDToUpdationTime() async {
|
||||
final result = <int, int>{};
|
||||
final cursor = await _sqliteDB.getAll(
|
||||
"SELECT id, updation_time FROM collections where is_deleted = 0",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
result[row['id'] as int] = row['updation_time'] as int;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> getRemoteAssets() async {
|
||||
final result = <RemoteAsset>[];
|
||||
final cursor = await _sqliteDB.getAll(
|
||||
"SELECT * FROM files",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
result.add(fromFilesRow(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> insertCollections(List<Collection> collections) async {
|
||||
if (collections.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(collections.slices(_batchInsertMaxCount),
|
||||
(slice) async {
|
||||
final List<List<Object?>> values =
|
||||
slice.map((e) => e.rowValiues()).toList();
|
||||
await _sqliteDB.executeBatch(
|
||||
'INSERT INTO collections ($collectionColumns) values($collectionValuePlaceHolder) ON CONFLICT(id) DO UPDATE SET $updateCollectionColumns',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollections complete in ${stopwatch.elapsed.inMilliseconds}ms for ${collections.length} collections',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> insertDiffItems(
|
||||
List<DiffItem> items,
|
||||
) async {
|
||||
if (items.isEmpty) return [];
|
||||
final List<RemoteAsset> assets = [];
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(_batchInsertMaxCount), (slice) async {
|
||||
final List<List<Object?>> collectionFileValues = [];
|
||||
final List<List<Object?>> fileValues = [];
|
||||
final List<List<Object?>> fileMetadataValues = [];
|
||||
for (final item in slice) {
|
||||
final rAsset = item.fileItem.toRemoteAsset();
|
||||
collectionFileValues.add(item.collectionFileRowValues());
|
||||
fileMetadataValues.add(item.fileItem.filesMetadataRowValues());
|
||||
fileValues.add(remoteAssetToRow(rAsset));
|
||||
assets.add(rAsset);
|
||||
}
|
||||
await Future.wait([
|
||||
_sqliteDB.executeBatch(
|
||||
'INSERT INTO collection_files ($collectionFilesColumns) values(?, ?, ?, ?, ?, ?) ON CONFLICT(file_id, collection_id) DO UPDATE SET $collectionFilesUpdateColumns',
|
||||
collectionFileValues,
|
||||
),
|
||||
_sqliteDB.executeBatch(
|
||||
'INSERT INTO files ($filesColumns) values(${getParams(23)}) ON CONFLICT(id) DO UPDATE SET $filesUpdateColumns',
|
||||
fileValues,
|
||||
),
|
||||
_sqliteDB.executeBatch(
|
||||
'INSERT INTO files_metadata ($filesMetadataColumns) values(${getParams(5)}) ON CONFLICT(id) DO UPDATE SET $filesMetadataUpdateColumns',
|
||||
fileMetadataValues,
|
||||
),
|
||||
]);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
return assets;
|
||||
}
|
||||
|
||||
Future<void> deleteFilesDiff(
|
||||
List<DiffItem> items,
|
||||
) async {
|
||||
final int collectionID = items.first.collectionID;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(_batchInsertMaxCount), (slice) async {
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM collection_files WHERE file_id IN (${slice.map((e) => e.fileID).join(',')}) AND collection_id = $collectionID',
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType deleteCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteEntries<T>(Set<T> ids, RemoteTable table) async {
|
||||
if (ids.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await _sqliteDB.execute(
|
||||
'DELETE FROM ${table.name.toLowerCase()} WHERE id IN (${ids.join(',')})',
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${ids.length} $table entries',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> rowCount(
|
||||
RemoteTable table,
|
||||
) async {
|
||||
final row = await _sqliteDB.get(
|
||||
'SELECT COUNT(*) as count FROM ${table.name}',
|
||||
);
|
||||
return row['count'] as int;
|
||||
}
|
||||
|
||||
Future<Set<T>> _getByIds<T>(
|
||||
Set<int> ids,
|
||||
String table,
|
||||
T Function(
|
||||
Map<String, Object?> row,
|
||||
) mapRow, {
|
||||
String columnName = "id",
|
||||
}) async {
|
||||
final result = <T>{};
|
||||
if (ids.isNotEmpty) {
|
||||
final rows = await _sqliteDB.getAll(
|
||||
'SELECT * from $table where $columnName IN (${ids.join(',')})',
|
||||
);
|
||||
for (final row in rows) {
|
||||
result.add(mapRow(row));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/api/diff/trash_time.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
import "package:photos/models/file/remote/rl_mapping.dart";
|
||||
import "package:photos/models/location/location.dart";
|
||||
|
||||
RemoteAsset fromTrashRow(Map<String, dynamic> row) {
|
||||
final metadata = Metadata.fromEncodedJson(row['metadata']);
|
||||
final privateMetadata = Metadata.fromEncodedJson(row['priv_metadata']);
|
||||
final publicMetadata = Metadata.fromEncodedJson(row['pub_metadata']);
|
||||
final info = Info.fromEncodedJson(row['info']);
|
||||
return RemoteAsset.fromMetadata(
|
||||
id: row['id'],
|
||||
ownerID: row['owner_id'],
|
||||
thumbHeader: row['thumb_header'],
|
||||
fileHeader: row['file_header'],
|
||||
metadata: metadata!,
|
||||
privateMetadata: privateMetadata,
|
||||
publicMetadata: publicMetadata,
|
||||
info: info,
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> remoteAssetToRow(RemoteAsset asset) {
|
||||
return [
|
||||
asset.id,
|
||||
asset.ownerID,
|
||||
asset.fileHeader,
|
||||
asset.thumbHeader,
|
||||
asset.creationTime,
|
||||
asset.modificationTime,
|
||||
asset.type,
|
||||
asset.subType,
|
||||
asset.title,
|
||||
asset.fileSize,
|
||||
asset.hash,
|
||||
asset.visibility,
|
||||
asset.durationInSec,
|
||||
asset.location?.latitude,
|
||||
asset.location?.longitude,
|
||||
asset.height,
|
||||
asset.width,
|
||||
asset.noThumb,
|
||||
asset.sv,
|
||||
asset.mediaType,
|
||||
asset.motionVideoIndex,
|
||||
asset.caption,
|
||||
asset.uploaderName,
|
||||
];
|
||||
}
|
||||
|
||||
RemoteAsset fromFilesRow(Map<String, Object?> row) {
|
||||
return RemoteAsset(
|
||||
id: row['id'] as int,
|
||||
ownerID: row['owner_id'] as int,
|
||||
thumbHeader: row['thumb_header'] as Uint8List,
|
||||
fileHeader: row['file_header'] as Uint8List,
|
||||
creationTime: row['creation_time'] as int,
|
||||
modificationTime: row['modification_time'] as int,
|
||||
type: row['type'] as int,
|
||||
subType: row['subtype'] as int,
|
||||
title: row['title'] as String,
|
||||
fileSize: row['size'] as int?,
|
||||
hash: row['hash'] as String?,
|
||||
visibility: row['visibility'] as int?,
|
||||
durationInSec: row['durationInSec'] as int?,
|
||||
location: Location(
|
||||
latitude: (row['lat'] as num?)?.toDouble(),
|
||||
longitude: (row['lng'] as num?)?.toDouble(),
|
||||
),
|
||||
height: row['height'] as int?,
|
||||
width: row['width'] as int?,
|
||||
noThumb: row['no_thumb'] as int?,
|
||||
sv: row['sv'] as int?,
|
||||
mediaType: row['media_type'] as int?,
|
||||
motionVideoIndex: row['motion_video_index'] as int?,
|
||||
caption: row['caption'] as String?,
|
||||
uploaderName: row['uploader_name'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
RLMapping rowToUploadLocalMapping(Map<String, Object?> row) {
|
||||
return RLMapping(
|
||||
remoteUploadID: row['file_id'] as int,
|
||||
localID: row['local_id'] as String,
|
||||
localCloudID: row['local_cloud_id'] as String?,
|
||||
mappingType:
|
||||
MappingTypeExtension.fromName(row['local_mapping_src'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
EnteFile trashRowToEnteFile(Map<String, Object?> row) {
|
||||
final RemoteAsset asset = fromTrashRow(row);
|
||||
final TrashTime time = TrashTime(
|
||||
createdAt: row['created_at'] as int,
|
||||
updatedAt: row['updated_at'] as int,
|
||||
deleteBy: row['delete_by'] as int,
|
||||
);
|
||||
final cf = CollectionFile(
|
||||
fileID: asset.id,
|
||||
collectionID: row['collection_id'] as int,
|
||||
encFileKey: row['enc_key'] as Uint8List,
|
||||
encFileKeyNonce: row['enc_key_nonce'] as Uint8List,
|
||||
updatedAt: time.updatedAt,
|
||||
createdAt: time.createdAt,
|
||||
);
|
||||
final file = EnteFile.fromRemoteAsset(asset, cf);
|
||||
file.trashTime = time;
|
||||
return file;
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
const collectionColumns =
|
||||
'id, owner, enc_key, enc_key_nonce, name, type, local_path, is_deleted, '
|
||||
'updation_time, sharees, public_urls, mmd_encoded_json, '
|
||||
'mmd_ver, pub_mmd_encoded_json, pub_mmd_ver, shared_mmd_json, '
|
||||
'shared_mmd_ver';
|
||||
|
||||
final String updateCollectionColumns = collectionColumns
|
||||
.split(', ')
|
||||
.where((column) => column != 'id') // Exclude primary key from update
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const collectionFilesColumns =
|
||||
'collection_id, file_id, enc_key, enc_key_nonce, created_at, updated_at';
|
||||
|
||||
final String collectionFilesUpdateColumns = collectionFilesColumns
|
||||
.split(', ')
|
||||
.where(
|
||||
(column) =>
|
||||
column != 'collection_id' ||
|
||||
column != 'file_id' ||
|
||||
column != 'created_at',
|
||||
)
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const filesColumns =
|
||||
'id, owner_id, file_header, thumb_header, creation_time, modification_time, '
|
||||
'type, subtype, title, size, hash, visibility, durationInSec, lat, lng, '
|
||||
'height, width, no_thumb, sv, media_type, motion_video_index, caption, uploader_name';
|
||||
|
||||
final String filesUpdateColumns = filesColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const filesMetadataColumns = 'id, metadata, priv_metadata, pub_metadata, info';
|
||||
final String filesMetadataUpdateColumns = filesMetadataColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const trashedFilesColumns =
|
||||
'id, owner_id, collection_id, enc_key,enc_key_nonce, file_header, thumb_header, metadata, priv_metadata, pub_metadata, info, created_at, updated_at, delete_by';
|
||||
|
||||
final String trashedFilesUpdateColumns = trashedFilesColumns
|
||||
.split(', ')
|
||||
.where((column) => (column != 'id'))
|
||||
.map((column) => '$column = excluded.$column') // Use excluded virtual table
|
||||
.join(', ');
|
||||
|
||||
const uploadLocalMappingColumns =
|
||||
'file_id, local_id, local_cloud_id, local_mapping_src';
|
||||
String collectionValuePlaceHolder =
|
||||
collectionColumns.split(',').map((_) => '?').join(',');
|
||||
|
||||
class RemoteDBMigration {
|
||||
static const migrationScripts = [
|
||||
'''
|
||||
CREATE TABLE collections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
enc_key TEXT NOT NULL,
|
||||
enc_key_nonce TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
local_path TEXT,
|
||||
is_deleted INTEGER NOT NULL,
|
||||
updation_time INTEGER NOT NULL,
|
||||
sharees TEXT NOT NULL DEFAULT '[]',
|
||||
public_urls TEXT NOT NULL DEFAULT '[]',
|
||||
mmd_encoded_json TEXT NOT NULL DEFAULT '{}',
|
||||
mmd_ver INTEGER NOT NULL DEFAULT 0,
|
||||
pub_mmd_encoded_json TEXT DEFAULT '{}',
|
||||
pub_mmd_ver INTEGER NOT NULL DEFAULT 0,
|
||||
shared_mmd_json TEXT NOT NULL DEFAULT '{}',
|
||||
shared_mmd_ver INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE collection_files (
|
||||
file_id INTEGER NOT NULL,
|
||||
collection_id INTEGER NOT NULL,
|
||||
enc_key BLOB NOT NULL,
|
||||
enc_key_nonce BLOB NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (file_id, collection_id)
|
||||
);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
file_header BLOB NOT NULL,
|
||||
thumb_header BLOB NOT NULL,
|
||||
creation_time INTEGER NOT NULL,
|
||||
modification_time INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
subtype INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
size INTEGER,
|
||||
hash TEXT,
|
||||
visibility integer,
|
||||
durationInSec INTEGER,
|
||||
lat REAL DEFAULT NULL,
|
||||
lng REAL DEFAULT NULL,
|
||||
height INTEGER,
|
||||
width INTEGER,
|
||||
no_thumb INTEGER,
|
||||
sv INTEGER,
|
||||
media_type INTEGER,
|
||||
motion_video_index INTEGER,
|
||||
caption TEXT,
|
||||
uploader_name TEXT
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS file_hash_index ON files(hash);
|
||||
''',
|
||||
'''
|
||||
CREATE INDEX IF NOT EXISTS file_creation_time_index ON files(creation_time);
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE files_metadata (
|
||||
id INTEGER PRIMARY KEY,
|
||||
metadata TEXT NOT NULL,
|
||||
priv_metadata TEXT,
|
||||
pub_metadata TEXT,
|
||||
info TEXT,
|
||||
FOREIGN KEY (id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE trash (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL,
|
||||
collection_id INTEGER NOT NULL,
|
||||
enc_key BLOB NOT NULL,
|
||||
enc_key_nonce BLOB NOT NULL,
|
||||
metadata TEXT NOT NULL,
|
||||
priv_metadata TEXT,
|
||||
pub_metadata TEXT,
|
||||
info TEXT,
|
||||
file_header BLOB NOT NULL,
|
||||
thumb_header BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
delete_by INTEGER NOT NULL
|
||||
)
|
||||
''',
|
||||
'''
|
||||
CREATE TRIGGER delete_orphaned_files
|
||||
AFTER DELETE ON collection_files
|
||||
FOR EACH ROW
|
||||
WHEN (
|
||||
-- Only proceed if this file_id actually existed before deletion
|
||||
OLD.file_id IS NOT NULL
|
||||
-- And only if this was the last reference to the file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM collection_files
|
||||
WHERE file_id = OLD.file_id
|
||||
)
|
||||
)
|
||||
BEGIN
|
||||
-- Only then delete from files table
|
||||
DELETE FROM files WHERE id = OLD.file_id;
|
||||
END;
|
||||
''',
|
||||
'''
|
||||
CREATE TABLE upload_mapping (
|
||||
file_id INTEGER PRIMARY KEY,
|
||||
local_id TEXT NOT NULL,
|
||||
-- icloud identifier if available
|
||||
local_cloud_id TEXT,
|
||||
local_mapping_src TEXT DEFAULT NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
||||
)'''
|
||||
];
|
||||
}
|
||||
|
||||
class FilterQueryParam {
|
||||
int? collectionID;
|
||||
int? limit;
|
||||
int? offset;
|
||||
String? orderByColumn;
|
||||
bool isAsc;
|
||||
(int?, int?)? createAtRange;
|
||||
|
||||
FilterQueryParam({
|
||||
this.limit,
|
||||
this.offset,
|
||||
this.collectionID,
|
||||
this.orderByColumn = "creation_time",
|
||||
this.isAsc = false,
|
||||
this.createAtRange,
|
||||
});
|
||||
|
||||
String get orderBy => orderByColumn == null
|
||||
? ""
|
||||
: "ORDER BY $orderByColumn ${isAsc ? "ASC" : "DESC"}";
|
||||
|
||||
String get limitOffset => (limit != null && offset != null)
|
||||
? "LIMIT $limit + OFFSET $offset)"
|
||||
: (limit != null)
|
||||
? "LIMIT $limit"
|
||||
: "";
|
||||
|
||||
String get collectionFilter =>
|
||||
(collectionID == null) ? "" : "collection_id = $collectionID";
|
||||
|
||||
String get createAtRangeStr => (createAtRange == null ||
|
||||
createAtRange!.$1 == null)
|
||||
? ""
|
||||
: "(creation_time BETWEEN ${createAtRange!.$1} AND ${createAtRange!.$2})";
|
||||
|
||||
String whereClause() {
|
||||
final where = <String>[];
|
||||
if (collectionFilter.isNotEmpty) {
|
||||
where.add(collectionFilter);
|
||||
}
|
||||
if (createAtRangeStr.isNotEmpty) {
|
||||
where.add(createAtRangeStr);
|
||||
}
|
||||
|
||||
return (where.isEmpty ? "" : where.join(" AND ")) +
|
||||
" " +
|
||||
orderBy +
|
||||
" " +
|
||||
limitOffset;
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/extensions/stop_watch.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
|
||||
extension CollectionFiles on RemoteDB {
|
||||
Future<int> getCollectionFileCount(int collectionID) async {
|
||||
final row = await sqliteDB.get(
|
||||
"SELECT COUNT(*) as count FROM collection_files WHERE collection_id = ?",
|
||||
[collectionID],
|
||||
);
|
||||
return row["count"] as int;
|
||||
}
|
||||
|
||||
Future<Set<int>> getUploadedFileIDs(int collectionID) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT file_id FROM collection_files WHERE collection_id = ?",
|
||||
[collectionID],
|
||||
);
|
||||
final Set<int> fileIDs = {};
|
||||
for (var row in rows) {
|
||||
fileIDs.add(row["file_id"] as int);
|
||||
}
|
||||
return fileIDs;
|
||||
}
|
||||
|
||||
Future<Set<int>> getAllCollectionIDsOfFile(int fileID) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id FROM collection_files WHERE file_id = ?",
|
||||
[fileID],
|
||||
);
|
||||
final Set<int> collectionIDs = {};
|
||||
for (var row in rows) {
|
||||
collectionIDs.add(row["collection_id"] as int);
|
||||
}
|
||||
return collectionIDs;
|
||||
}
|
||||
|
||||
Future<Map<int, List<CollectionFile>>> getCollectionFilesGroupedByCollection(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
final result = <int, List<CollectionFile>>{};
|
||||
if (fileIDs.isEmpty) {
|
||||
return result;
|
||||
}
|
||||
final inParam = fileIDs.map((id) => "'$id'").join(',');
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT * FROM collection_files WHERE file_id IN ($inParam)',
|
||||
);
|
||||
for (final row in results) {
|
||||
final eachFile = CollectionFile.fromMap(row);
|
||||
if (!result.containsKey(eachFile.collectionID)) {
|
||||
result[eachFile.collectionID] = <CollectionFile>[];
|
||||
}
|
||||
result[eachFile.collectionID]!.add(eachFile);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getAllCFForFileIDs(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
if (fileIDs.isEmpty) return [];
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT * FROM collection_files WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIdToFileCount(List<int> fileIDs) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id, COUNT(*) as count FROM collection_files WHERE file_id IN (${fileIDs.join(",")}) GROUP BY collection_id",
|
||||
);
|
||||
final Map<int, int> collectionIdToFileCount = {};
|
||||
for (var row in rows) {
|
||||
final collectionId = row["collection_id"] as int;
|
||||
final count = row["count"] as int;
|
||||
collectionIdToFileCount[collectionId] = count;
|
||||
}
|
||||
return collectionIdToFileCount;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getCollectionFiles(
|
||||
FilterQueryParam? params,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE ${params?.whereClause() ?? "order by creation_time desc"}",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getCollectionsFiles(
|
||||
Set<int> collectionIDs,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE collection_id IN (${collectionIDs.join(",")}) ORDER BY creation_time DESC",
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Map<int, CollectionFile>> getFileIdToCollectionFile(
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files on collection_files.file_id=files.id WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
final Map<int, CollectionFile> result = {};
|
||||
for (var row in rows) {
|
||||
final entry = CollectionFile.fromMap(row);
|
||||
result[entry.fileID] = entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getAllFiles(int userID) {
|
||||
return sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.owner_id = ? ORDER BY files.creation_time DESC",
|
||||
[userID],
|
||||
).then(
|
||||
(rows) => rows.map((row) => CollectionFile.fromMap(row)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<(Set<int>, Map<String, int>)> getUploadAndHash(
|
||||
int collectionID,
|
||||
) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT id, hash FROM collection_files JOIN files ON files.id = collection_files.file_id'
|
||||
' WHERE collection_id = ?',
|
||||
[
|
||||
collectionID,
|
||||
],
|
||||
);
|
||||
final ids = <int>{};
|
||||
final hash = <String, int>{};
|
||||
for (final result in results) {
|
||||
ids.add(result['id'] as int);
|
||||
if (result['hash'] != null) {
|
||||
hash[result['hash'] as String] = result['id'] as int;
|
||||
}
|
||||
}
|
||||
return (ids, hash);
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> ownedFilesWithSameHash(
|
||||
List<String> hashes,
|
||||
int ownerID,
|
||||
) async {
|
||||
if (hashes.isEmpty) return [];
|
||||
final inParam = hashes.map((e) => "'$e'").join(',');
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.hash IN ($inParam) AND files.owner_id = ?",
|
||||
[ownerID],
|
||||
);
|
||||
return rows
|
||||
.map((row) => CollectionFile.fromMap(row))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<CollectionFile?> coverFile(
|
||||
int collectionID,
|
||||
int? fileID, {
|
||||
bool sortInAsc = false,
|
||||
}) async {
|
||||
if (fileID != null) {
|
||||
final entry = await getCollectionFileEntry(collectionID, fileID);
|
||||
if (entry != null) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
final sortedRow = await sqliteDB.getOptional(
|
||||
"SELECT collection_files.* FROM collection_files join files on files.id= collection_files.file_id WHERE collection_id = ? ORDER BY files.creation_time ${sortInAsc ? 'ASC' : 'DESC'} LIMIT 1",
|
||||
[collectionID],
|
||||
);
|
||||
if (sortedRow != null) {
|
||||
return CollectionFile.fromMap(sortedRow);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CollectionFile?> getCollectionFileEntry(
|
||||
int collectionID,
|
||||
int fileID,
|
||||
) async {
|
||||
final row = await sqliteDB.getOptional(
|
||||
"SELECT * FROM collection_files WHERE collection_id = ? AND file_id = ?",
|
||||
[collectionID, fileID],
|
||||
);
|
||||
if (row != null) {
|
||||
return CollectionFile.fromMap(row);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CollectionFile?> getAnyCollectionEntry(
|
||||
int fileID,
|
||||
) async {
|
||||
final row = await sqliteDB.getAll(
|
||||
"SELECT * FROM collection_files WHERE file_id = ? limit 1",
|
||||
[fileID],
|
||||
);
|
||||
if (row.isNotEmpty) {
|
||||
return CollectionFile.fromMap(row.first);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> getFilesCreatedWithinDurations(
|
||||
List<List<int>> durations,
|
||||
Set<int> ignoredCollectionIDs, {
|
||||
String order = 'DESC',
|
||||
}) async {
|
||||
final List<CollectionFile> result = [];
|
||||
for (final duration in durations) {
|
||||
final start = duration[0];
|
||||
final end = duration[1];
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_files.* FROM collection_files join files on files.id=collection_files.file_id WHERE files.creation_time BETWEEN ? AND ? AND collection_id NOT IN (${ignoredCollectionIDs.join(",")}) ORDER BY creation_time $order",
|
||||
[start, end],
|
||||
);
|
||||
result.addAll(rows.map((row) => CollectionFile.fromMap(row)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<CollectionFile>> filesWithLocation() {
|
||||
return sqliteDB
|
||||
.getAll(
|
||||
"SELECT collection_files.* FROM collection_files JOIN files ON collection_files.file_id = files.id WHERE files.lat IS NOT NULL and files.lng IS NOT NULL order by files.creation_time desc",
|
||||
)
|
||||
.then(
|
||||
(rows) => rows.map((row) => CollectionFile.fromMap(row)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteFiles(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE file_id IN (${fileIDs.join(",")})",
|
||||
);
|
||||
debugPrint(
|
||||
'$runtimeType deleteFiles complete in ${stopwatch.elapsed.inMilliseconds}ms for ${fileIDs.length}',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCollectionFiles(List<int> cIDs) async {
|
||||
if (cIDs.isEmpty) return;
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE collection_id IN (${cIDs.join(",")})",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCFEnteries(
|
||||
int collectionID,
|
||||
List<int> fileIDs,
|
||||
) async {
|
||||
if (fileIDs.isEmpty) return;
|
||||
await sqliteDB.execute(
|
||||
"DELETE FROM collection_files WHERE collection_id = ? AND file_id IN (${fileIDs.join(",")})",
|
||||
[collectionID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getCollectionIDToMaxCreationTime() async {
|
||||
final enteWatch = EnteWatch("getCollectionIDToMaxCreationTime")..start();
|
||||
final rows = await sqliteDB.getAll(
|
||||
'''SELECT collection_id, MAX(creation_time) as max_creation_time FROM collection_files join files on
|
||||
collection_files.file_id=files.id GROUP BY collection_id''',
|
||||
);
|
||||
final Map<int, int> result = {};
|
||||
for (var row in rows) {
|
||||
final collectionId = row["collection_id"] as int;
|
||||
final maxCreationTime = row["max_creation_time"] as int;
|
||||
result[collectionId] = maxCreationTime;
|
||||
}
|
||||
enteWatch.log("query done");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
|
||||
extension FilesTable on RemoteDB {
|
||||
// For a given userID, return unique uploadedFileId for the given userID
|
||||
Future<List<int>> fileIDsWithMissingSize(int userId) async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id FROM files WHERE owner_id = ? AND size = -1",
|
||||
[userId],
|
||||
);
|
||||
final result = <int>[];
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as int);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<int, int>> getIDToCreationTime() async {
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id, creation_time FROM files",
|
||||
);
|
||||
final result = <int, int>{};
|
||||
for (final row in rows) {
|
||||
result[row['id'] as int] = row['creation_time'] as int;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<int, Metadata?>> getIDToMetadata(
|
||||
Set<int> ids, {
|
||||
bool private = false,
|
||||
bool public = false,
|
||||
bool metadata = false,
|
||||
}) async {
|
||||
if (ids.isEmpty) return {};
|
||||
|
||||
// Ensure only one parameter is true
|
||||
final trueCount = [private, public, metadata].where((x) => x).length;
|
||||
if (trueCount != 1) {
|
||||
throw ArgumentError(
|
||||
'Exactly one of private, public, or metadata must be true',
|
||||
);
|
||||
}
|
||||
|
||||
final placeholders = List.filled(ids.length, '?').join(',');
|
||||
String column;
|
||||
|
||||
if (private) {
|
||||
column = 'priv_metadata';
|
||||
} else if (public) {
|
||||
column = 'pub_metadata';
|
||||
} else {
|
||||
column = 'metadata';
|
||||
}
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT id, $column FROM files_metadata WHERE id IN ($placeholders)",
|
||||
ids.toList(),
|
||||
);
|
||||
final result = <int, Metadata?>{};
|
||||
for (final row in rows) {
|
||||
final metadata = Metadata.fromEncodedJson(row[column]);
|
||||
result[row['id'] as int] = metadata;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Set<int>> idsWithSameHashAndType(String hash, int ownerID) {
|
||||
return sqliteDB.getAll(
|
||||
"SELECT id FROM files WHERE hash = ? AND owner_id = ?",
|
||||
[hash, ownerID],
|
||||
).then((rows) {
|
||||
final result = <int>{};
|
||||
for (final row in rows) {
|
||||
result.add(row['id'] as int);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// updateSizeForUploadIDs takes a map of upploadedFileID and fileSize and
|
||||
// update the fileSize for the given uploadedFileID
|
||||
Future<void> updateSize(
|
||||
Map<int, int> idToSize,
|
||||
) async {
|
||||
final parameterSets = <List<Object?>>[];
|
||||
for (final id in idToSize.keys) {
|
||||
parameterSets.add([idToSize[id], id]);
|
||||
}
|
||||
return sqliteDB.executeBatch(
|
||||
"UPDATE files SET size = ? WHERE id = ?;",
|
||||
parameterSets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<int>> getAllFilesAfterDate({
|
||||
required FileType fileType,
|
||||
required DateTime beginDate,
|
||||
required int userID,
|
||||
}) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'''
|
||||
SELECT files.id FROM files join upload_mapping
|
||||
ON files.id = upload_mapping.file_id
|
||||
WHERE file_type = ?
|
||||
AND creation_time > ?
|
||||
AND owner_id = ?
|
||||
AND (size IS NOT NULL AND size <= 524288000)
|
||||
AND (durationInSec IS NOT NULL AND (durationInSec <= 60 AND durationInSec > 0))
|
||||
''',
|
||||
[getInt(fileType), beginDate.microsecondsSinceEpoch, userID],
|
||||
);
|
||||
final fileIDs = <int>[];
|
||||
for (final row in results) {
|
||||
fileIDs.add(row['id'] as int);
|
||||
}
|
||||
return fileIDs;
|
||||
}
|
||||
|
||||
Future<Map<int, List<(int, Metadata?)>>> getNotificationCandidate(
|
||||
List<int> collectionIDs,
|
||||
int lastAppOpen,
|
||||
) async {
|
||||
if (collectionIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(collectionIDs.length, '?').join(',');
|
||||
final rows = await sqliteDB.getAll(
|
||||
"SELECT collection_id, files.owner_id, metadata FROM collection_files join files ON collection_files.file_id = files.id WHERE collection_id IN ($placeholders) AND collection_files.created_at > ?",
|
||||
[...collectionIDs, lastAppOpen],
|
||||
);
|
||||
final result = <int, List<(int, Metadata?)>>{};
|
||||
for (final row in rows) {
|
||||
final collectionID = row['collection_id'] as int;
|
||||
final ownerID = row['owner_id'] as int;
|
||||
final metadata = Metadata.fromEncodedJson(row['metadata']);
|
||||
result.putIfAbsent(collectionID, () => []).add((ownerID, metadata));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<int> getFilesCountByVisibility(
|
||||
int visibility,
|
||||
int ownerID,
|
||||
Set<int> hiddenCollections,
|
||||
) async {
|
||||
String subQuery = '';
|
||||
if (hiddenCollections.isNotEmpty) {
|
||||
subQuery =
|
||||
'AND id NOT IN (SELECT file_id FROM collection_files WHERE collection_id IN (${hiddenCollections.join(',')}))';
|
||||
}
|
||||
final row = await sqliteDB.get(
|
||||
'SELECT COUNT(id) as count FROM files WHERE visibility = ? AND owner_id = ? $subQuery',
|
||||
[visibility, ownerID],
|
||||
);
|
||||
return row['count'] as int;
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/models/backup_status.dart";
|
||||
import "package:photos/models/file/remote/rl_mapping.dart";
|
||||
|
||||
extension UploadMappingTable on RemoteDB {
|
||||
Future<void> insertMappings(List<RLMapping> mappings) async {
|
||||
if (mappings.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(mappings.slices(1000), (slice) async {
|
||||
final List<List<Object?>> values = slice.map((e) => e.rowValues).toList();
|
||||
await sqliteDB.executeBatch(
|
||||
'INSERT INTO upload_mapping ($uploadLocalMappingColumns) values(?,?,?,?)',
|
||||
values,
|
||||
);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertMappings complete in ${stopwatch.elapsed.inMilliseconds}ms for ${mappings.length} mappings',
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<RLMapping>> getMappings() async {
|
||||
final result = <RLMapping>[];
|
||||
final cursor = await sqliteDB.getAll("SELECT * FROM upload_mapping");
|
||||
for (final row in cursor) {
|
||||
result.add(rowToUploadLocalMapping(row));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> deleteMappingsForLocalIDs(Set<String> localIDs) async {
|
||||
if (localIDs.isEmpty) return;
|
||||
final placeholders = List.filled(localIDs.length, '?').join(',');
|
||||
await sqliteDB.execute(
|
||||
'DELETE FROM upload_mapping WHERE local_id IN ($placeholders)',
|
||||
localIDs.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, RLMapping>> getLocalIDToMappingForActiveFiles() async {
|
||||
final result = <String, RLMapping>{};
|
||||
final cursor = await sqliteDB.getAll(
|
||||
"SELECT * FROM upload_mapping join files on upload_mapping.file_id = files.id",
|
||||
);
|
||||
for (final row in cursor) {
|
||||
final mapping = rowToUploadLocalMapping(row);
|
||||
result[mapping.localID] = mapping;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// getLocalIDsForUser returns information about the localIDs that have been
|
||||
// uploaded for the given userID. If the localIDSInGivenPath is not null,
|
||||
// it will only return the localIDs that are in the given path.
|
||||
Future<BackedUpFileIDs> getLocalIDsForUser(
|
||||
int userID,
|
||||
Set<String>? localIDSInGivenPath,
|
||||
) async {
|
||||
final results = await sqliteDB.getAll(
|
||||
'SELECT local_id, files.id, size FROM upload_mapping join files on upload_mapping.file_id = files.id WHERE owner_id = ?',
|
||||
[userID],
|
||||
);
|
||||
|
||||
final Set<String> localIDs = <String>{};
|
||||
final Set<int> uploadedIDs = <int>{};
|
||||
int localSize = 0;
|
||||
for (final result in results) {
|
||||
final String localID = result['local_id'] as String;
|
||||
if (localIDSInGivenPath != null &&
|
||||
!localIDSInGivenPath.contains(localID)) {
|
||||
continue; // Skip if not in the given path
|
||||
}
|
||||
final int? fileSize = result['size'] as int?;
|
||||
if (!localIDs.contains(localID) && fileSize != null) {
|
||||
localSize += fileSize;
|
||||
}
|
||||
localIDs.add(localID);
|
||||
uploadedIDs.add(result['id'] as int);
|
||||
}
|
||||
return BackedUpFileIDs(localIDs.toList(), uploadedIDs.toList(), localSize);
|
||||
}
|
||||
|
||||
Future<Set<String>> getLocalIDsWithMapping(List<String> localIDs) async {
|
||||
if (localIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(localIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT local_id FROM upload_mapping join files on upload_mapping.file_id = files.id WHERE local_id IN ($placeholders)',
|
||||
localIDs,
|
||||
);
|
||||
return cursor.map((row) => row['local_id'] as String).toSet();
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getFileIDToLocalIDMapping(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(fileIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT file_id, local_id FROM upload_mapping WHERE file_id IN ($placeholders)',
|
||||
fileIDs,
|
||||
);
|
||||
return Map.fromEntries(
|
||||
cursor.map(
|
||||
(row) => MapEntry(row['file_id'] as int, row['local_id'] as String),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<int>> getFilesWithMapping(List<int> fileIDs) async {
|
||||
if (fileIDs.isEmpty) return {};
|
||||
final placeholders = List.filled(fileIDs.length, '?').join(',');
|
||||
final cursor = await sqliteDB.getAll(
|
||||
'SELECT file_id FROM upload_mapping WHERE file_id IN ($placeholders)',
|
||||
fileIDs,
|
||||
);
|
||||
return cursor.map((row) => row['file_id'] as int).toSet();
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import "package:collection/collection.dart";
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/db/remote/db.dart";
|
||||
import "package:photos/db/remote/mappers.dart";
|
||||
import "package:photos/db/remote/schema.dart";
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
extension TrashTable on RemoteDB {
|
||||
Future<void> insertTrashDiffItems(List<DiffItem> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await Future.forEach(items.slices(1000), (slice) async {
|
||||
final List<List<Object?>> trashRowValues = [];
|
||||
for (final item in slice) {
|
||||
trashRowValues.add(item.trashRowValues());
|
||||
}
|
||||
await Future.wait([
|
||||
sqliteDB.executeBatch(
|
||||
'INSERT INTO trash ($trashedFilesColumns) values(${getParams(14)})',
|
||||
trashRowValues,
|
||||
),
|
||||
]);
|
||||
});
|
||||
debugPrint(
|
||||
'$runtimeType insertCollectionFilesDiff complete in ${stopwatch.elapsed.inMilliseconds}ms for ${items.length}',
|
||||
);
|
||||
}
|
||||
|
||||
// removes the items and returns the number of items removed
|
||||
Future<int> removeTrashItems(List<int> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
final result = await sqliteDB.execute(
|
||||
'DELETE FROM trash WHERE id IN (${ids.join(",")})',
|
||||
);
|
||||
return result.isNotEmpty ? result.first['changes'] as int : 0;
|
||||
}
|
||||
|
||||
Future<List<EnteFile>> getTrashFiles() async {
|
||||
final result = await sqliteDB.getAll(
|
||||
'SELECT * FROM trash',
|
||||
);
|
||||
return result.map((e) => trashRowToEnteFile(e)).toList();
|
||||
}
|
||||
|
||||
Future<void> clearTrash() async {
|
||||
await sqliteDB.execute('DELETE FROM trash');
|
||||
}
|
||||
}
|
||||
250
mobile/apps/photos/lib/db/trash_db.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:photos/models/file/trash_file.dart';
|
||||
import 'package:photos/models/file_load_result.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
// The TrashDB doesn't need to flatten and store all attributes of a file.
|
||||
// Before adding any other column, we should evaluate if we need to query on that
|
||||
// column or not while showing trashed items. Even if we miss storing any new attributes,
|
||||
// during restore, all file attributes will be fetched & stored as required.
|
||||
class TrashDB {
|
||||
static const _databaseName = "ente.trash.db";
|
||||
static const _databaseVersion = 1;
|
||||
static final Logger _logger = Logger("TrashDB");
|
||||
static const tableName = 'trash';
|
||||
|
||||
static const columnUploadedFileID = 'uploaded_file_id';
|
||||
static const columnCollectionID = 'collection_id';
|
||||
static const columnOwnerID = 'owner_id';
|
||||
static const columnTrashUpdatedAt = 't_updated_at';
|
||||
static const columnTrashDeleteBy = 't_delete_by';
|
||||
static const columnEncryptedKey = 'encrypted_key';
|
||||
static const columnKeyDecryptionNonce = 'key_decryption_nonce';
|
||||
static const columnFileDecryptionHeader = 'file_decryption_header';
|
||||
static const columnThumbnailDecryptionHeader = 'thumbnail_decryption_header';
|
||||
static const columnUpdationTime = 'updation_time';
|
||||
|
||||
static const columnCreationTime = 'creation_time';
|
||||
static const columnLocalID = 'local_id';
|
||||
|
||||
// standard file metadata, which isn't editable
|
||||
static const columnFileMetadata = 'file_metadata';
|
||||
|
||||
static const columnMMdEncodedJson = 'mmd_encoded_json';
|
||||
static const columnMMdVersion = 'mmd_ver';
|
||||
|
||||
static const columnPubMMdEncodedJson = 'pub_mmd_encoded_json';
|
||||
static const columnPubMMdVersion = 'pub_mmd_ver';
|
||||
|
||||
Future _onCreate(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $tableName (
|
||||
$columnUploadedFileID INTEGER PRIMARY KEY NOT NULL,
|
||||
$columnCollectionID INTEGER NOT NULL,
|
||||
$columnOwnerID INTEGER,
|
||||
$columnTrashUpdatedAt INTEGER NOT NULL,
|
||||
$columnTrashDeleteBy INTEGER NOT NULL,
|
||||
$columnEncryptedKey TEXT,
|
||||
$columnKeyDecryptionNonce TEXT,
|
||||
$columnFileDecryptionHeader TEXT,
|
||||
$columnThumbnailDecryptionHeader TEXT,
|
||||
$columnUpdationTime INTEGER,
|
||||
$columnLocalID TEXT,
|
||||
$columnCreationTime INTEGER NOT NULL,
|
||||
$columnFileMetadata TEXT DEFAULT '{}',
|
||||
$columnMMdEncodedJson TEXT DEFAULT '{}',
|
||||
$columnMMdVersion INTEGER DEFAULT 0,
|
||||
$columnPubMMdEncodedJson TEXT DEFAULT '{}',
|
||||
$columnPubMMdVersion INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS creation_time_index ON $tableName($columnCreationTime);
|
||||
CREATE INDEX IF NOT EXISTS delete_by_time_index ON $tableName($columnTrashDeleteBy);
|
||||
CREATE INDEX IF NOT EXISTS updated_at_time_index ON $tableName($columnTrashUpdatedAt);
|
||||
''',
|
||||
);
|
||||
}
|
||||
|
||||
TrashDB._privateConstructor();
|
||||
|
||||
static final TrashDB instance = TrashDB._privateConstructor();
|
||||
|
||||
// only have a single app-wide reference to the database
|
||||
static Future<Database>? _dbFuture;
|
||||
|
||||
Future<Database> get database async {
|
||||
// lazily instantiate the db the first time it is accessed
|
||||
_dbFuture ??= _initDatabase();
|
||||
return _dbFuture!;
|
||||
}
|
||||
|
||||
// this opens the database (and creates it if it doesn't exist)
|
||||
Future<Database> _initDatabase() async {
|
||||
final Directory documentsDirectory =
|
||||
await getApplicationDocumentsDirectory();
|
||||
final String path = join(documentsDirectory.path, _databaseName);
|
||||
_logger.info("DB path " + path);
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: _databaseVersion,
|
||||
onCreate: _onCreate,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await instance.database;
|
||||
await db.delete(tableName);
|
||||
}
|
||||
|
||||
Future<int> count() async {
|
||||
final db = await instance.database;
|
||||
final count = Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM $tableName'),
|
||||
);
|
||||
return count ?? 0;
|
||||
}
|
||||
|
||||
Future<void> insertMultiple(List<TrashFile> trashFiles) async {
|
||||
final startTime = DateTime.now();
|
||||
final db = await instance.database;
|
||||
var batch = db.batch();
|
||||
int batchCounter = 0;
|
||||
for (TrashFile trash in trashFiles) {
|
||||
if (batchCounter == 400) {
|
||||
await batch.commit(noResult: true);
|
||||
batch = db.batch();
|
||||
batchCounter = 0;
|
||||
}
|
||||
batch.insert(
|
||||
tableName,
|
||||
_getRowForTrash(trash),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
batchCounter++;
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
final endTime = DateTime.now();
|
||||
final duration = Duration(
|
||||
microseconds:
|
||||
endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch,
|
||||
);
|
||||
_logger.info(
|
||||
"Batch insert of " +
|
||||
trashFiles.length.toString() +
|
||||
" took " +
|
||||
duration.inMilliseconds.toString() +
|
||||
"ms.",
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> delete(List<int> uploadedFileIDs) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
tableName,
|
||||
where: '$columnUploadedFileID IN (${uploadedFileIDs.join(', ')})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> update(TrashFile file) async {
|
||||
final db = await instance.database;
|
||||
return await db.update(
|
||||
tableName,
|
||||
_getRowForTrash(file),
|
||||
where: '$columnUploadedFileID = ?',
|
||||
whereArgs: [file.uploadedFileID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<FileLoadResult> getTrashedFiles(
|
||||
int startTime,
|
||||
int endTime, {
|
||||
int? limit,
|
||||
bool? asc,
|
||||
}) async {
|
||||
final db = await instance.database;
|
||||
final order = (asc ?? false ? 'ASC' : 'DESC');
|
||||
final results = await db.query(
|
||||
tableName,
|
||||
where: '$columnCreationTime >= ? AND $columnCreationTime <= ?',
|
||||
whereArgs: [startTime, endTime],
|
||||
orderBy: '$columnCreationTime ' + order,
|
||||
limit: limit,
|
||||
);
|
||||
final files = _convertToFiles(results);
|
||||
return FileLoadResult(files, files.length == limit);
|
||||
}
|
||||
|
||||
List<TrashFile> _convertToFiles(List<Map<String, dynamic>> results) {
|
||||
final List<TrashFile> trashedFiles = [];
|
||||
for (final result in results) {
|
||||
trashedFiles.add(_getTrashFromRow(result));
|
||||
}
|
||||
return trashedFiles;
|
||||
}
|
||||
|
||||
TrashFile _getTrashFromRow(Map<String, dynamic> row) {
|
||||
final trashFile = TrashFile();
|
||||
trashFile.updateAt = row[columnTrashUpdatedAt];
|
||||
trashFile.deleteBy = row[columnTrashDeleteBy];
|
||||
trashFile.uploadedFileID = row[columnUploadedFileID];
|
||||
// dirty hack to ensure that the file_downloads & cache mechanism works
|
||||
trashFile.generatedID = -1 * trashFile.uploadedFileID!;
|
||||
trashFile.ownerID = row[columnOwnerID];
|
||||
trashFile.collectionID =
|
||||
row[columnCollectionID] == -1 ? null : row[columnCollectionID];
|
||||
trashFile.encryptedKey = row[columnEncryptedKey];
|
||||
trashFile.keyDecryptionNonce = row[columnKeyDecryptionNonce];
|
||||
trashFile.fileDecryptionHeader = row[columnFileDecryptionHeader];
|
||||
trashFile.thumbnailDecryptionHeader = row[columnThumbnailDecryptionHeader];
|
||||
trashFile.updationTime = row[columnUpdationTime] ?? 0;
|
||||
trashFile.creationTime = row[columnCreationTime];
|
||||
final fileMetadata = row[columnFileMetadata] ?? '{}';
|
||||
trashFile.applyMetadata(jsonDecode(fileMetadata));
|
||||
trashFile.localID = row[columnLocalID];
|
||||
|
||||
trashFile.mMdVersion = row[columnMMdVersion] ?? 0;
|
||||
trashFile.mMdEncodedJson = row[columnMMdEncodedJson] ?? '{}';
|
||||
|
||||
trashFile.pubMmdVersion = row[columnPubMMdVersion] ?? 0;
|
||||
trashFile.pubMmdEncodedJson = row[columnPubMMdEncodedJson] ?? '{}';
|
||||
|
||||
if (trashFile.pubMagicMetadata != null &&
|
||||
trashFile.pubMagicMetadata!.editedTime != null) {
|
||||
// override existing creationTime to avoid re-writing all queries related
|
||||
// to loading the gallery
|
||||
row[columnCreationTime] = trashFile.pubMagicMetadata!.editedTime!;
|
||||
}
|
||||
|
||||
return trashFile;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getRowForTrash(TrashFile trash) {
|
||||
final row = <String, dynamic>{};
|
||||
row[columnTrashUpdatedAt] = trash.updateAt;
|
||||
row[columnTrashDeleteBy] = trash.deleteBy;
|
||||
row[columnUploadedFileID] = trash.uploadedFileID;
|
||||
row[columnCollectionID] = trash.collectionID;
|
||||
row[columnOwnerID] = trash.ownerID;
|
||||
row[columnEncryptedKey] = trash.encryptedKey;
|
||||
row[columnKeyDecryptionNonce] = trash.keyDecryptionNonce;
|
||||
row[columnFileDecryptionHeader] = trash.fileDecryptionHeader;
|
||||
row[columnThumbnailDecryptionHeader] = trash.thumbnailDecryptionHeader;
|
||||
row[columnUpdationTime] = trash.updationTime;
|
||||
|
||||
row[columnLocalID] = trash.localID;
|
||||
row[columnCreationTime] = trash.creationTime;
|
||||
row[columnFileMetadata] = jsonEncode(trash.metadata);
|
||||
|
||||
row[columnMMdVersion] = trash.mMdVersion;
|
||||
row[columnMMdEncodedJson] = trash.mMdEncodedJson ?? '{}';
|
||||
|
||||
row[columnPubMMdVersion] = trash.pubMmdVersion;
|
||||
row[columnPubMMdEncodedJson] = trash.pubMmdEncodedJson ?? '{}';
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ class UploadLocksDB {
|
||||
|
||||
static final migrationScripts = [
|
||||
..._createTrackUploadsTable(),
|
||||
..._createStreamQueueTable(),
|
||||
];
|
||||
|
||||
final dbConfig = MigrationConfig(
|
||||
@@ -141,6 +142,11 @@ class UploadLocksDB {
|
||||
${_streamUploadErrorTable.columnCreatedAt} INTEGER DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
''',
|
||||
];
|
||||
}
|
||||
|
||||
static List<String> _createStreamQueueTable() {
|
||||
return [
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS ${_streamQueueTable.table} (
|
||||
${_streamQueueTable.columnUploadedFileID} INTEGER PRIMARY KEY,
|
||||
@@ -152,7 +158,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<void> clearTable() async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
await db.delete(_uploadLocksTable.table);
|
||||
await db.delete(_trackUploadTable.table);
|
||||
await db.delete(_partsTable.table);
|
||||
@@ -160,7 +166,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<void> acquireLock(String id, String owner, int time) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
final row = <String, dynamic>{};
|
||||
row[_uploadLocksTable.columnID] = id;
|
||||
row[_uploadLocksTable.columnOwner] = owner;
|
||||
@@ -173,7 +179,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<String> getLockData(String id) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(
|
||||
_uploadLocksTable.table,
|
||||
where: '${_uploadLocksTable.columnID} = ?',
|
||||
@@ -190,7 +196,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<bool> isLocked(String id, String owner) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(
|
||||
_uploadLocksTable.table,
|
||||
where:
|
||||
@@ -201,7 +207,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> releaseLock(String id, String owner) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
_uploadLocksTable.table,
|
||||
where:
|
||||
@@ -211,7 +217,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> releaseLocksAcquiredByOwnerBefore(String owner, int time) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
_uploadLocksTable.table,
|
||||
where:
|
||||
@@ -221,7 +227,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> releaseAllLocksAcquiredBefore(int time) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
_uploadLocksTable.table,
|
||||
where: '${_uploadLocksTable.columnTime} < ?',
|
||||
@@ -235,7 +241,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
@@ -262,7 +268,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
await db.update(
|
||||
_trackUploadTable.table,
|
||||
{
|
||||
@@ -285,7 +291,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
where: '${_trackUploadTable.columnLocalID} = ?'
|
||||
@@ -343,7 +349,7 @@ class UploadLocksDB {
|
||||
int uploadedFileID,
|
||||
String errorMessage,
|
||||
) async {
|
||||
final db = await database;
|
||||
final db = await UploadLocksDB.instance.database;
|
||||
|
||||
await db.insert(
|
||||
_streamUploadErrorTable.table,
|
||||
@@ -361,7 +367,7 @@ class UploadLocksDB {
|
||||
int uploadedFileID,
|
||||
String errorMessage,
|
||||
) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
await db.update(
|
||||
_streamUploadErrorTable.table,
|
||||
{
|
||||
@@ -375,7 +381,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<int> deleteStreamUploadErrorEntry(int uploadedFileID) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
return await db.delete(
|
||||
_streamUploadErrorTable.table,
|
||||
where: '${_streamUploadErrorTable.columnUploadedFileID} = ?',
|
||||
@@ -384,7 +390,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getStreamUploadError() {
|
||||
return database.then((db) async {
|
||||
return instance.database.then((db) async {
|
||||
final rows = await db.query(
|
||||
_streamUploadErrorTable.table,
|
||||
columns: [
|
||||
@@ -413,7 +419,7 @@ class UploadLocksDB {
|
||||
String keyNonce, {
|
||||
required int partSize,
|
||||
}) async {
|
||||
final db = await database;
|
||||
final db = await UploadLocksDB.instance.database;
|
||||
final objectKey = urls.objectKey;
|
||||
|
||||
await db.insert(
|
||||
@@ -456,7 +462,7 @@ class UploadLocksDB {
|
||||
int partNumber,
|
||||
String etag,
|
||||
) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
await db.update(
|
||||
_partsTable.table,
|
||||
{
|
||||
@@ -473,7 +479,7 @@ class UploadLocksDB {
|
||||
String objectKey,
|
||||
MultipartStatus status,
|
||||
) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
await db.update(
|
||||
_trackUploadTable.table,
|
||||
{
|
||||
@@ -487,7 +493,7 @@ class UploadLocksDB {
|
||||
Future<int> deleteMultipartTrack(
|
||||
String localId,
|
||||
) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
return await db.delete(
|
||||
_trackUploadTable.table,
|
||||
where: '${_trackUploadTable.columnLocalID} = ?',
|
||||
@@ -497,7 +503,7 @@ class UploadLocksDB {
|
||||
|
||||
// getFileNameToLastAttemptedAtMap returns a map of encrypted file name to last attempted at time
|
||||
Future<Map<String, int>> getFileNameToLastAttemptedAtMap() {
|
||||
return database.then((db) async {
|
||||
return instance.database.then((db) async {
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
columns: [
|
||||
@@ -519,7 +525,7 @@ class UploadLocksDB {
|
||||
String fileHash,
|
||||
int collectionID,
|
||||
) {
|
||||
return database.then((db) async {
|
||||
return instance.database.then((db) async {
|
||||
final rows = await db.query(
|
||||
_trackUploadTable.table,
|
||||
where: '${_trackUploadTable.columnLocalID} = ?'
|
||||
@@ -540,7 +546,7 @@ class UploadLocksDB {
|
||||
int uploadedFileID,
|
||||
String queueType, // 'create' or 'recreate'
|
||||
) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
await db.insert(
|
||||
_streamQueueTable.table,
|
||||
{
|
||||
@@ -552,7 +558,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<void> removeFromStreamQueue(int uploadedFileID) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
await db.delete(
|
||||
_streamQueueTable.table,
|
||||
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
||||
@@ -561,7 +567,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<Map<int, String>> getStreamQueue() async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(
|
||||
_streamQueueTable.table,
|
||||
columns: [
|
||||
@@ -578,7 +584,7 @@ class UploadLocksDB {
|
||||
}
|
||||
|
||||
Future<bool> isInStreamQueue(int uploadedFileID) async {
|
||||
final db = await database;
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(
|
||||
_streamQueueTable.table,
|
||||
where: '${_streamQueueTable.columnUploadedFileID} = ?',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class FileCaptionUpdatedEvent extends Event {
|
||||
final String fileTag;
|
||||
final int fileGeneratedID;
|
||||
|
||||
FileCaptionUpdatedEvent(this.fileTag);
|
||||
FileCaptionUpdatedEvent(this.fileGeneratedID);
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class LocalAssetChangedEvent extends Event {
|
||||
final String source;
|
||||
|
||||
LocalAssetChangedEvent(this.source);
|
||||
|
||||
@override
|
||||
String get reason => '$runtimeType{"via": $source}';
|
||||
}
|
||||
@@ -39,26 +39,20 @@ class EnteWatch extends Stopwatch {
|
||||
class TimeLogger {
|
||||
final String context;
|
||||
final int logThreshold;
|
||||
final DateTime _start;
|
||||
DateTime _toStringStart = DateTime.now();
|
||||
TimeLogger({this.context = "TLog:", this.logThreshold = 5})
|
||||
DateTime _start;
|
||||
TimeLogger({this.context = "TLog", this.logThreshold = 5})
|
||||
: _start = DateTime.now();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final int diff = DateTime.now().difference(_toStringStart).inMilliseconds;
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
late String res;
|
||||
if (diff > logThreshold) {
|
||||
res = "[$context$diff ms]";
|
||||
res = "[$context: $diff ms]";
|
||||
} else {
|
||||
res = "[]";
|
||||
}
|
||||
_toStringStart = DateTime.now();
|
||||
_start = DateTime.now();
|
||||
return res;
|
||||
}
|
||||
|
||||
String get elapsed {
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
return "[$context$diff ms]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
import "package:photos/core/cache/lru_map.dart";
|
||||
import "package:photos/models/file/file.dart";
|
||||
|
||||
// Singleton instance for global access
|
||||
final enteImageCache = InMemoryImageCache._instance;
|
||||
|
||||
class InMemoryImageCache {
|
||||
static final InMemoryImageCache _instance = InMemoryImageCache._();
|
||||
|
||||
// Private constructor for singleton
|
||||
InMemoryImageCache._();
|
||||
|
||||
// Supported dimensions with associated cache sizes
|
||||
static const Map<int, int> _cacheSizes = {
|
||||
32: 5000, // Small: 32*32 = 1024 bytes * 5000 = 6.25MB
|
||||
256: 2000, // Medium: 256*256 = 65536 bytes * 2000 = 128MB
|
||||
512: 100, // Large: 512*512 = 262144 bytes * 100 = 25MB
|
||||
};
|
||||
|
||||
// Cache instances for each dimension
|
||||
final Map<int, LRUMap<String, Uint8List?>> _caches = {
|
||||
32: LRUMap<String, Uint8List?>(5000),
|
||||
256: LRUMap<String, Uint8List?>(2000),
|
||||
512: LRUMap<String, Uint8List?>(100),
|
||||
};
|
||||
|
||||
/// Gets a thumbnail for a file at the specified dimension
|
||||
Uint8List? getThumb(EnteFile file, int dimension) {
|
||||
return _getFromCache(file.cacheKey(), dimension);
|
||||
}
|
||||
|
||||
/// Gets a thumbnail by ID at the specified dimension
|
||||
Uint8List? getThumbByID(String id, int dimension) {
|
||||
return _getFromCache(id, dimension);
|
||||
}
|
||||
|
||||
/// Stores a thumbnail for a file at the specified dimension
|
||||
void putThumb(EnteFile file, Uint8List? imageData, int dimension) {
|
||||
_putInCache(file.cacheKey(), imageData, dimension);
|
||||
}
|
||||
|
||||
/// Stores a thumbnail by ID at the specified dimension
|
||||
void putThumbByID(String id, Uint8List? imageData, int dimension) {
|
||||
_putInCache(id, imageData, dimension);
|
||||
}
|
||||
|
||||
/// Checks if a thumbnail exists for a file at the specified dimension
|
||||
bool containsThumb(EnteFile file, int dimension) {
|
||||
return _isCached(file.cacheKey(), dimension);
|
||||
}
|
||||
|
||||
void clearCache(EnteFile file) {
|
||||
_caches.forEach((_, cache) {
|
||||
cache.remove(file.cacheKey());
|
||||
});
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
Uint8List? _getFromCache(String key, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
return _caches[dimension]?.get(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _putInCache(String key, Uint8List? imageData, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
_caches[dimension]?.put(key, imageData);
|
||||
} else {
|
||||
debugPrint("Unsupported dimension: $dimension");
|
||||
}
|
||||
}
|
||||
|
||||
bool _isCached(String key, int dimension) {
|
||||
if (_isValidDimension(dimension)) {
|
||||
return _caches[dimension]?.containsKey(key) ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isValidDimension(int dimension) {
|
||||
if (_caches.containsKey(dimension)) {
|
||||
return true;
|
||||
}
|
||||
debugPrint("Invalid dimension: $dimension");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import "package:equatable/equatable.dart";
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import "package:photos/image/in_memory_image_cache.dart";
|
||||
import "package:photos/utils/standalone/task_queue.dart";
|
||||
|
||||
final thumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 15,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200, // Limit the queue to 50 pending tasks
|
||||
);
|
||||
|
||||
final mediumThumbnailQueue = TaskQueue<String>(
|
||||
maxConcurrentTasks: 5,
|
||||
taskTimeout: const Duration(minutes: 1),
|
||||
maxQueueSize: 200, // Limit the queue to 50 pending tasks
|
||||
);
|
||||
|
||||
class LocalThumbnailProvider extends ImageProvider<LocalThumbnailProviderKey> {
|
||||
final LocalThumbnailProviderKey key;
|
||||
final int maxRetries;
|
||||
final Duration retryDelay;
|
||||
|
||||
LocalThumbnailProvider(
|
||||
this.key, {
|
||||
this.maxRetries = 300,
|
||||
this.retryDelay = const Duration(milliseconds: 5),
|
||||
});
|
||||
|
||||
@override
|
||||
Future<LocalThumbnailProviderKey> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) async {
|
||||
return SynchronousFuture<LocalThumbnailProviderKey>(key);
|
||||
}
|
||||
|
||||
static cancelRequest(LocalThumbnailProviderKey key) {
|
||||
thumbnailQueue.removeTask('${key.asset.id}-small');
|
||||
mediumThumbnailQueue.removeTask('${key.asset.id}-medium');
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
LocalThumbnailProviderKey key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription('id: ${key.asset.id} name: ${key.asset.title}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<ui.Codec> _codec(
|
||||
LocalThumbnailProviderKey key,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// First try to get from cache
|
||||
Uint8List? normalThumbBytes =
|
||||
enteImageCache.getThumbByID(key.asset.id, key.height);
|
||||
if (normalThumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
chunkEvents.close().ignore();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to load small thumbnail with retry logic
|
||||
final Uint8List? thumbBytes = await _loadWithRetry(
|
||||
key: key,
|
||||
size: ThumbnailSize(key.smallThumbWidth, key.smallThumbHeight),
|
||||
quality: 75,
|
||||
cacheKey: '${key.asset.id}-small',
|
||||
queue: thumbnailQueue,
|
||||
cacheWidth: key.smallThumbWidth,
|
||||
);
|
||||
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("$runtimeType smallThumb ${key.asset.title} failed");
|
||||
}
|
||||
|
||||
// Try to load normal thumbnail with retry logic if not already in cache
|
||||
if (normalThumbBytes == null) {
|
||||
normalThumbBytes = await _loadWithRetry(
|
||||
key: key,
|
||||
size: ThumbnailSize(key.width, key.height),
|
||||
quality: 50,
|
||||
cacheKey: '${key.asset.id}-medium',
|
||||
queue: mediumThumbnailQueue,
|
||||
cacheWidth: key.height,
|
||||
);
|
||||
|
||||
if (normalThumbBytes == null) {
|
||||
throw StateError("$runtimeType biThumb ${key.asset.title} failed");
|
||||
}
|
||||
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
}
|
||||
|
||||
chunkEvents.close().ignore();
|
||||
}
|
||||
|
||||
Future<Uint8List?> _loadWithRetry({
|
||||
required LocalThumbnailProviderKey key,
|
||||
required ThumbnailSize size,
|
||||
required int quality,
|
||||
required String cacheKey,
|
||||
required TaskQueue<String> queue,
|
||||
required int cacheWidth,
|
||||
}) async {
|
||||
int attempt = 0;
|
||||
Uint8List? result;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
try {
|
||||
// Check cache first on retry attempts
|
||||
if (attempt > 0) {
|
||||
result = enteImageCache.getThumbByID(key.asset.id, cacheWidth);
|
||||
if (result != null) return result;
|
||||
}
|
||||
|
||||
final Completer<Uint8List?> future = Completer();
|
||||
await queue.addTask(cacheKey, () async {
|
||||
final bytes =
|
||||
await key.asset.thumbnailDataWithSize(size, quality: quality);
|
||||
enteImageCache.putThumbByID(key.asset.id, bytes, cacheWidth);
|
||||
future.complete(bytes);
|
||||
});
|
||||
result = await future.future;
|
||||
return result;
|
||||
} catch (e) {
|
||||
// Only retry on specific exceptions
|
||||
if (e is! TaskQueueOverflowException &&
|
||||
e is! TaskQueueTimeoutException &&
|
||||
e is! TaskQueueCancelledException) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
if (attempt <= maxRetries) {
|
||||
await Future.delayed(retryDelay * attempt); // Exponential backoff
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class LocalThumbnailProviderKey extends Equatable {
|
||||
final AssetEntity asset;
|
||||
final int height;
|
||||
final int width;
|
||||
final int smallThumbHeight;
|
||||
final int smallThumbWidth;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
asset.id,
|
||||
asset.modifiedDateSecond ?? 0,
|
||||
height,
|
||||
width,
|
||||
smallThumbHeight,
|
||||
smallThumbWidth,
|
||||
];
|
||||
|
||||
const LocalThumbnailProviderKey({
|
||||
required this.asset,
|
||||
this.height = 256,
|
||||
this.width = 256,
|
||||
this.smallThumbWidth = 32,
|
||||
this.smallThumbHeight = 32,
|
||||
});
|
||||
}
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "الحذف من كليهما",
|
||||
"newAlbum": "ألبوم جديد",
|
||||
"albums": "الألبومات",
|
||||
"memoryCount": "{count, plural, =0 {لا توجد ذكريات} one {ذكرى واحدة} two {ذكريتان} other {{formattedCount} ذكرى}}",
|
||||
"memoryCount": "{count, plural, =0 {لا توجد ذكريات} one {ذكرى واحدة} two {ذكريتان} few {{formattedCount} ذكريات} many {{formattedCount} ذكرى} other {{formattedCount} ذكرى}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -460,7 +460,7 @@
|
||||
"skip": "تخط",
|
||||
"updatingFolderSelection": "جارٍ تحديث تحديد المجلد...",
|
||||
"itemCount": "{count, plural, one {{count} عُنْصُر} other {{count} عَنَاصِر}}",
|
||||
"deleteItemCount": "{count, plural, =1 {حذف عنصر واحد} two {حذف عنصرين} other {حذف {count} عنصرًا}}",
|
||||
"deleteItemCount": "{count, plural, =1 {حذف عنصر واحد} two {حذف عنصرين} few {حذف {count} عناصر} many {حذف {count} عنصرًا} other {حذف {count} عنصرًا}}",
|
||||
"duplicateItemsGroup": "{count} ملفات، {formattedSize} لكل منها",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "عرض الذكريات",
|
||||
"yearsAgo": "{count, plural, one {قبل سنة} two {قبل سنتين} other {قبل {count} سنة}}",
|
||||
"yearsAgo": "{count, plural, one {قبل سنة} two {قبل سنتين} few {قبل {count} سنوات} many {قبل {count} سنة} other {قبل {count} سنة}}",
|
||||
"backupSettings": "إعدادات النسخ الاحتياطي",
|
||||
"backupStatus": "حالة النسخ الاحتياطي",
|
||||
"backupStatusDescription": "ستظهر العناصر التي تم نسخها احتياطيًا هنا",
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "تذكر أيضًا إفراغ \"سلة المهملات\" لاستعادة المساحة المحررة.",
|
||||
"sparkleSuccess": "✨ نجاح",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} other {{count} ملفًا مكررًا}}، مما وفر {storageSaved}!",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} few {{count} ملفات مكررة} many {{count} ملفًا مكررًا} other {{count} ملفًا مكررًا}}، مما وفر {storageSaved}!",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "مشاركة",
|
||||
"unhideToAlbum": "إظهار في الألبوم",
|
||||
"restoreToAlbum": "استعادة إلى الألبوم",
|
||||
"moveItem": "{count, plural, =1 {نقل عنصر} two {نقل عنصرين} other {نقل {count} عنصرًا}}",
|
||||
"moveItem": "{count, plural, =1 {نقل عنصر} two {نقل عنصرين} few {نقل {count} عناصر} many {نقل {count} عنصرًا} other {نقل {count} عنصرًا}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} other {إضافة {count} عنصرًا}}",
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} few {إضافة {count} عناصر} many {إضافة {count} عنصرًا} other {إضافة {count} عنصرًا}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -826,7 +826,7 @@
|
||||
"referFriendsAnd2xYourPlan": "أحِل الأصدقاء وضاعف خطتك مرتين",
|
||||
"shareAlbumHint": "افتح ألبومًا وانقر على زر المشاركة في الزاوية اليمنى العليا للمشاركة.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "تعرض العناصر عدد الأيام المتبقية قبل الحذف الدائم.",
|
||||
"trashDaysLeft": "{count, plural, =0 {قريبًا} =1 {يوم واحد} two {يومان} other {{count} يومًا}}",
|
||||
"trashDaysLeft": "{count, plural, =0 {قريبًا} =1 {يوم واحد} two {يومان} few {{count} أيام} many {{count} يومًا} other {{count} يومًا}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
@@ -899,8 +899,8 @@
|
||||
"authToViewYourMemories": "يرجى المصادقة لعرض ذكرياتك.",
|
||||
"unlock": "فتح",
|
||||
"freeUpSpace": "تحرير المساحة",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {يمكن حذفه من الجهاز لتحرير {formattedSize}} two {يمكن حذفهما من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} other {{formattedNumber} ملفًا}} في هذا الألبوم تم نسخه احتياطيًا بأمان",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {يمكن حذفه من الجهاز لتحرير {formattedSize}} two {يمكن حذفهما من الجهاز لتحرير {formattedSize}} few {يمكن حذفها من الجهاز لتحرير {formattedSize}} many {يمكن حذفها من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} في هذا الألبوم تم نسخه احتياطيًا بأمان",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -915,7 +915,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {ملف واحد} two {ملفان} other {{formattedNumber} ملفًا}} على هذا الجهاز تم نسخه احتياطيًا بأمان",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {ملف واحد} two {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} على هذا الجهاز تم نسخه احتياطيًا بأمان",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "الموقع",
|
||||
"searchHint5": "قريبًا: الوجوه والبحث السحري ✨",
|
||||
"addYourPhotosNow": "أضف صورك الآن",
|
||||
"searchResultCount": "{count, plural, other{{count} النتائج التي تم العثور عليها}}",
|
||||
"searchResultCount": "{count, plural, one{{count} النتائج التي تم العثور عليها} other{{count} النتائج التي تم العثور عليها}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1269,8 +1269,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "البحث عن الأشخاص بسرعة بالاسم",
|
||||
"addViewers": "{count, plural, =0 {إضافة مشاهد} =1 {إضافة مشاهد} two {إضافة مشاهدين} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} other {إضافة {count} متعاونًا}}",
|
||||
"addViewers": "{count, plural, =0 {إضافة مشاهد} =1 {إضافة مشاهد} two {إضافة مشاهدين} few {إضافة {count} مشاهدين} many {إضافة {count} مشاهدًا} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} few {إضافة {count} متعاونين} many {إضافة {count} متعاونًا} other {إضافة {count} متعاونًا}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "اضغط مطولاً على بريد إلكتروني للتحقق من التشفير من طرف إلى طرف.",
|
||||
"developerSettingsWarning": "هل أنت متأكد من رغبتك في تعديل إعدادات المطور؟",
|
||||
"developerSettings": "إعدادات المطور",
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "قم بتمكين تعلم الآلة للبحث السحري والتعرف على الوجوه.",
|
||||
"searchDiscoverEmptySection": "سيتم عرض الصور هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"searchPersonsEmptySection": "سيتم عرض الأشخاص هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} other {تمت إضافة {count} مشاهدًا}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} few {تمت إضافة {count} مشاهدين} many {تمت إضافة {count} مشاهدًا} other {تمت إضافة {count} مشاهدًا}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1413,7 +1413,7 @@
|
||||
},
|
||||
"description": "Number of viewers that were successfully added to an album."
|
||||
},
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 متعاونين} =1 {تمت إضافة متعاون واحد} two {تمت إضافة متعاونين} other {تمت إضافة {count} متعاونًا}}",
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 متعاونين} =1 {تمت إضافة متعاون واحد} two {تمت إضافة متعاونين} few {تمت إضافة {count} متعاونين} many {تمت إضافة {count} متعاونًا} other {تمت إضافة {count} متعاونًا}}",
|
||||
"@collaboratorsSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "قيد التشغيل حاليًا",
|
||||
"ignored": "تم التجاهل",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "نقل الصور المحددة إلى تاريخ واحد",
|
||||
"shiftDatesAndTime": "تغيير التواريخ والوقت",
|
||||
"photosKeepRelativeTimeDifference": "تحتفظ الصور بالفرق الزمني النسبي",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "سيتم إزالة العناصر المحددة من هذا الشخص، ولكن لن يتم حذفها من مكتبتك.",
|
||||
"throughTheYears": "{dateFormat} عبر السنين",
|
||||
"thisWeekThroughTheYears": "هذا الأسبوع عبر السنين",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} few {هذا الأسبوع، قبل {count} سنوات} many {هذا الأسبوع، قبل {count} سنة} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"youAndThem": "أنت و {name}",
|
||||
"admiringThem": "الإعجاب بـ {name}",
|
||||
"embracingThem": "معانقة {name}",
|
||||
@@ -1821,4 +1821,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"faq": "Často kladené dotazy (FAQ)",
|
||||
"faq": "Často kladené dotazy",
|
||||
"help": "Nápověda",
|
||||
"oopsSomethingWentWrong": "Jejda, něco se pokazilo",
|
||||
"peopleUsingYourCode": "Lidé, kteří používají váš kód",
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Odstranit z obou",
|
||||
"newAlbum": "Nové album",
|
||||
"albums": "Alba",
|
||||
"memoryCount": "{count, plural, =0{žádné vzpomínky} one{{formattedCount} vzpomínka} other{{formattedCount} vzpomínek}}",
|
||||
"memoryCount": "{count, plural, =0{žádné vzpomínky} one{{formattedCount} vzpomínka} few{{formattedCount} vzpomínky} other{{formattedCount} vzpomínek}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -459,8 +459,8 @@
|
||||
"selectAll": "Vybrat vše",
|
||||
"skip": "Přeskočit",
|
||||
"updatingFolderSelection": "Aktualizuji výběr složek...",
|
||||
"itemCount": "{count, plural, one{{count} položka} other{{count} položek}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Smazat {count} položku} other {Smazat {count} položek}}",
|
||||
"itemCount": "{count, plural, one{{count} položka} few{{count} položky} other{{count} položek}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Smazat {count} položku} few{Smazat {count} položky} other {Smazat {count} položek}}",
|
||||
"duplicateItemsGroup": "{count} souborů, {formattedSize} každý",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -498,7 +498,7 @@
|
||||
"authToChangeYourEmail": "Pro změnu e-mailové adresy se prosím ověřte",
|
||||
"changePassword": "Změnit heslo",
|
||||
"authToChangeYourPassword": "Pro změnu hesla se prosím ověřte",
|
||||
"emailVerificationToggle": "Ověření pomocí e-mailu",
|
||||
"emailVerificationToggle": "Ověření emailem",
|
||||
"authToChangeEmailVerificationSetting": "Pro změnu ověření pomocí emailu se musíte ověřit",
|
||||
"exportYourData": "Exportujte svá data",
|
||||
"logout": "Odhlásit se",
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "Vyprázdněte také \"Koš\", abyste získali uvolněné místo",
|
||||
"sparkleSuccess": "✨ Úspěch",
|
||||
"duplicateFileCountWithStorageSaved": "Vyčistili jste {count, plural, one{{count} duplicitní soubor} other{{count} duplicitních souborů}}, a ušetřili jste {storageSaved}!",
|
||||
"duplicateFileCountWithStorageSaved": "Vyčistili jste {count, plural, one{{count} duplicitní soubor} few{{count} duplicitní soubory} other{{count} duplicitních souborů}}, a ušetřili jste {storageSaved}!",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -594,7 +594,7 @@
|
||||
"theme": "Motiv",
|
||||
"lightTheme": "Světlý",
|
||||
"darkTheme": "Tmavý",
|
||||
"systemTheme": "Systém",
|
||||
"systemTheme": "Podle systému",
|
||||
"freeTrial": "Bezplatná zkušební verze",
|
||||
"selectYourPlan": "Vyberte svůj tarif",
|
||||
"enteSubscriptionPitch": "Ente uchovává vaše vzpomínky, takže jsou vám vždy k dispozici, i když ztratíte své zařízení.",
|
||||
@@ -826,7 +826,7 @@
|
||||
"referFriendsAnd2xYourPlan": "Doporučte přátele a zdvojnásobte svůj tarif",
|
||||
"shareAlbumHint": "Otevřete album a klepněte na tlačítko sdílení v pravém horním rohu, abyste jej sdíleli.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "Položky zobrazují počet dní zbývajících do trvalého smazání",
|
||||
"trashDaysLeft": "{count, plural, =0{Brzy} =1{1 den} other{{count} dní}}",
|
||||
"trashDaysLeft": "{count, plural, =0{Brzy} =1{1 den} few{{count} dny} other{{count} dní}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
@@ -900,7 +900,7 @@
|
||||
"unlock": "Odemknout",
|
||||
"freeUpSpace": "Uvolnit místo",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Lze jej odstranit ze zařízení, aby se uvolnilo {formattedSize} místa} other {Lze je odstranit ze zařízení, aby se uvolnilo {formattedSize} místa}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 soubor v tomto albu byl bezpečně zálohován} other {{formattedNumber} souborů v tomto albu bylo bezpečně zálohováno}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 soubor v tomto albu byl bezpečně zálohován} few {{formattedNumber} soubory v tomto albu byly bezpečně zálohovány} other {{formattedNumber} souborů v tomto albu bylo bezpečně zálohováno}}",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -915,7 +915,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {1 soubor na tomto zařízení byl bezpečně zálohován} other {{formattedNumber} souborů na tomto zařízení bylo bezpečně zálohováno}}",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {1 soubor na tomto zařízení byl bezpečně zálohován} few {{formattedNumber} soubory na tomto zařízení byly bezpečně zálohovány} other {{formattedNumber} souborů na tomto zařízení bylo bezpečně zálohováno}}",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "Poloha",
|
||||
"searchHint5": "Již brzy: Kouzelné vyhledávání tváří ✨",
|
||||
"addYourPhotosNow": "Přidejte své fotografie nyní",
|
||||
"searchResultCount": "{count, plural, one{Nalezen {count} výsledek} other{Nalezeno {count} výsledků}}",
|
||||
"searchResultCount": "{count, plural, one{Nalezen {count} výsledek} few{Nalezeny {count} výsledky} other{Nalezeno {count} výsledků}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "Povolte strojové učení pro magické vyhledávání a rozpoznávání obličejů",
|
||||
"searchDiscoverEmptySection": "Obrázky se zde zobrazí po dokončení zpracování a synchronizace",
|
||||
"searchPersonsEmptySection": "Lidé se zde zobrazí po dokončení zpracování a synchronizace",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 pozorovatelů} =1 {Přidán 1 pozorovatel} other {Přidáno {count} pozorovatelů}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 pozorovatelů} =1 {Přidán 1 pozorovatel} few {Přidáni {count} pozorovatelé} other {Přidáno {count} pozorovatelů}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1413,7 +1413,7 @@
|
||||
},
|
||||
"description": "Number of viewers that were successfully added to an album."
|
||||
},
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 spolupracovníků} =1 {Přidán 1 spolupracovník} other {Přidáno {count} spolupracovníků}}",
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 spolupracovníků} =1 {Přidán 1 spolupracovník} few {Přidáni {count} spolupracovníci} other {Přidáno {count} spolupracovníků}}",
|
||||
"@collaboratorsSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "aktuálně běží",
|
||||
"ignored": "ignorováno",
|
||||
"photosCount": "{count, plural, =0 {0 fotografií} =1 {1 fotografie} other {{count} fotografií}}",
|
||||
"photosCount": "{count, plural, =0 {0 fotografií} =1 {1 fotografie} few {{count} fotografie} other {{count} fotografií}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "Přesunout vybrané fotografie do jednoho data",
|
||||
"shiftDatesAndTime": "Posunout datum a čas",
|
||||
"photosKeepRelativeTimeDifference": "Fotografie zachovávají relativní časový rozdíl",
|
||||
"photocountPhotos": "{count, plural, =0 {Žádné fotografie} =1 {1 fotografie} other {{count} fotografií}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Žádné fotografie} =1 {1 fotografie} few {{count} fotografie} other {{count} fotografií}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Vybrané položky budou z této osoby odebrány, ale nebudou smazány z vaší knihovny.",
|
||||
"throughTheYears": "{dateFormat} v průběhu let",
|
||||
"thisWeekThroughTheYears": "Tento týden v průběhu let",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Tento týden, {count} rok nazpět} other {Tento týden, {count} let nazpět}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Tento týden, {count} rok nazpět} few {Tento týden, {count} roky nazpět} other {Tento týden, {count} let nazpět}}",
|
||||
"youAndThem": "Vy a {name}",
|
||||
"admiringThem": "Obdiv k {name}",
|
||||
"embracingThem": "Objímání {name}",
|
||||
@@ -1827,98 +1827,5 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Zpracovaná videa",
|
||||
"totalVideos": "Celkový počet videí",
|
||||
"skippedVideos": "Přeskočená videa",
|
||||
"videoStreamingNote": "Na tomto zařízení se zpracovávají pouze videa z posledních 60 dnů, která jsou kratší než 1 minuta. U starších/delších videí povolte streamování v desktopové aplikaci.",
|
||||
"createStream": "Vytvořit stream",
|
||||
"recreateStream": "Obnovit stream",
|
||||
"addedToStreamCreationQueue": "Přidáno do fronty pro vytvoření streamu",
|
||||
"addedToStreamRecreationQueue": "Přidáno do fronty pro obnovení streamu",
|
||||
"videoPreviewAlreadyExists": "Náhled videa již existuje",
|
||||
"videoAlreadyInQueue": "Video soubor již je ve frontě",
|
||||
"addedToQueue": "Přidáno do fronty",
|
||||
"creatingStream": "Vytváření streamu",
|
||||
"similarImages": "Podobné obrázky",
|
||||
"findSimilarImages": "Najít podobné obrázky",
|
||||
"noSimilarImagesFound": "Nebyly nalezeny žádné podobné obrázky",
|
||||
"yourPhotosLookUnique": "Vaše fotografie vypadají jedinečně",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} skupina nalezena} other{{count} skupin nalezeno}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "Zkontrolujte a odstraňte podobné obrázky",
|
||||
"deletePhotosWithSize": "Smazat {count} fotek ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Možnosti výběru",
|
||||
"selectExactWithCount": "Úplně stejné ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Vybrat shodné",
|
||||
"selectSimilarWithCount": "Hodně podobné ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Vyberte podobné",
|
||||
"selectAllWithCount": "Všechny podobnosti ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Vyberte podobné obrázky",
|
||||
"chooseSimilarImagesToSelect": "Vyberte obrázky na základě jejich vizuální podobnosti",
|
||||
"clearSelection": "Vymazat výběr",
|
||||
"similarImagesCount": "{count} podobných obrázků",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Smazat ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Smazat soubory",
|
||||
"areYouSureDeleteFiles": "Opravdu chcete tyto soubory smazat?",
|
||||
"greatJob": "Dobrá práce!",
|
||||
"size": "Velikost",
|
||||
"similarity": "Podobnost",
|
||||
"processingLocally": "Místní zpracování",
|
||||
"useMLToFindSimilarImages": "Zkontrolujte a odstraňte obrázky, které se navzájem podobají.",
|
||||
"all": "Vše",
|
||||
"similar": "Podobné",
|
||||
"identical": "Identické",
|
||||
"nothingHereTryAnotherFilter": "Tady nic není, zkuste jiný filtr! 👀"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,16 @@
|
||||
"after1Month": "Efter 1 måned",
|
||||
"after1Year": "Efter 1 år",
|
||||
"manageParticipants": "Administrer",
|
||||
"albumParticipantsCount": "{count, plural, =0 {Ingen Deltagere} =1 {1 Deltager} other {{count} Deltagere}}",
|
||||
"@albumParticipantsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int",
|
||||
"example": "5"
|
||||
}
|
||||
},
|
||||
"description": "Number of participants in an album, including the album owner."
|
||||
},
|
||||
"collabLinkSectionDescription": "Opret et link, så folk kan tilføje og se fotos i dit delte album uden at behøve en Ente-app eller konto. Fantastisk til at indsamle event fotos.",
|
||||
"collectPhotos": "Indsaml billeder",
|
||||
"collaborativeLink": "Kollaborativt link",
|
||||
|
||||
@@ -1827,111 +1827,5 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Videos verarbeitet",
|
||||
"totalVideos": "Videos insgesamt",
|
||||
"skippedVideos": "Übersprungene Videos",
|
||||
"videoStreamingDescriptionLine1": "Videos sofort auf jedem Gerät abspielen.",
|
||||
"videoStreamingDescriptionLine2": "Aktivieren, um Video-Streams auf diesem Gerät zu verarbeiten.",
|
||||
"videoStreamingNote": "Nur Videos der letzten 60 Tage und unter einer Minute werden auf diesem Gerät verarbeitet. Für ältere/längere Videos aktiviere das Streaming in der Desktop-App.",
|
||||
"createStream": "Stream erzeugen",
|
||||
"recreateStream": "Stream neu erzeugen",
|
||||
"addedToStreamCreationQueue": "Zur Warteschlange für Streamerstellung hinzugefügt",
|
||||
"addedToStreamRecreationQueue": "Zur Warteschlange für Neuerstellung der Streams hinzugefügt",
|
||||
"videoPreviewAlreadyExists": "Videovorschau existiert bereits",
|
||||
"videoAlreadyInQueue": "Videodatei existiert bereits in der Warteschlange",
|
||||
"addedToQueue": "Zur Warteschlange hinzugefügt",
|
||||
"creatingStream": "Stream wird erzeugt",
|
||||
"similarImages": "Ähnliche Bilder",
|
||||
"findSimilarImages": "Ähnliche Bilder finden",
|
||||
"noSimilarImagesFound": "Keine ähnlichen Bilder gefunden",
|
||||
"yourPhotosLookUnique": "Deine Fotos sehen einzigartig aus",
|
||||
"similarGroupsFound": "{count, plural, =1{Eine Gruppe gefunden} other{{count} Gruppen gefunden}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "Überprüfe und lösche ähnliche Bilder",
|
||||
"deletePhotosWithSize": "Lösche {count} Fotos ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Auswahloptionen",
|
||||
"selectExactWithCount": "Exakt gleich ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Exakte auswählen",
|
||||
"selectSimilarWithCount": "Nahezu gleich ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Ähnliche auswählen",
|
||||
"selectAllWithCount": "Alle Ähnlichkeiten ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Ähnliche Bilder auswählen",
|
||||
"chooseSimilarImagesToSelect": "Wähle Bilder anhand ihrer visuellen Ähnlichkeit",
|
||||
"clearSelection": "Auswahl aufheben",
|
||||
"similarImagesCount": "{count} ähnliche Bilder",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Löschen ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Dateien löschen",
|
||||
"areYouSureDeleteFiles": "Bist du sicher, dass du diese Dateien löschen willst?",
|
||||
"greatJob": "Gut gemacht!",
|
||||
"cleanedUpSimilarImages": "Du hast {size} an Speicherplatz freigegeben",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Größe",
|
||||
"similarity": "Ähnlichkeit",
|
||||
"processingLocally": "Lokale Verarbeitung",
|
||||
"useMLToFindSimilarImages": "Überprüfe und entferne Bilder, die sich ähnlich sehen.",
|
||||
"all": "Alle",
|
||||
"similar": "Ähnlich",
|
||||
"identical": "Identisch",
|
||||
"nothingHereTryAnotherFilter": "Nichts zu sehen, probier einen anderen Filter! 👀",
|
||||
"related": "Verwandt",
|
||||
"hoorayyyy": "Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Hier gibt es nichts zu bereinigen"
|
||||
}
|
||||
}
|
||||
@@ -1831,8 +1831,7 @@
|
||||
"videosProcessed": "Videos processed",
|
||||
"totalVideos": "Total videos",
|
||||
"skippedVideos": "Skipped videos",
|
||||
"videoStreamingDescriptionLine1": "Play videos instantly on any device.",
|
||||
"videoStreamingDescriptionLine2": "Enable to process video streams on this device.",
|
||||
"videoStreamingDescription": "Play videos instantly on any device. Enable to process video streams on this device.",
|
||||
"videoStreamingNote": "Only videos from last 60 days and under 1 minute are processed on this device. For older/longer videos, enable streaming in the desktop app.",
|
||||
"createStream": "Create stream",
|
||||
"recreateStream": "Recreate stream",
|
||||
@@ -1843,6 +1842,14 @@
|
||||
"addedToQueue": "Added to queue",
|
||||
"creatingStream": "Creating stream",
|
||||
"similarImages": "Similar images",
|
||||
"deletingProgress": "Deleting... {progress}",
|
||||
"@deletingProgress": {
|
||||
"placeholders": {
|
||||
"progress": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"findSimilarImages": "Find similar images",
|
||||
"noSimilarImagesFound": "No similar images found",
|
||||
"yourPhotosLookUnique": "Your photos look unique",
|
||||
@@ -1859,7 +1866,7 @@
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
"type": "int"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
@@ -1915,9 +1922,12 @@
|
||||
"deleteFiles": "Delete files",
|
||||
"areYouSureDeleteFiles": "Are you sure you want to delete these files?",
|
||||
"greatJob": "Great job!",
|
||||
"cleanedUpSimilarImages": "You freed up {size} of space",
|
||||
"cleanedUpSimilarImages": "You cleaned up {count, plural, =1{{count} similar image} other{{count} similar images}} and freed up {size}",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
@@ -1925,19 +1935,11 @@
|
||||
},
|
||||
"size": "Size",
|
||||
"similarity": "Similarity",
|
||||
"analyzingPhotosLocally": "Analyzing your photos locally...",
|
||||
"lookingForVisualSimilarities": "Looking for visual similarities...",
|
||||
"comparingImageDetails": "Comparing image details...",
|
||||
"findingSimilarImages": "Finding similar images...",
|
||||
"almostDone": "Almost done...",
|
||||
"analyzingPhotosLocally": "Analyzing your photos locally",
|
||||
"lookingForVisualSimilarities": "Looking for visual similarities",
|
||||
"comparingImageDetails": "Comparing image details",
|
||||
"findingSimilarImages": "Finding similar images",
|
||||
"almostDone": "Almost done",
|
||||
"processingLocally": "Processing locally",
|
||||
"useMLToFindSimilarImages": "Review and remove images that look similar to each other.",
|
||||
"all": "All",
|
||||
"similar": "Similar",
|
||||
"identical": "Identical",
|
||||
"nothingHereTryAnotherFilter": "Nothing here, try another filter! 👀",
|
||||
"related": "Related",
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Nothing to tidy up here",
|
||||
"deletingDash": "Deleting - "
|
||||
"useMLToFindSimilarImages": "Use ML to find images that look similar to each other."
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
"addCollaborator": "Agregar colaborador",
|
||||
"addANewEmail": "Agregar nuevo correo electrónico",
|
||||
"orPickAnExistingOne": "O elige uno existente",
|
||||
"collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Colaboradores pueden añadir fotos y vídeos al álbum compartido.",
|
||||
"collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Colaboradores pueden añadir fotos y videos al álbum compartido.",
|
||||
"enterEmail": "Ingresar correo electrónico ",
|
||||
"albumOwner": "Propietario",
|
||||
"@albumOwner": {
|
||||
@@ -270,7 +270,7 @@
|
||||
"shareTextConfirmOthersVerificationID": "Hola, ¿puedes confirmar que esta es tu ID de verificación ente.io: {verificationID}?",
|
||||
"somethingWentWrong": "Algo salió mal",
|
||||
"sendInvite": "Enviar invitación",
|
||||
"shareTextRecommendUsingEnte": "Descarga Ente para que podamos compartir fácilmente fotos y vídeos en calidad original.\n\nhttps://ente.io",
|
||||
"shareTextRecommendUsingEnte": "Descarga Ente para que podamos compartir fácilmente fotos y videos en calidad original.\n\nhttps://ente.io",
|
||||
"done": "Hecho",
|
||||
"applyCodeTitle": "Usar código",
|
||||
"enterCodeDescription": "Introduce el código proporcionado por tu amigo para reclamar almacenamiento gratuito para ambos",
|
||||
@@ -857,7 +857,7 @@
|
||||
"deviceFilesAutoUploading": "Los archivos añadidos a este álbum de dispositivo se subirán automáticamente a Ente.",
|
||||
"turnOnBackupForAutoUpload": "Activar la copia de seguridad para subir automáticamente archivos añadidos a la carpeta de este dispositivo a Ente.",
|
||||
"noHiddenPhotosOrVideos": "No hay fotos ni vídeos ocultos",
|
||||
"toHideAPhotoOrVideo": "Para ocultar una foto o vídeo",
|
||||
"toHideAPhotoOrVideo": "Para ocultar una foto o video",
|
||||
"openTheItem": "• Abrir el elemento",
|
||||
"clickOnTheOverflowMenu": "• Haga clic en el menú desbordante",
|
||||
"click": "• Clic",
|
||||
@@ -866,7 +866,7 @@
|
||||
"archiveAlbum": "Archivar álbum",
|
||||
"calculating": "Calculando...",
|
||||
"pleaseWaitDeletingAlbum": "Por favor espera. Borrando el álbum",
|
||||
"searchByExamples": "• Nombres de álbumes (por ejemplo, \"Cámara\")\n• Tipos de archivos (por ejemplo, \"Vídeos\", \".gif\")\n• Años y meses (por ejemplo, \"2022\", \"Enero\")\n• Vacaciones (por ejemplo, \"Navidad\")\n• Descripciones fotográficas (por ejemplo, \"#diversión\")",
|
||||
"searchByExamples": "• Nombres de álbumes (por ejemplo, \"Cámara\")\n• Tipos de archivos (por ejemplo, \"Videos\", \".gif\")\n• Años y meses (por ejemplo, \"2022\", \"Enero\")\n• Vacaciones (por ejemplo, \"Navidad\")\n• Descripciones fotográficas (por ejemplo, \"#diversión\")",
|
||||
"youCanTrySearchingForADifferentQuery": "Puedes intentar buscar una consulta diferente.",
|
||||
"noResultsFound": "No se han encontrado resultados",
|
||||
"addedBy": "Añadido por {emailOrName}",
|
||||
@@ -884,8 +884,8 @@
|
||||
"filesSavedToGallery": "Archivo guardado en la galería",
|
||||
"fileFailedToSaveToGallery": "No se pudo guardar el archivo en la galería",
|
||||
"download": "Descargar",
|
||||
"pressAndHoldToPlayVideo": "Presiona y mantén presionado para reproducir el vídeo",
|
||||
"pressAndHoldToPlayVideoDetailed": "Mantén pulsada la imagen para reproducir el vídeo",
|
||||
"pressAndHoldToPlayVideo": "Presiona y mantén presionado para reproducir el video",
|
||||
"pressAndHoldToPlayVideoDetailed": "Mantén pulsada la imagen para reproducir el video",
|
||||
"downloadFailed": "Descarga fallida",
|
||||
"deduplicateFiles": "Deduplicar archivos",
|
||||
"deselectAll": "Deseleccionar todo",
|
||||
@@ -895,7 +895,7 @@
|
||||
"count": "Cuenta",
|
||||
"totalSize": "Tamaño total",
|
||||
"longpressOnAnItemToViewInFullscreen": "Manten presionado un elemento para ver en pantalla completa",
|
||||
"decryptingVideo": "Descifrando vídeo...",
|
||||
"decryptingVideo": "Descifrando video...",
|
||||
"authToViewYourMemories": "Por favor, autentícate para ver tus recuerdos",
|
||||
"unlock": "Desbloquear",
|
||||
"freeUpSpace": "Liberar espacio",
|
||||
@@ -1014,7 +1014,7 @@
|
||||
"cachedData": "Datos almacenados en caché",
|
||||
"clearCaches": "Limpiar cachés",
|
||||
"remoteImages": "Imágenes remotas",
|
||||
"remoteVideos": "Vídeos remotos",
|
||||
"remoteVideos": "Videos remotos",
|
||||
"remoteThumbnails": "Miniaturas remotas",
|
||||
"pendingSync": "Sincronización pendiente",
|
||||
"localGallery": "Galería local",
|
||||
@@ -1169,7 +1169,7 @@
|
||||
"description": "Label for the map view"
|
||||
},
|
||||
"maps": "Mapas",
|
||||
"enableMaps": "Habilitar mapas",
|
||||
"enableMaps": "Activar Mapas",
|
||||
"enableMapsDesc": "Esto mostrará tus fotos en el mapa mundial.\n\nEste mapa está gestionado por Open Street Map, y la ubicación exacta de tus fotos nunca se comparte.\n\nPuedes deshabilitar esta función en cualquier momento en Ajustes.",
|
||||
"quickLinks": "Acceso rápido",
|
||||
"selectItemsToAdd": "Selecciona elementos para agregar",
|
||||
@@ -1346,7 +1346,7 @@
|
||||
"noSystemLockFound": "Bloqueo de sistema no encontrado",
|
||||
"tapToUnlock": "Toca para desbloquear",
|
||||
"tooManyIncorrectAttempts": "Demasiados intentos incorrectos",
|
||||
"videoInfo": "Información de vídeo",
|
||||
"videoInfo": "Información de video",
|
||||
"autoLock": "Bloqueo automático",
|
||||
"immediately": "Inmediatamente",
|
||||
"autoLockFeatureDescription": "Tiempo después de que la aplicación esté en segundo plano",
|
||||
@@ -1433,7 +1433,7 @@
|
||||
"description": "In session page, warn user (in toast) that active sessions could not be fetched."
|
||||
},
|
||||
"failedToRefreshStripeSubscription": "Error al actualizar la suscripción",
|
||||
"failedToPlayVideo": "Error al reproducir el vídeo",
|
||||
"failedToPlayVideo": "Error al reproducir el video",
|
||||
"uploadIsIgnoredDueToIgnorereason": "La subida se ignoró debido a {ignoreReason}",
|
||||
"@uploadIsIgnoredDueToIgnorereason": {
|
||||
"placeholders": {
|
||||
@@ -1588,7 +1588,7 @@
|
||||
},
|
||||
"legacyInvite": "{email} te ha invitado a ser un contacto de confianza",
|
||||
"authToManageLegacy": "Por favor, autentícate para administrar tus contactos de confianza",
|
||||
"useDifferentPlayerInfo": "¿Tienes problemas para reproducir este vídeo? Mantén pulsado aquí para probar un reproductor diferente.",
|
||||
"useDifferentPlayerInfo": "¿Tienes problemas para reproducir este video? Mantén pulsado aquí para probar un reproductor diferente.",
|
||||
"hideSharedItemsFromHomeGallery": "Ocultar elementos compartidos de la galería de inicio",
|
||||
"gallery": "Galería",
|
||||
"joinAlbum": "Unir álbum",
|
||||
@@ -1662,7 +1662,7 @@
|
||||
"@linkPersonCaption": {
|
||||
"description": "Caption for the 'Link person' title. It should be a continuation of the 'Link person' title. Just like how 'Link person' + 'for better sharing experience' forms a proper sentence in English, the combination of these two strings should also be a proper sentence in other languages."
|
||||
},
|
||||
"videoStreaming": "Vídeos en transmisión",
|
||||
"videoStreaming": "Vídeos en streaming",
|
||||
"processingVideos": "Procesando vídeos",
|
||||
"streamDetails": "Detalles de la transmisión",
|
||||
"processing": "Procesando",
|
||||
@@ -1819,7 +1819,7 @@
|
||||
"font": "Fuente",
|
||||
"background": "Fondo",
|
||||
"align": "Alinear",
|
||||
"addedToAlbums": "{count, plural, =1{Añadido con éxito a 1 álbum} other{Añadido con éxito a {count} álbumes}}",
|
||||
"addedToAlbums": "{count, plural, one {}=1{Añadido con éxito a 1 álbum} other{Añadido con éxito a {count} álbumes}}",
|
||||
"@addedToAlbums": {
|
||||
"description": "Message shown when items are added to albums",
|
||||
"placeholders": {
|
||||
@@ -1827,111 +1827,5 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Vídeos procesados",
|
||||
"totalVideos": "Vídeos totales",
|
||||
"skippedVideos": "Vídeos omitidos",
|
||||
"videoStreamingDescriptionLine1": "Reproduce vídeos al instante en cualquier dispositivo.",
|
||||
"videoStreamingDescriptionLine2": "Habilitar para procesar transmisiones de vídeo en este dispositivo.",
|
||||
"videoStreamingNote": "Solo los vídeos de los últimos 60 días y menos de 1 minuto se procesan en este dispositivo. Para vídeos más viejos/más largos, habilita la transmisión en la aplicación de escritorio.",
|
||||
"createStream": "Crear transmisión",
|
||||
"recreateStream": "Recrear transmisión",
|
||||
"addedToStreamCreationQueue": "Añadido a la cola de creación de transmisiones",
|
||||
"addedToStreamRecreationQueue": "Añadido a la cola de grabación de transmisiones",
|
||||
"videoPreviewAlreadyExists": "La vista previa de vídeo ya existe",
|
||||
"videoAlreadyInQueue": "El archivo de vídeo ya está en la cola",
|
||||
"addedToQueue": "Añadido a la cola",
|
||||
"creatingStream": "Creando transmisión",
|
||||
"similarImages": "Imágenes similares",
|
||||
"findSimilarImages": "Buscar imágenes similares",
|
||||
"noSimilarImagesFound": "No se encontraron imágenes similares",
|
||||
"yourPhotosLookUnique": "Tus fotos se ven únicas",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} grupo encontrado} other{{count} grupos encontrados}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "Revisar y eliminar imágenes similares",
|
||||
"deletePhotosWithSize": "Eliminar {count} fotos ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Opciones de selección",
|
||||
"selectExactWithCount": "Exactamente similar ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Seleccionar exactos",
|
||||
"selectSimilarWithCount": "Mayormente, similar ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Seleccionar similares",
|
||||
"selectAllWithCount": "Todas las similitudes ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Seleccionar imágenes similares",
|
||||
"chooseSimilarImagesToSelect": "Seleccionar imágenes basándose en su similitud visual",
|
||||
"clearSelection": "Borrar selección",
|
||||
"similarImagesCount": "{count} imágenes similares",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Eliminar ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Eliminar archivos",
|
||||
"areYouSureDeleteFiles": "¿Estás seguro que quieres eliminar estos archivos?",
|
||||
"greatJob": "¡Bien hecho!",
|
||||
"cleanedUpSimilarImages": "Has liberado {size} de espacio",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Tamaño",
|
||||
"similarity": "Similitud",
|
||||
"processingLocally": "Procesando localmente",
|
||||
"useMLToFindSimilarImages": "Revisar y eliminar imágenes que se parecen entre sí.",
|
||||
"all": "Todas",
|
||||
"similar": "Similares",
|
||||
"identical": "Idénticas",
|
||||
"nothingHereTryAnotherFilter": "Nada aquí, ¡prueba con otro filtro! 👀",
|
||||
"related": "Relacionado",
|
||||
"hoorayyyy": "¡Hurraaaa!",
|
||||
"nothingToTidyUpHere": "Nada que limpiar aquí"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Authentifiez-vous pour voir vos souvenirs",
|
||||
"unlock": "Déverrouiller",
|
||||
"freeUpSpace": "Libérer de l'espace",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Il peut être supprimé de l'appareil pour libérer {formattedSize}} other {Ils peuvent être supprimés de l'appareil pour libérer {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, one {}=1 {Il peut être supprimé de l'appareil pour libérer {formattedSize}} other {Ils peuvent être supprimés de l'appareil pour libérer {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 fichier dans cet album a été sauvegardé en toute sécurité} other {{formattedNumber} fichiers dans cet album ont été sauvegardés en toute sécurité}}",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -933,7 +933,7 @@
|
||||
"@freeUpSpaceSaving": {
|
||||
"description": "Text to tell user how much space they can free up by deleting items from the device"
|
||||
},
|
||||
"freeUpAccessPostDelete": "Vous pouvez toujours {count, plural, =1 {l'} other {les}} accéder sur Ente tant que vous avez un abonnement actif",
|
||||
"freeUpAccessPostDelete": "Vous pouvez toujours {count, plural, one {}=1 {l'} other {les}} accéder sur Ente tant que vous avez un abonnement actif",
|
||||
"@freeUpAccessPostDelete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1269,8 +1269,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "Trouver des personnes rapidement par leur nom",
|
||||
"addViewers": "{count, plural, =0 {Ajouter un spectateur} =1 {Ajouter une spectateur} other {Ajouter des spectateurs}}",
|
||||
"addCollaborators": "{count, plural, =0 {Ajouter un collaborateur} =1 {Ajouter un collaborateur} other {Ajouter des collaborateurs}}",
|
||||
"addViewers": "{count, plural, one {}=0 {Ajouter un spectateur} =1 {Ajouter une spectateur} other {Ajouter des spectateurs}}",
|
||||
"addCollaborators": "{count, plural, one {}=0 {Ajouter un collaborateur} =1 {Ajouter un collaborateur} other {Ajouter des collaborateurs}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Appuyez longuement sur un email pour vérifier le chiffrement de bout en bout.",
|
||||
"developerSettingsWarning": "Êtes-vous sûr de vouloir modifier les paramètres du développeur ?",
|
||||
"developerSettings": "Paramètres du développeur",
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "Activer l'apprentissage automatique pour la reconnaissance des visages et la recherche magique",
|
||||
"searchDiscoverEmptySection": "Les images seront affichées ici une fois le traitement terminé",
|
||||
"searchPersonsEmptySection": "Les personnes seront affichées ici une fois le traitement terminé",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {0 spectateur ajouté} =1 {Un spectateur ajouté} other {{count} spectateurs ajoutés}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {}=0 {0 spectateur ajouté} =1 {Un spectateur ajouté} other {{count} spectateurs ajoutés}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1819,7 +1819,7 @@
|
||||
"font": "Police",
|
||||
"background": "Arrière-plan",
|
||||
"align": "Aligner",
|
||||
"addedToAlbums": "{count, plural, =1{Ajouté avec succès à 1 album} other{Ajouté avec succès à {count} albums}}",
|
||||
"addedToAlbums": "{count, plural, one {}=1{Ajouté avec succès à 1 album} other{Ajouté avec succès à {count} albums}}",
|
||||
"@addedToAlbums": {
|
||||
"description": "Message shown when items are added to albums",
|
||||
"placeholders": {
|
||||
@@ -1827,103 +1827,5 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Vidéos traitées",
|
||||
"totalVideos": "Total de vidéos",
|
||||
"skippedVideos": "Vidéos ignorées",
|
||||
"videoStreamingDescriptionLine1": "Lire instantanément des vidéos sur n'importe quel appareil.",
|
||||
"videoStreamingDescriptionLine2": "Activer pour traiter les flux vidéo sur cet appareil.",
|
||||
"videoStreamingNote": "Seules les vidéos des 60 derniers jours et de moins d'une minute sont traitées sur cet appareil. Pour les vidéos plus anciennes/plus longues, activez le streaming dans l'application pour ordinateur.",
|
||||
"createStream": "Créer le flux",
|
||||
"recreateStream": "Recréer le flux",
|
||||
"addedToStreamCreationQueue": "Ajouté à la file d'attente de création de flux",
|
||||
"addedToStreamRecreationQueue": "Ajouté à la file d'attente de re-création de flux",
|
||||
"videoPreviewAlreadyExists": "L'aperçu vidéo existe déjà",
|
||||
"videoAlreadyInQueue": "Fichier vidéo déjà présent dans la file d'attente",
|
||||
"addedToQueue": "Ajouté à la file d'attente",
|
||||
"creatingStream": "Création du flux",
|
||||
"similarImages": "Images similaires",
|
||||
"findSimilarImages": "Rechercher des images similaires",
|
||||
"noSimilarImagesFound": "Aucune image similaire trouvée",
|
||||
"yourPhotosLookUnique": "Vos photos semblent uniques",
|
||||
"reviewAndRemoveSimilarImages": "Examiner et supprimer les images similaires",
|
||||
"deletePhotosWithSize": "Supprimer {count} photos ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Options de sélection",
|
||||
"selectExactWithCount": "Exactement similaire ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Sélectionner exactement",
|
||||
"selectSimilarWithCount": "Plutôt similaire ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Sélectionner à l'identique",
|
||||
"selectAllWithCount": "Toutes les similarités ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Sélectionner les images similaires",
|
||||
"chooseSimilarImagesToSelect": "Sélectionnez des images en fonction de leur similitude visuelle",
|
||||
"clearSelection": "Effacer la sélection",
|
||||
"similarImagesCount": "{count} images similaires",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Supprimer ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Supprimer les fichiers",
|
||||
"areYouSureDeleteFiles": "Êtes-vous sûr de vouloir supprimer ces fichiers ?",
|
||||
"greatJob": "Excellent !",
|
||||
"cleanedUpSimilarImages": "Vous avez libéré {size} d'espace",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Taille",
|
||||
"similarity": "Similitude",
|
||||
"processingLocally": "Traitement local",
|
||||
"useMLToFindSimilarImages": "Examinez et supprimez les images qui se ressemblent entre elles.",
|
||||
"all": "Toutes",
|
||||
"similar": "Similaires",
|
||||
"identical": "Identiques",
|
||||
"nothingHereTryAnotherFilter": "Rien ici, essayez un autre filtre ! 👀",
|
||||
"related": "Liés",
|
||||
"hoorayyyy": "Houraaa !",
|
||||
"nothingToTidyUpHere": "Rien à nettoyer ici"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,8 +389,8 @@
|
||||
"selectAll": "בחר הכל",
|
||||
"skip": "דלג",
|
||||
"updatingFolderSelection": "מעדכן את בחירת התיקיות...",
|
||||
"itemCount": "{count, plural, one{{count} פריט} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {count} פריט} other {מחק {count} פריטים}}",
|
||||
"itemCount": "{count, plural, one{{count} פריט} two {{count} פריטים} many {{count} פריטים} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {count} פריט} two {מחק {count} פריטים} other {מחק {count} פריטים}}",
|
||||
"duplicateItemsGroup": "{count} קבצים, כל אחד {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -407,7 +407,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "הצג זכרונות",
|
||||
"yearsAgo": "{count, plural, one{לפני {count} שנה} other{לפני {count} שנים}}",
|
||||
"yearsAgo": "{count, plural, one{לפני {count} שנה} two {לפני {count} שנים} many {לפני {count} שנים} other{לפני {count} שנים}}",
|
||||
"backupSettings": "הגדרות גיבוי",
|
||||
"backupOverMobileData": "גבה על רשת סלולרית",
|
||||
"backupVideos": "גבה סרטונים",
|
||||
@@ -792,4 +792,4 @@
|
||||
"create": "צור",
|
||||
"viewAll": "הצג הכל",
|
||||
"hiding": "מחביא..."
|
||||
}
|
||||
}
|
||||
@@ -458,7 +458,7 @@
|
||||
"selectAll": "Összes kijelölése",
|
||||
"skip": "Kihagyás",
|
||||
"updatingFolderSelection": "Mappakijelölés frissítése...",
|
||||
"itemCount": "{count, plural, other{{count} elem}}",
|
||||
"itemCount": "{count, plural, one{{count} elem} other{{count} elem}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Elem {count} törlése} other {Elemek {count} törlése}}",
|
||||
"duplicateItemsGroup": "{count} fájl, {formattedSize} mindegyik",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -541,4 +541,4 @@
|
||||
}
|
||||
},
|
||||
"remindToEmptyEnteTrash": "Ürítsd ki a \"Kukát\" is, hogy visszaszerezd a felszabadult helyet."
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,6 @@
|
||||
"allowAddPhotosDescription": "Izinkan orang yang memiliki link untuk menambahkan foto ke album berbagi ini.",
|
||||
"passwordLock": "Kunci dengan sandi",
|
||||
"canNotOpenTitle": "Tidak dapat membuka album ini",
|
||||
"canNotOpenBody": "Maaf, album ini tidak dapat dibuka di aplikasi.",
|
||||
"disableDownloadWarningTitle": "Perlu diketahui",
|
||||
"disableDownloadWarningBody": "Orang yang melihat masih bisa mengambil tangkapan layar atau menyalin foto kamu menggunakan alat eksternal",
|
||||
"allowDownloads": "Izinkan pengunduhan",
|
||||
@@ -356,7 +355,6 @@
|
||||
"failedToLoadAlbums": "Gagal memuat album",
|
||||
"hidden": "Tersembunyi",
|
||||
"authToViewYourHiddenFiles": "Harap autentikasi untuk melihat file tersembunyi kamu",
|
||||
"authToViewTrashedFiles": "Silakan autentikasi untuk melihat file anda yang ada di tong sampah",
|
||||
"trash": "Sampah",
|
||||
"uncategorized": "Tak Berkategori",
|
||||
"videoSmallCase": "video",
|
||||
@@ -372,21 +370,6 @@
|
||||
"deleteFromBoth": "Hapus dari keduanya",
|
||||
"newAlbum": "Album baru",
|
||||
"albums": "Album",
|
||||
"memoryCount": "{count, plural, =0{tidak ada memori} other{{formattedCount} memori}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "1",
|
||||
"type": "int"
|
||||
},
|
||||
"formattedCount": {
|
||||
"type": "String",
|
||||
"example": "11.513, 11,511"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectedPhotos": "{count} terpilih",
|
||||
"@selectedPhotos": {
|
||||
"description": "Display the number of selected photos",
|
||||
@@ -436,7 +419,6 @@
|
||||
"discover_receipts": "Tanda Terima",
|
||||
"discover_notes": "Catatan",
|
||||
"discover_memes": "Meme",
|
||||
"discover_visiting_cards": "Kartu Nama",
|
||||
"discover_babies": "Bayi",
|
||||
"discover_pets": "Hewan",
|
||||
"discover_selfies": "Swafoto",
|
||||
@@ -445,7 +427,6 @@
|
||||
"discover_celebrations": "Perayaan",
|
||||
"discover_sunset": "Senja",
|
||||
"discover_hills": "Bukit",
|
||||
"discover_greenery": "Hijau-hijauan",
|
||||
"mlIndexingDescription": "Perlu diperhatikan bahwa pemelajaran mesin dapat meningkatkan penggunaan data dan baterai perangkat hingga seluruh item selesai terindeks. Gunakan aplikasi desktop untuk pengindeksan lebih cepat, seluruh hasil akan tersinkronkan secara otomatis.",
|
||||
"loadingModel": "Mengunduh model...",
|
||||
"waitingForWifi": "Menunggu WiFi...",
|
||||
@@ -461,21 +442,6 @@
|
||||
"updatingFolderSelection": "Memperbaharui pilihan folder...",
|
||||
"itemCount": "{count, plural, other{{count} item}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Hapus {count} item} other {Hapus {count} item}}",
|
||||
"duplicateItemsGroup": "{count} berkas, masing-masing {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
"type": "text",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "12",
|
||||
"type": "int"
|
||||
},
|
||||
"formattedSize": {
|
||||
"example": "2.3 MB",
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"showMemories": "Lihat kenangan",
|
||||
"yearsAgo": "{count, plural, other{{count} tahun lalu}}",
|
||||
"backupSettings": "Pengaturan pencadangan",
|
||||
@@ -526,7 +492,6 @@
|
||||
"viewLargeFiles": "File berukuran besar",
|
||||
"viewLargeFilesDesc": "Tampilkan file yang paling besar mengonsumsi ruang penyimpanan.",
|
||||
"noDuplicates": "✨ Tak ada file duplikat",
|
||||
"youveNoDuplicateFilesThatCanBeCleared": "Anda tidak memiliki file duplikat yang dapat dihapus",
|
||||
"success": "Berhasil",
|
||||
"rateUs": "Beri kami nilai",
|
||||
"remindToEmptyDeviceTrash": "Kosongkan juga “Baru Dihapus” dari “Pengaturan” -> “Penyimpanan” untuk memperoleh ruang yang baru saja dibersihkan",
|
||||
@@ -693,7 +658,6 @@
|
||||
"rateTheApp": "Nilai app ini",
|
||||
"startBackup": "Mulai pencadangan",
|
||||
"noPhotosAreBeingBackedUpRightNow": "Tidak ada foto yang sedang dicadangkan sekarang",
|
||||
"preserveMore": "Amankan lebih banyak",
|
||||
"grantFullAccessPrompt": "Harap berikan akses ke semua foto di app Pengaturan",
|
||||
"allowPermTitle": "Izinkan akses ke foto",
|
||||
"allowPermBody": "Ijinkan akses ke foto Anda dari Pengaturan agar Ente dapat menampilkan dan mencadangkan pustaka Anda.",
|
||||
@@ -750,7 +714,6 @@
|
||||
"lastUpdated": "Terakhir diperbarui",
|
||||
"deleteEmptyAlbums": "Hapus album kosong",
|
||||
"deleteEmptyAlbumsWithQuestionMark": "Hapus album yang kosong?",
|
||||
"deleteAlbumsDialogBody": "Ini akan menghapus semua album kosong. Ini berguna ketika anda ingin mengurangi kekacauan di daftar album anda.",
|
||||
"deleteProgress": "Menghapus {currentlyDeleting} / {totalCount}",
|
||||
"genericProgress": "Memproses {currentlyProcessing} / {totalCount}",
|
||||
"@genericProgress": {
|
||||
@@ -768,7 +731,6 @@
|
||||
}
|
||||
},
|
||||
"permanentlyDelete": "Hapus secara permanen",
|
||||
"canOnlyCreateLinkForFilesOwnedByYou": "Hanya dapat membuat tautan untuk file yang dimiliki oleh anda",
|
||||
"publicLinkCreated": "Link publik dibuat",
|
||||
"youCanManageYourLinksInTheShareTab": "Kamu bisa atur link yang telah kamu buat di tab berbagi.",
|
||||
"linkCopiedToClipboard": "Link tersalin ke papan klip",
|
||||
@@ -778,30 +740,15 @@
|
||||
"type": "text"
|
||||
},
|
||||
"moveToAlbum": "Pindahkan ke album",
|
||||
"unhide": "Tampilkan",
|
||||
"unarchive": "Keluarkan dari arsip",
|
||||
"favorite": "Favorit",
|
||||
"removeFromFavorite": "Hapus dari favorit",
|
||||
"shareLink": "Bagikan link",
|
||||
"createCollage": "Buat kolase",
|
||||
"saveCollage": "Simpan kolase",
|
||||
"collageSaved": "Kolase tersimpan ke galeri",
|
||||
"collageLayout": "Tata letak",
|
||||
"addToEnte": "Tambah ke Ente",
|
||||
"addToAlbum": "Tambah ke album",
|
||||
"delete": "Hapus",
|
||||
"hide": "Sembunyikan",
|
||||
"share": "Bagikan",
|
||||
"unhideToAlbum": "Tampikan ke album",
|
||||
"restoreToAlbum": "Pulihkan ke album",
|
||||
"moveItem": "{count, plural, =1 {Pindahkan item} other {Pindahkan item}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {Tambahkan item} other {Tambahkan item}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
"createOrSelectAlbum": "Buat atau pilih album",
|
||||
"selectAlbum": "Pilih album",
|
||||
"searchByAlbumNameHint": "Nama album",
|
||||
@@ -809,33 +756,18 @@
|
||||
"enterAlbumName": "Masukkan nama album",
|
||||
"restoringFiles": "Memulihkan file...",
|
||||
"movingFilesToAlbum": "Memindahkan file ke album...",
|
||||
"unhidingFilesToAlbum": "Tampilkan berkas ke album",
|
||||
"canNotUploadToAlbumsOwnedByOthers": "Tidak dapat mengunggah album yang dimiliki oleh orang lain",
|
||||
"uploadingFilesToAlbum": "Mengunggah file ke album...",
|
||||
"addedSuccessfullyTo": "Berhasil ditambahkan ke {albumName}",
|
||||
"movedSuccessfullyTo": "Berhasil dipindahkan ke {albumName}",
|
||||
"thisAlbumAlreadyHDACollaborativeLink": "Link kolaborasi untuk album ini sudah terbuat",
|
||||
"collaborativeLinkCreatedFor": "Link kolaborasi terbuat untuk {albumName}",
|
||||
"askYourLovedOnesToShare": "Minta orang terkasih anda untuk berbagi",
|
||||
"invite": "Undang",
|
||||
"shareYourFirstAlbum": "Bagikan album pertamamu",
|
||||
"sharedWith": "Dibagikan dengan {emailIDs}",
|
||||
"sharedWithMe": "Dibagikan dengan saya",
|
||||
"sharedByMe": "Dibagikan oleh saya",
|
||||
"doubleYourStorage": "Gandakan kuota kamu",
|
||||
"referFriendsAnd2xYourPlan": "Ajak teman dan gandakan paket anda",
|
||||
"shareAlbumHint": "Buka album lalu ketuk tombol bagikan di sudut kanan atas untuk berbagi.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "Item menampilkan jumlah hari yang tersisa sebelum dihapus permanen",
|
||||
"trashDaysLeft": "{count, plural, =0 {Segera} =1 {1 hari} other {{count} hari}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "1|2|3",
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteAll": "Hapus Semua",
|
||||
"renameAlbum": "Ubah nama album",
|
||||
"convertToAlbum": "Ubah menjadi album",
|
||||
@@ -850,16 +782,13 @@
|
||||
"leaveSharedAlbum": "Tinggalkan album bersama?",
|
||||
"leaveAlbum": "Tinggalkan album",
|
||||
"photosAddedByYouWillBeRemovedFromTheAlbum": "Foto yang telah kamu tambahkan akan dihapus dari album ini",
|
||||
"youveNoFilesInThisAlbumThatCanBeDeleted": "Anda tidak memiliki file di album ini yang dapat dihapus",
|
||||
"youDontHaveAnyArchivedItems": "Kamu tidak memiliki item di arsip.",
|
||||
"ignoredFolderUploadReason": "Sejumlah file di album ini tidak terunggah karena telah dihapus sebelumnya dari Ente.",
|
||||
"resetIgnoredFiles": "Atur ulang file yang diabaikan",
|
||||
"deviceFilesAutoUploading": "File yang ditambahkan ke album perangkat ini akan diunggah ke Ente secara otomatis.",
|
||||
"turnOnBackupForAutoUpload": "Aktifkan pencadangan untuk mengunggah file yang ditambahkan ke folder ini ke Ente secara otomatis.",
|
||||
"noHiddenPhotosOrVideos": "Tidak ada foto atau video tersembunyi",
|
||||
"toHideAPhotoOrVideo": "Untuk menyembunyikan foto atau video",
|
||||
"openTheItem": "• Buka item-nya",
|
||||
"clickOnTheOverflowMenu": "• Klik pada menu overflow",
|
||||
"click": "• Click",
|
||||
"nothingToSeeHere": "Tidak ada apa-apa di sini! 👀",
|
||||
"unarchiveAlbum": "Keluarkan album dari arsip",
|
||||
@@ -867,7 +796,6 @@
|
||||
"calculating": "Menghitung...",
|
||||
"pleaseWaitDeletingAlbum": "Harap tunggu, sedang menghapus album",
|
||||
"searchByExamples": "• Nama album (cth. \"Kamera\")\n• Jenis file (cth. \"Video\", \".gif\")\n• Tahun atau bulan (cth. \"2022\", \"Januari\")\n• Musim liburan (cth. \"Natal\")\n• Keterangan foto (cth. “#seru”)",
|
||||
"youCanTrySearchingForADifferentQuery": "Anda dapat mencoba mencari dengan kata-kata yang berbeda",
|
||||
"noResultsFound": "Tidak ditemukan hasil",
|
||||
"addedBy": "Ditambahkan oleh {emailOrName}",
|
||||
"loadingExifData": "Memuat data EXIF...",
|
||||
@@ -876,7 +804,6 @@
|
||||
"thisImageHasNoExifData": "Gambar ini tidak memiliki data exif",
|
||||
"exif": "EXIF",
|
||||
"noResults": "Tidak ada hasil",
|
||||
"weDontSupportEditingPhotosAndAlbumsThatYouDont": "Kami belum mendukung pengeditan foto dan album yang bukan milik anda",
|
||||
"failedToFetchOriginalForEdit": "Gagal memuat file asli untuk mengedit",
|
||||
"close": "Tutup",
|
||||
"setAs": "Pasang sebagai",
|
||||
@@ -887,19 +814,12 @@
|
||||
"pressAndHoldToPlayVideo": "Tekan dan tahan untuk memutar video",
|
||||
"pressAndHoldToPlayVideoDetailed": "Tekan dan tahan gambar untuk memutar video",
|
||||
"downloadFailed": "Gagal mengunduh",
|
||||
"deduplicateFiles": "Hilangkan Duplikasi File",
|
||||
"deselectAll": "Batalkan semua pilihan",
|
||||
"reviewDeduplicateItems": "Silakan lihat dan hapus item yang merupakan duplikat.",
|
||||
"clubByCaptureTime": "Kelompokkan berdasarkan waktu pengambilan",
|
||||
"clubByFileName": "Kelompokkan berdasarkan nama berkas",
|
||||
"count": "Jumlah",
|
||||
"totalSize": "Ukuran total",
|
||||
"longpressOnAnItemToViewInFullscreen": "Tekan lama pada item untuk melihat dalam layar penuh",
|
||||
"decryptingVideo": "Mendekripsi video...",
|
||||
"authToViewYourMemories": "Harap autentikasi untuk melihat kenanganmu",
|
||||
"unlock": "Buka",
|
||||
"freeUpSpace": "Bersihkan ruang",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Itu dapat dihapus dari perangkat untuk mengosongkan {formattedSize}} other {Itu dapat dihapus dari perangkat untuk mengosongkan {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, other {{formattedNumber} file}} dalam album ini telah berhasil dicadangkan",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -930,24 +850,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@freeUpSpaceSaving": {
|
||||
"description": "Text to tell user how much space they can free up by deleting items from the device"
|
||||
},
|
||||
"freeUpAccessPostDelete": "anda masih dapat mengakses {count, plural, =1 {itu} other {mereka}} di Ente selama anda memiliki langganan aktif",
|
||||
"@freeUpAccessPostDelete": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"example": "1",
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"freeUpAmount": "Bersihkan {sizeInMBorGB}",
|
||||
"thisEmailIsAlreadyInUse": "Email ini telah digunakan",
|
||||
"incorrectCode": "Kode salah",
|
||||
"authenticationFailedPleaseTryAgain": "Autentikasi gagal, silakan coba lagi",
|
||||
"verificationFailedPleaseTryAgain": "Verifikasi gagal, silakan coba lagi",
|
||||
"authenticating": "Autentikasi...",
|
||||
"authenticationSuccessful": "Autentikasi berhasil!",
|
||||
"incorrectRecoveryKey": "Kunci pemulihan salah",
|
||||
"theRecoveryKeyYouEnteredIsIncorrect": "Kunci pemulihan yang kamu masukkan salah",
|
||||
@@ -958,35 +865,12 @@
|
||||
"sorryTheCodeYouveEnteredIsIncorrect": "Maaf, kode yang kamu masukkan salah",
|
||||
"yourVerificationCodeHasExpired": "Kode verifikasi kamu telah kedaluwarsa",
|
||||
"emailChangedTo": "Email diubah menjadi {newEmail}",
|
||||
"verifying": "Memverifikasi...",
|
||||
"disablingTwofactorAuthentication": "Menonaktifkan autentikasi dua langkah...",
|
||||
"allMemoriesPreserved": "Semua kenangan terpelihara",
|
||||
"loadingGallery": "Memuat galeri...",
|
||||
"syncing": "Menyinkronkan...",
|
||||
"encryptingBackup": "Mengenkripsi cadangan...",
|
||||
"syncStopped": "Sinkronisasi terhenti",
|
||||
"syncProgress": "{completed}/{total} memori tersimpan",
|
||||
"uploadingMultipleMemories": "Menyimpan {count} memori...",
|
||||
"@uploadingMultipleMemories": {
|
||||
"description": "Text to tell user how many memories are being preserved",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uploadingSingleMemory": "Menyimpan 1 memori...",
|
||||
"@syncProgress": {
|
||||
"description": "Text to tell user how many memories have been preserved",
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "String"
|
||||
},
|
||||
"total": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"archiving": "Mengarsipkan...",
|
||||
"unarchiving": "Mengeluarkan dari arsip...",
|
||||
"successfullyArchived": "Berhasil diarsipkan",
|
||||
@@ -1001,8 +885,6 @@
|
||||
"empty": "Kosongkan",
|
||||
"couldNotFreeUpSpace": "Tidak dapat membersihkan ruang",
|
||||
"permanentlyDeleteFromDevice": "Hapus dari perangkat secara permanen?",
|
||||
"someOfTheFilesYouAreTryingToDeleteAre": "Beberapa file yang anda coba hapus hanya tersedia di perangkat anda dan tidak dapat dipulihkan jika dihapus",
|
||||
"theyWillBeDeletedFromAllAlbums": "Mereka akan dihapus dari semua album.",
|
||||
"someItemsAreInBothEnteAndYourDevice": "Sejumlah item tersimpan di Ente serta di perangkat ini.",
|
||||
"selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Item terpilih akan dihapus dari semua album dan dipindahkan ke sampah.",
|
||||
"theseItemsWillBeDeletedFromYourDevice": "Item ini akan dihapus dari perangkat ini.",
|
||||
@@ -1012,17 +894,12 @@
|
||||
"networkHostLookUpErr": "Tidak dapat terhubung dengan Ente, harap periksa pengaturan jaringan kamu dan hubungi dukungan jika masalah berlanjut.",
|
||||
"networkConnectionRefusedErr": "Tidak dapat terhubung dengan Ente, silakan coba lagi setelah beberapa saat. Jika masalah berlanjut, harap hubungi dukungan.",
|
||||
"cachedData": "Data cache",
|
||||
"clearCaches": "Bersihkan cache",
|
||||
"remoteImages": "Gambar jarak jauh",
|
||||
"remoteVideos": "Video jarak jauh",
|
||||
"remoteThumbnails": "Thumbnail jarak jauh",
|
||||
"pendingSync": "Sinkronisasi tertunda",
|
||||
"localGallery": "Galeri lokal",
|
||||
"todaysLogs": "Log hari ini",
|
||||
"viewLogs": "Lihat log",
|
||||
"logsDialogBody": "Ini akan mengirimkan log untuk membantu kami memperbaiki masalah anda. Harap diperhatikan bahwa nama file akan disertakan untuk membantu melacak masalah pada file tertentu.",
|
||||
"preparingLogs": "Menyiapkan log...",
|
||||
"emailYourLogs": "Kirim log anda melalui email",
|
||||
"pleaseSendTheLogsTo": "Silakan kirim log-nya ke \n{toEmail}",
|
||||
"copyEmailAddress": "Salin alamat email",
|
||||
"exportLogs": "Ekspor log",
|
||||
@@ -1234,4 +1111,4 @@
|
||||
"left": "Kiri",
|
||||
"right": "Kanan",
|
||||
"whatsNew": "Hal yang baru"
|
||||
}
|
||||
}
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "Condividi",
|
||||
"unhideToAlbum": "Non nascondere l'album",
|
||||
"restoreToAlbum": "Ripristina l'album",
|
||||
"moveItem": "{count, plural, =1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"moveItem": "{count, plural, one {}=1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {Aggiungi elemento} other {Aggiungi elementi}}",
|
||||
"addItem": "{count, plural, one {}=1 {Aggiungi elemento} other {Aggiungi elementi}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Autenticati per visualizzare le tue foto",
|
||||
"unlock": "Sblocca",
|
||||
"freeUpSpace": "Libera spazio",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Può essere cancellato per liberare {formattedSize}} other {Possono essere cancellati per liberare {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, one {}=1 {Può essere cancellato per liberare {formattedSize}} other {Possono essere cancellati per liberare {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 file} other {{formattedNumber} file}} di quest'album sono stati salvati in modo sicuro",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -1260,8 +1260,8 @@
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "Trova rapidamente le persone per nome",
|
||||
"addViewers": "{count, plural, =0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, =0 {Aggiungi collaboratore} =1 {Aggiungi collaboratore} other {Aggiungi collaboratori}}",
|
||||
"addViewers": "{count, plural, one {}=0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, one {}=0 {Aggiungi collaboratore} =1 {Aggiungi collaboratore} other {Aggiungi collaboratori}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Premi a lungo un'email per verificare la crittografia end to end.",
|
||||
"developerSettingsWarning": "Sei sicuro di voler modificare le Impostazioni sviluppatore?",
|
||||
"developerSettings": "Impostazioni sviluppatore",
|
||||
@@ -1394,7 +1394,7 @@
|
||||
"enableMachineLearningBanner": "Abilita l'apprendimento automatico per la ricerca magica e il riconoscimento facciale",
|
||||
"searchDiscoverEmptySection": "Le immagini saranno mostrate qui una volta che l'elaborazione e la sincronizzazione saranno completate",
|
||||
"searchPersonsEmptySection": "Le persone saranno mostrate qui una volta che l'elaborazione e la sincronizzazione saranno completate",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Added 0 visualizzatori} =1 {Added 1 visualizzatore} other {Added {count} visualizzatori}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {}=0 {Added 0 visualizzatori} =1 {Added 1 visualizzatore} other {Added {count} visualizzatori}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1479,7 +1479,7 @@
|
||||
},
|
||||
"currentlyRunning": "attualmente in esecuzione",
|
||||
"ignored": "ignorato",
|
||||
"photosCount": "{count, plural, =0 {0 foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photosCount": "{count, plural, one {}=0 {0 foto} =1 {1 foto} other {{count} foto}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1677,7 +1677,7 @@
|
||||
"moveSelectedPhotosToOneDate": "Sposta foto selezionate in una data specifica",
|
||||
"shiftDatesAndTime": "Sposta date e orari",
|
||||
"photosKeepRelativeTimeDifference": "Le foto mantengono una differenza di tempo relativa",
|
||||
"photocountPhotos": "{count, plural, =0 {Nessuna foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photocountPhotos": "{count, plural, one {}=0 {Nessuna foto} =1 {1 foto} other {{count} foto}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1691,7 +1691,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Gli elementi selezionati verranno rimossi da questa persona, ma non eliminati dalla tua libreria.",
|
||||
"throughTheYears": "{dateFormat} negli anni",
|
||||
"thisWeekThroughTheYears": "Questa settimana negli anni",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Questa settimana, {count} anno fa} other {Questa settimana, {count} anni fa}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, one {}=1 {Questa settimana, {count} anno fa} other {Questa settimana, {count} anni fa}}",
|
||||
"youAndThem": "Tu e {name}",
|
||||
"admiringThem": "Ammirando {name}",
|
||||
"embracingThem": "Abbracciando {name}",
|
||||
@@ -1746,4 +1746,4 @@
|
||||
"receiveRemindersOnBirthdays": "Ricevi promemoria quando è il compleanno di qualcuno. Toccare la notifica ti porterà alle foto della persona che compie gli anni.",
|
||||
"happyBirthday": "Buon compleanno! 🥳",
|
||||
"birthdays": "Compleanni"
|
||||
}
|
||||
}
|
||||
@@ -461,7 +461,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "思い出を表示",
|
||||
"yearsAgo": "{count, plural, other{{count} 年前}}",
|
||||
"yearsAgo": "{count, plural, one{{count} 年前} other{{count} 年前}}",
|
||||
"backupSettings": "バックアップ設定",
|
||||
"backupStatus": "バックアップの状態",
|
||||
"backupStatusDescription": "バックアップされたアイテムがここに表示されます",
|
||||
@@ -527,7 +527,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "「ゴミ箱」も空にするとアカウントのストレージが解放されます",
|
||||
"sparkleSuccess": "成功✨",
|
||||
"duplicateFileCountWithStorageSaved": "お掃除しました {count, plural, other{{count} 個の重複ファイル}}, ({storageSaved}が開放されます!)",
|
||||
"duplicateFileCountWithStorageSaved": "お掃除しました {count, plural, one{{count} 個の重複ファイル} other{{count} 個の重複ファイル}}, ({storageSaved}が開放されます!)",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -1178,7 +1178,7 @@
|
||||
"searchHint4": "場所",
|
||||
"searchHint5": "近日公開: フェイスとマジック検索 ✨",
|
||||
"addYourPhotosNow": "写真を今すぐ追加する",
|
||||
"searchResultCount": "{count, plural, other{{count} 個の結果}}",
|
||||
"searchResultCount": "{count, plural, one{{count} 個の結果} other{{count} 個の結果}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1666,4 +1666,4 @@
|
||||
"onTheRoad": "再び道で",
|
||||
"food": "料理を楽しむ",
|
||||
"pets": "毛むくじゃらな仲間たち"
|
||||
}
|
||||
}
|
||||
@@ -794,7 +794,11 @@
|
||||
"share": "Bendrinti",
|
||||
"unhideToAlbum": "Rodyti į albumą",
|
||||
"restoreToAlbum": "Atkurti į albumą",
|
||||
"addItem": "{count, plural, =1 {Pridėti elementą} other {Pridėti elementų}}",
|
||||
"moveItem": "{count, plural, =1 {Perkelti elementą} other {Perkelti elementų}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {Pridėti elementą} other {Pridėti elementų}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -896,7 +900,7 @@
|
||||
"unlock": "Atrakinti",
|
||||
"freeUpSpace": "Atlaisvinti vietos",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Jį galima ištrinti iš įrenginio, kad atlaisvintų {formattedSize}} other {Jų galima ištrinti iš įrenginio, kad atlaisvintų {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {{formattedNumber} failas šiame albume saugiai sukurta atsarginė kopija} other {{formattedNumber} failų šiame albume saugiai sukurta atsarginė kopija}}.",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {{formattedNumber} failas šiame albume saugiai sukurta atsarginė kopija} few {{formattedNumber} failai šiame albume saugiai sukurtos atsarginės kopijos} many {{formattedNumber} failo šiame albume saugiai sukurtos atsargines kopijos} other {{formattedNumber} failų šiame albume saugiai sukurta atsarginė kopija}}.",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -911,7 +915,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {{formattedNumber} failas šiame įrenginyje saugiai sukurta atsarginė kopija} other {{formattedNumber} failų šiame įrenginyje saugiai sukurta atsarginių kopijų}}.",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {{formattedNumber} failas šiame įrenginyje saugiai sukurta atsarginė kopija} few {{formattedNumber} failai šiame įrenginyje saugiai sukurtos atsarginės kopijos} many {{formattedNumber} failo šiame įrenginyje saugiai sukurtos atsargines kopijos} other {{formattedNumber} failų šiame įrenginyje saugiai sukurta atsarginių kopijų}}.",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1399,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "Įjunkite mašininį mokymąsi magiškai paieškai ir veidų atpažinimui",
|
||||
"searchDiscoverEmptySection": "Vaizdai bus rodomi čia, kai bus užbaigtas apdorojimas ir sinchronizavimas.",
|
||||
"searchPersonsEmptySection": "Asmenys bus rodomi čia, kai bus užbaigtas apdorojimas ir sinchronizavimas.",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Įtraukta 0 žiūrėtojų} =1 {Įtrauktas 1 žiūrėtojas} other {Įtraukta {count} žiūrėtojų}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, one {Įtrauktas {count} žiūrėtojas} few {Įtraukti {count} žiūrėtojai} many {Įtraukta {count} žiūrėtojo} =0 {Įtraukta 0 žiūrėtojų} =1 {Įtrauktas 1 žiūrėtojas} other {Įtraukta {count} žiūrėtojų}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1484,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "šiuo metu vykdoma",
|
||||
"ignored": "ignoruota",
|
||||
"photosCount": "{count, plural, =0 {0 nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"photosCount": "{count, plural, one {{count} nuotrauka} few {{count} nuotraukos} many {{count} nuotraukos} =0 {0 nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1682,11 +1686,21 @@
|
||||
"moveSelectedPhotosToOneDate": "Perkelti pasirinktas nuotraukas į vieną datą",
|
||||
"shiftDatesAndTime": "Pastumti datas ir laiką",
|
||||
"photosKeepRelativeTimeDifference": "Nuotraukos išlaiko santykinį laiko skirtumą",
|
||||
"photocountPhotos": "{count, plural, =0 {Nėra nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int",
|
||||
"example": "2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appIcon": "Programos piktograma",
|
||||
"notThisPerson": "Ne šis asmuo?",
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Pasirinkti elementai bus pašalinti iš šio asmens, bet nebus ištrinti iš jūsų bibliotekos.",
|
||||
"throughTheYears": "{dateFormat} per metus",
|
||||
"thisWeekThroughTheYears": "Ši savaitė per metus",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Šią savaitę, prieš {count} metus} other {Šią savaitę, prieš {count} metų}}",
|
||||
"youAndThem": "Jūs ir {name}",
|
||||
"admiringThem": "Žavisi {name}",
|
||||
"embracingThem": "Apkabinat {name}",
|
||||
@@ -1804,16 +1818,5 @@
|
||||
"brushColor": "Teptuko spalva",
|
||||
"font": "Šriftas",
|
||||
"background": "Fonas",
|
||||
"align": "Lygiuoti",
|
||||
"similarImages": "Panašūs vaizdai",
|
||||
"findSimilarImages": "Rasti panašų vaizdų",
|
||||
"noSimilarImagesFound": "Panašių vaizdų nerasta",
|
||||
"yourPhotosLookUnique": "Jūsų nuotraukos atrodo ypatingos",
|
||||
"selectionOptions": "Pasirinkimo parinktys",
|
||||
"deleteFiles": "Ištrinti failus",
|
||||
"areYouSureDeleteFiles": "Ar tikrai norite ištrinti šiuos failus?",
|
||||
"greatJob": "Puiku!",
|
||||
"size": "Dydis",
|
||||
"similarity": "Panašumas",
|
||||
"processingLocally": "Apdorojama vietoje"
|
||||
}
|
||||
"align": "Lygiuoti"
|
||||
}
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Toon herinneringen",
|
||||
"yearsAgo": "{count, plural, other{{count} jaar geleden}}",
|
||||
"yearsAgo": "{count, plural, one{{count} jaar geleden} other{{count} jaar geleden}}",
|
||||
"backupSettings": "Back-up instellingen",
|
||||
"backupStatus": "Back-up status",
|
||||
"backupStatusDescription": "Items die zijn geback-upt, worden hier getoond",
|
||||
@@ -1773,4 +1773,4 @@
|
||||
"areYouSureYouWantToMergeThem": "Weet je zeker dat je ze wilt samenvoegen?",
|
||||
"allUnnamedGroupsWillBeMergedIntoTheSelectedPerson": "Alle naamloze groepen worden samengevoegd met de geselecteerde persoon. Dit kan nog steeds ongedaan worden gemaakt vanuit het geschiedenisoverzicht van de persoon.",
|
||||
"yesIgnore": "Ja, negeer"
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@
|
||||
"deleteFromBoth": "Slett frå begge",
|
||||
"newAlbum": "Nytt album",
|
||||
"albums": "Albums",
|
||||
"memoryCount": "{count, plural, =0{ingen minne} other{{formattedCount} minne}}",
|
||||
"memoryCount": "{count, plural, =0{ingen minne} one{{formattedCount} minne} other{{formattedCount} minne}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -310,4 +310,4 @@
|
||||
"adjust": "Juster",
|
||||
"draw": "Klistremerke",
|
||||
"brushColor": "Penselfarge"
|
||||
}
|
||||
}
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Vis minner",
|
||||
"yearsAgo": "{count, plural, other{{count} år siden}}",
|
||||
"yearsAgo": "{count, plural, one{{count} år siden} other{{count} år siden}}",
|
||||
"backupSettings": "Sikkerhetskopier innstillinger",
|
||||
"backupStatus": "Status for sikkerhetskopi",
|
||||
"backupStatusDescription": "Elementer som har blitt sikkerhetskopiert vil vises her",
|
||||
@@ -1737,4 +1737,4 @@
|
||||
"memoriesWidgetDesc": "Velg typen minner du ønsker å se på din hjemskjerm.",
|
||||
"smartMemories": "Smarte minner",
|
||||
"pastYearsMemories": "Tidligere års minner"
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Usuń z obu",
|
||||
"newAlbum": "Nowy album",
|
||||
"albums": "Albumy",
|
||||
"memoryCount": "{count, plural, =0{brak wspomnień} one{{formattedCount} wspomnienie} other{{formattedCount} wspomnień}}",
|
||||
"memoryCount": "{count, plural, =0{brak wspomnień} one{{formattedCount} wspomnienie} few{{formattedCount} wspomnienia} many{{formattedCount} wspomnień} other{{formattedCount} wspomnień}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -459,8 +459,8 @@
|
||||
"selectAll": "Zaznacz wszystko",
|
||||
"skip": "Pomiń",
|
||||
"updatingFolderSelection": "Aktualizowanie wyboru folderu...",
|
||||
"itemCount": "{count, plural, one{{count} element} other{{count} elementu}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Usuń {count} element} other{Usuń {count} elementu}}",
|
||||
"itemCount": "{count, plural, one{{count} element} few {{count} elementy} many {{count} elementów} other{{count} elementu}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Usuń {count} element} few {Usuń {count} elementy} many {Usuń {count} elementów} other{Usuń {count} elementu}}",
|
||||
"duplicateItemsGroup": "{count} plików, każdy po {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
"description": "Display the number of duplicate files and their size",
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Pokaż wspomnienia",
|
||||
"yearsAgo": "{count, plural, one{{count} rok temu} other{{count} lata temu}}",
|
||||
"yearsAgo": "{count, plural, one{{count} rok temu} few {{count} lata temu} many {{count} lat temu} other{{count} lata temu}}",
|
||||
"backupSettings": "Ustawienia kopii zapasowej",
|
||||
"backupStatus": "Status kopii zapasowej",
|
||||
"backupStatusDescription": "Elementy, których kopia zapasowa została utworzona, zostaną wyświetlone w tym miejscu",
|
||||
@@ -794,11 +794,11 @@
|
||||
"share": "Udostępnij",
|
||||
"unhideToAlbum": "Odkryj do albumu",
|
||||
"restoreToAlbum": "Przywróć do albumu",
|
||||
"moveItem": "{count, plural, =1 {Przenieś element} other {Przenieś elementów}}",
|
||||
"moveItem": "{count, plural, =1 {Przenieś element} few {Przenieś elementy} many {Przenieś elementów} other {Przenieś elementów}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {Dodaj element} other {Dodaj elementów}}",
|
||||
"addItem": "{count, plural, =1 {Dodaj element} few {Dodaj elementy} many {Dodaj elementów} other {Dodaj elementów}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -826,7 +826,7 @@
|
||||
"referFriendsAnd2xYourPlan": "Poleć znajomym i podwój swój plan",
|
||||
"shareAlbumHint": "Otwórz album i dotknij przycisk udostępniania w prawym górnym rogu, aby udostępnić.",
|
||||
"itemsShowTheNumberOfDaysRemainingBeforePermanentDeletion": "Elementy pokazują liczbę dni pozostałych przed trwałym usunięciem",
|
||||
"trashDaysLeft": "{count, plural, =0 {Wkrótce} =1{1 dzień} other{{count} dni}}",
|
||||
"trashDaysLeft": "{count, plural, =0 {Wkrótce} =1{1 dzień} few {{count} dni} other{{count} dni}}",
|
||||
"@trashDaysLeft": {
|
||||
"description": "Text to indicate number of days remaining before permanent deletion",
|
||||
"placeholders": {
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Prosimy uwierzytelnić się, aby wyświetlić swoje wspomnienia",
|
||||
"unlock": "Odblokuj",
|
||||
"freeUpSpace": "Zwolnij miejsce",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Może zostać usunięty z urządzenia, aby zwolnić {formattedSize}} other {Mogą być usunięte z urządzenia, aby zwolnić {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Może zostać usunięty z urządzenia, aby zwolnić {formattedSize}} many {Może być usuniętych z urządzenia, aby zwolnić {formattedSize}} other {Mogą być usunięte z urządzenia, aby zwolnić {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 plikowi} other {{formattedNumber} plikom}} w tym albumie została bezpiecznie utworzona kopia zapasowa",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "Lokalizacja",
|
||||
"searchHint5": "Wkrótce: Twarze i magiczne wyszukiwanie ✨",
|
||||
"addYourPhotosNow": "Dodaj swoje zdjęcia teraz",
|
||||
"searchResultCount": "{count, plural, one{Znaleziono {count} wynik} other{Znaleziono {count} wyników}}",
|
||||
"searchResultCount": "{count, plural, one{Znaleziono {count} wynik} few {Znaleziono {count} wyniki} other{Znaleziono {count} wyników}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "aktualnie uruchomiony",
|
||||
"ignored": "ignorowane",
|
||||
"photosCount": "{count, plural, =0 {0 zdjęć} =1 {1 zdjęcie} other {{count} zdjęć}}",
|
||||
"photosCount": "{count, plural, =0 {0 zdjęć} =1 {1 zdjęcie} few {{count} zdjęcia} many {{count} zdjęć} other {{count} zdjęć}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "Przenieś wybrane zdjęcia na jedną datę",
|
||||
"shiftDatesAndTime": "Zmień daty i czas",
|
||||
"photosKeepRelativeTimeDifference": "Zdjęcia zachowują względną różnicę czasu",
|
||||
"photocountPhotos": "{count, plural, =0 {Brak zdjęć} =1 {1 zdjęcie} other {{count} zdjęć}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Brak zdjęć} =1 {1 zdjęcie} few {{count} zdjęcia} many {{count} zdjęć} other {{count} zdjęć}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "Wybrane elementy zostaną usunięte z tej osoby, ale nie zostaną usunięte z Twojej biblioteki.",
|
||||
"throughTheYears": "{dateFormat} przez lata",
|
||||
"thisWeekThroughTheYears": "Ten tydzień przez lata",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {W tym tygodniu, {count} rok temu} other {W tym tygodniu, {count} lat temu}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {W tym tygodniu, {count} rok temu} few {W tym tygodniu, {count} lata temu} many {W tym tygodniu, {count} lat temu} other {W tym tygodniu, {count} lat temu}}",
|
||||
"youAndThem": "Ty i {name}",
|
||||
"admiringThem": "Podziwianie {name}",
|
||||
"embracingThem": "Obejmowanie {name}",
|
||||
@@ -1828,4 +1828,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1220,17 +1220,15 @@
|
||||
"@findThemQuickly": {
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"findPeopleByName": "Busque pessoas facilmente pelo nome",
|
||||
"addViewers": "{count, plural, one {Adicionar visualizador} other {Adicionar visualizadores}}",
|
||||
"addCollaborators": "{count, plural, one {Adicionar colaborador} other {Adicionar colaboradores}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Pressione um e-mail para verificar a criptografia ponta a ponta.",
|
||||
"developerSettingsWarning": "Deseja modificar as Opções de Desenvolvedor?",
|
||||
"developerSettings": "Opções de desenvolvedor",
|
||||
"serverEndpoint": "Ponto final do servidor",
|
||||
"invalidEndpoint": "Ponto final inválido",
|
||||
"invalidEndpointMessage": "Desculpe, o ponto final inserido é inválido. Insira um ponto final válido e tente novamente.",
|
||||
"endpointUpdatedMessage": "Ponto final atualizado com sucesso",
|
||||
"customEndpoint": "Conectado à {endpoint}",
|
||||
"findPeopleByName": "Encontrar pessoas rapidamente pelo nome",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "Pressione e segure um e-mail para verificar a criptografia de ponta a ponta.",
|
||||
"developerSettingsWarning": "Tem a certeza de que pretende modificar as definições de programador?",
|
||||
"developerSettings": "Definições do programador",
|
||||
"serverEndpoint": "Endpoint do servidor",
|
||||
"invalidEndpoint": "Endpoint inválido",
|
||||
"invalidEndpointMessage": "Desculpe, o endpoint que introduziu é inválido. Introduza um ponto final válido e tente novamente.",
|
||||
"endpointUpdatedMessage": "Endpoint atualizado com sucesso",
|
||||
"customEndpoint": "Conectado a {endpoint}",
|
||||
"createCollaborativeLink": "Criar link colaborativo",
|
||||
"search": "Pesquisar",
|
||||
"enterPersonName": "Inserir nome da pessoa",
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
"publicLinkEnabled": "Link público ativado",
|
||||
"shareALink": "Partilhar um link",
|
||||
"sharedAlbumSectionDescription": "Criar álbuns compartilhados e colaborativos com outros usuários da Ente, incluindo usuários em planos gratuitos.",
|
||||
"shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {Compartilhe com pessoas específicas} =1 {Compartilhado com 1 pessoa} other {Compartilhado com {numberOfPeople} pessoas}}",
|
||||
"shareWithPeopleSectionTitle": "{numberOfPeople, plural, one {}=0 {Compartilhe com pessoas específicas} =1 {Compartilhado com 1 pessoa} other {Compartilhado com {numberOfPeople} pessoas}}",
|
||||
"@shareWithPeopleSectionTitle": {
|
||||
"placeholders": {
|
||||
"numberOfPeople": {
|
||||
@@ -899,7 +899,7 @@
|
||||
"authToViewYourMemories": "Por favor, autentique-se para ver suas memórias",
|
||||
"unlock": "Desbloquear",
|
||||
"freeUpSpace": "Libertar espaço",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {Pode eliminá-lo do aparelho para esvaziar {formattedSize}} other {Pode eliminá-los do aparelho para esvaziar {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, one {}=1 {Pode eliminá-lo do aparelho para esvaziar {formattedSize}} other {Pode eliminá-los do aparelho para esvaziar {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {1 arquivo} other {{formattedNumber} arquivos}} neste álbum teve um backup seguro",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
@@ -1828,4 +1828,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,7 +443,7 @@
|
||||
"selectAll": "Selectare totală",
|
||||
"skip": "Omiteți",
|
||||
"updatingFolderSelection": "Se actualizează selecția dosarelor...",
|
||||
"itemCount": "{count, plural, one{{count} articol} other{{count} de articole}}",
|
||||
"itemCount": "{count, plural, one{{count} articol} few {{count} articole} other{{count} de articole}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Ștergeți {count} articol} other {Ștergeți {count} de articole}}",
|
||||
"duplicateItemsGroup": "{count} fișiere, {formattedSize} fiecare",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -461,7 +461,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Afișare amintiri",
|
||||
"yearsAgo": "{count, plural, one{acum {count} an} other{acum {count} de ani}}",
|
||||
"yearsAgo": "{count, plural, one{acum {count} an} few {acum {count} ani} other{acum {count} de ani}}",
|
||||
"backupSettings": "Setări copie de rezervă",
|
||||
"backupStatus": "Stare copie de rezervă",
|
||||
"backupStatusDescription": "Articolele care au fost salvate vor apărea aici",
|
||||
@@ -526,7 +526,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "De asemenea, goliți „Coșul de gunoi” pentru a revendica spațiul eliberat",
|
||||
"sparkleSuccess": "✨ Succes",
|
||||
"duplicateFileCountWithStorageSaved": "Ați curățat {count, plural, one{{count} dublură} other{{count} de dubluri}}, economisind ({storageSaved}!)",
|
||||
"duplicateFileCountWithStorageSaved": "Ați curățat {count, plural, one{{count} dublură} few {{count} dubluri} other{{count} de dubluri}}, economisind ({storageSaved}!)",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -873,7 +873,7 @@
|
||||
"authToViewYourMemories": "Vă rugăm să vă autentificați pentru a vă vizualiza amintirile",
|
||||
"unlock": "Deblocare",
|
||||
"freeUpSpace": "Eliberați spațiu",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {Un fișier din acest album a fost deja salvat în siguranță} other {{formattedNumber} de fișiere din acest album au fost deja salvate în siguranță}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {Un fișier din acest album a fost deja salvat în siguranță} few {{formattedNumber} fișiere din acest album au fost deja salvate în siguranță} other {{formattedNumber} de fișiere din acest album au fost deja salvate în siguranță}}",
|
||||
"@filesBackedUpInAlbum": {
|
||||
"description": "Text to tell user how many files have been backed up in the album",
|
||||
"placeholders": {
|
||||
@@ -888,7 +888,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"filesBackedUpFromDevice": "{count, plural, one {Un fișier de pe acest dispozitiv a fost deja salvat în siguranță} other {{formattedNumber} de fișiere de pe acest dispozitiv fost deja salvate în siguranță}}",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {Un fișier de pe acest dispozitiv a fost deja salvat în siguranță} few {{formattedNumber} fișiere de pe acest dispozitiv au fost deja salvate în siguranță} other {{formattedNumber} de fișiere de pe acest dispozitiv fost deja salvate în siguranță}}",
|
||||
"@filesBackedUpFromDevice": {
|
||||
"description": "Text to tell user how many files have been backed up from this device",
|
||||
"placeholders": {
|
||||
@@ -1177,7 +1177,7 @@
|
||||
"searchHint4": "Locație",
|
||||
"searchHint5": "În curând: chipuri și căutare magică ✨",
|
||||
"addYourPhotosNow": "Adăugați-vă fotografiile acum",
|
||||
"searchResultCount": "{count, plural, one{{count} rezultat găsit} other{{count} de rezultate găsite}}",
|
||||
"searchResultCount": "{count, plural, one{{count} rezultat găsit} few {{count} rezultate găsite} other{{count} de rezultate găsite}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1522,4 +1522,4 @@
|
||||
"joinAlbumSubtext": "pentru a vedea și a adăuga fotografii",
|
||||
"joinAlbumSubtextViewer": "pentru a adăuga la albumele distribuite",
|
||||
"join": "Alăturare"
|
||||
}
|
||||
}
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Показывать воспоминания",
|
||||
"yearsAgo": "{count, plural, one{{count} год назад} other{{count} лет назад}}",
|
||||
"yearsAgo": "{count, plural, one{{count} год назад} few{{count} года назад} other{{count} лет назад}}",
|
||||
"backupSettings": "Настройки резервного копирования",
|
||||
"backupStatus": "Статус резервного копирования",
|
||||
"backupStatusDescription": "Элементы, сохранённые в резервной копии, появятся здесь",
|
||||
@@ -1786,4 +1786,4 @@
|
||||
"day": "День",
|
||||
"filter": "Фильтр",
|
||||
"font": "Шрифт"
|
||||
}
|
||||
}
|
||||
@@ -445,7 +445,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Прикажи успомене",
|
||||
"yearsAgo": "{count, plural, other{{count} година уназад}}",
|
||||
"yearsAgo": "{count, plural, one{{count} година уназад} few {{count} године уназад} other{{count} година уназад}}",
|
||||
"backupStatus": "Статус резервних копија",
|
||||
"backupOverMobileData": "Копирај користећи мобилни интернет",
|
||||
"backupVideos": "Копирај видео снимке",
|
||||
@@ -496,7 +496,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"duplicateFileCountWithStorageSaved": "Обрисали сте {count, plural, one{{count} дупликат} other{{count} дупликата}}, ослобађам ({storageSaved}!)",
|
||||
"duplicateFileCountWithStorageSaved": "Обрисали сте {count, plural, one{{count} дупликат} few {{count} дупликата} other{{count} дупликата}}, ослобађам ({storageSaved}!)",
|
||||
"@duplicateFileCountWithStorageSaved": {
|
||||
"description": "The text to display when the user has successfully cleaned up duplicate files",
|
||||
"type": "text",
|
||||
@@ -921,4 +921,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,7 +459,7 @@
|
||||
"selectAll": "Markera allt",
|
||||
"skip": "Hoppa över",
|
||||
"updatingFolderSelection": "Uppdaterar mappval...",
|
||||
"itemCount": "{count, plural, other{{count} objekt}}",
|
||||
"itemCount": "{count, plural, one{{count} objekt} other{{count} objekt}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Radera {count} objekt} other {Radera {count} objekt}}",
|
||||
"duplicateItemsGroup": "{count} filer, {formattedSize} vardera",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Visa minnen",
|
||||
"yearsAgo": "{count, plural, other{{count} år sedan}}",
|
||||
"yearsAgo": "{count, plural, one{{count} år sedan} other{{count} år sedan}}",
|
||||
"backupSettings": "Säkerhetskopieringsinställningar",
|
||||
"backupStatus": "Säkerhetskopieringsstatus",
|
||||
"backupStatusDescription": "Objekt som har säkerhetskopierats kommer att visas här",
|
||||
@@ -619,7 +619,7 @@
|
||||
"viewAll": "Visa alla",
|
||||
"inviteYourFriendsToEnte": "Bjud in dina vänner till Ente",
|
||||
"fileTypes": "Filtyper",
|
||||
"searchResultCount": "{count, plural, other{{count} resultat hittades}}",
|
||||
"searchResultCount": "{count, plural, one{{count} resultat hittades} other{{count} resultat hittades}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -655,4 +655,4 @@
|
||||
"newPerson": "Ny person",
|
||||
"addName": "Lägg till namn",
|
||||
"add": "Lägg till"
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,7 @@
|
||||
"deleteFromBoth": "Her ikisinden de sil",
|
||||
"newAlbum": "Yeni albüm",
|
||||
"albums": "Albümler",
|
||||
"memoryCount": "{count, plural, =0{hiç anı yok} other{{formattedCount} anı}}",
|
||||
"memoryCount": "{count, plural, =0{hiç anı yok} one{{formattedCount} anı} other{{formattedCount} anı}}",
|
||||
"@memoryCount": {
|
||||
"description": "The text to display the number of memories",
|
||||
"type": "text",
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Anıları göster",
|
||||
"yearsAgo": "{count, plural, other{{count} yıl önce}}",
|
||||
"yearsAgo": "{count, plural, one{{count} yıl önce} other{{count} yıl önce}}",
|
||||
"backupSettings": "Yedekleme seçenekleri",
|
||||
"backupStatus": "Yedekleme durumu",
|
||||
"backupStatusDescription": "Eklenen öğeler burada görünecek",
|
||||
@@ -1217,7 +1217,7 @@
|
||||
"searchHint4": "Konum",
|
||||
"searchHint5": "Çok yakında: Yüzler ve sihirli arama ✨",
|
||||
"addYourPhotosNow": "Fotoğraflarınızı şimdi ekleyin",
|
||||
"searchResultCount": "{count, plural, other{{count} yıl önce}}",
|
||||
"searchResultCount": "{count, plural, one{{count} yıl önce} other{{count} yıl önce}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1777,4 +1777,4 @@
|
||||
"different": "Farklı",
|
||||
"sameperson": "Aynı kişi mi?",
|
||||
"indexingPausedStatusDescription": "Dizin oluşturma duraklatıldı. Cihaz hazır olduğunda otomatik olarak devam edecektir. Cihaz, pil seviyesi, pil sağlığı ve termal durumu sağlıklı bir aralıkta olduğunda hazır kabul edilir."
|
||||
}
|
||||
}
|
||||
@@ -438,7 +438,7 @@
|
||||
"selectAll": "Вибрати все",
|
||||
"skip": "Пропустити",
|
||||
"updatingFolderSelection": "Оновлення вибору теки...",
|
||||
"itemCount": "{count, plural, one{{count} елемент} other{{count} елементів}}",
|
||||
"itemCount": "{count, plural, one{{count} елемент} few {{count} елементи} many {{count} елементів} other{{count} елементів}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Видалено {count} елемент} other {Видалено {count} елементів}}",
|
||||
"duplicateItemsGroup": "{count} файлів, кожен по {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -1172,7 +1172,7 @@
|
||||
"searchHint4": "Розташування",
|
||||
"searchHint5": "Незабаром: Обличчя і магічний пошук ✨",
|
||||
"addYourPhotosNow": "Додайте свої фотографії",
|
||||
"searchResultCount": "{count, plural, one{Знайдено {count} результат} other{Знайдено {count} результати}}",
|
||||
"searchResultCount": "{count, plural, one{Знайдено {count} результат} few {Знайдено {count} результати} many {Знайдено {count} результатів} other{Знайдено {count} результати}}",
|
||||
"@searchResultCount": {
|
||||
"description": "Text to tell user how many results were found for their search query",
|
||||
"placeholders": {
|
||||
@@ -1510,4 +1510,4 @@
|
||||
"legacyInvite": "{email} запросив вас стати довіреною особою",
|
||||
"authToManageLegacy": "Авторизуйтесь, щоби керувати довіреними контактами",
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр."
|
||||
}
|
||||
}
|
||||
@@ -1827,117 +1827,5 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "Video đã được xử lý",
|
||||
"totalVideos": "Tổng số video",
|
||||
"skippedVideos": "Video bị bỏ qua",
|
||||
"videoStreamingDescriptionLine1": "Phát video trên bất kỳ thiết bị.",
|
||||
"videoStreamingDescriptionLine2": "Bật để xử lý luồng phát video trên thiết bị này.",
|
||||
"videoStreamingNote": "Thiết bị này chỉ xử lý các video từ 60 ngày trở xuống và có thời lượng dưới 1 phút. Đối với các video cũ hơn/dài hơn, hãy bật tính năng phát trực tuyến trong ứng dụng máy tính để bàn.",
|
||||
"createStream": "Tạo phát trực tiếp",
|
||||
"recreateStream": "Tạo lại phát trực tiếp",
|
||||
"addedToStreamCreationQueue": "Đã thêm vào hàng đợi tạo luồng",
|
||||
"addedToStreamRecreationQueue": "Đã thêm vào hàng đợi tạo lại luồng",
|
||||
"videoPreviewAlreadyExists": "Bản xem trước video đã tồn tại",
|
||||
"videoAlreadyInQueue": "Tệp video đã có trong hàng đợi",
|
||||
"addedToQueue": "Đã thêm vào hàng đợi",
|
||||
"creatingStream": "Đang tạo luồng",
|
||||
"similarImages": "Ảnh giống nhau",
|
||||
"findSimilarImages": "Tìm ảnh giống nhau",
|
||||
"noSimilarImagesFound": "Không tìm thấy ảnh giống nhau",
|
||||
"yourPhotosLookUnique": "Ảnh của bạn trông độc đáo",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} nhóm được tìm thấy} other{{count} nhóm được tìm thấy}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "Xem lại và xóa ảnh giống nhau",
|
||||
"deletePhotosWithSize": "Xóa {count} ảnh ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "Tùy chọn lựa chọn",
|
||||
"selectExactWithCount": "Giống nhau hoàn toàn ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "Chọn chính xác",
|
||||
"selectSimilarWithCount": "Giống nhau một phần ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "Chọn giống nhau",
|
||||
"selectAllWithCount": "Tất cả giống nhau ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "Chọn những ảnh giống nhau",
|
||||
"chooseSimilarImagesToSelect": "Chọn ảnh dựa trên sự tương đồng thị giác",
|
||||
"clearSelection": "Bỏ chọn",
|
||||
"similarImagesCount": "{count} ảnh giống nhau",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "Xóa ({count})",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "Xóa các tệp",
|
||||
"areYouSureDeleteFiles": "Bạn có chắc muốn xóa các tệp này?",
|
||||
"greatJob": "Tốt lắm!",
|
||||
"cleanedUpSimilarImages": "Bạn tiết kiệm được {size}",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "Dung lượng",
|
||||
"similarity": "Sự giống nhau",
|
||||
"analyzingPhotosLocally": "Phân tích ảnh của bạn trên thiết bị...",
|
||||
"lookingForVisualSimilarities": "Tìm theo sự tương đồng thị giác...",
|
||||
"comparingImageDetails": "So sánh các đặc điểm ảnh...",
|
||||
"findingSimilarImages": "Tìm các ảnh giống nhau...",
|
||||
"almostDone": "Sắp xong...",
|
||||
"processingLocally": "Đang xử lý cục bộ",
|
||||
"useMLToFindSimilarImages": "Xem lại và xóa những ảnh có vẻ giống nhau.",
|
||||
"all": "Tất cả",
|
||||
"similar": "Giống nhau",
|
||||
"identical": "Giống hệt nhau",
|
||||
"nothingHereTryAnotherFilter": "Không thấy gì, hãy thử thay đổi bộ lọc! 👀",
|
||||
"related": "Có liên quan",
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Ở đây đã ngon lành rồi",
|
||||
"deletingDash": "Đang xóa - "
|
||||
}
|
||||
}
|
||||
@@ -459,7 +459,7 @@
|
||||
"selectAll": "全选",
|
||||
"skip": "跳过",
|
||||
"updatingFolderSelection": "正在更新文件夹选择...",
|
||||
"itemCount": "{count, plural, other{{count} 个项目}}",
|
||||
"itemCount": "{count, plural, one{{count} 个项目} other{{count} 个项目}}",
|
||||
"deleteItemCount": "{count, plural, =1 {删除 {count} 个项目} other {删除 {count} 个项目}}",
|
||||
"duplicateItemsGroup": "{count} 个文件,每个文件 {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "显示回忆",
|
||||
"yearsAgo": "{count, plural, other{{count} 年前}}",
|
||||
"yearsAgo": "{count, plural, one{{count} 年前} other{{count} 年前}}",
|
||||
"backupSettings": "备份设置",
|
||||
"backupStatus": "备份状态",
|
||||
"backupStatusDescription": "已备份的项目将显示在此处",
|
||||
@@ -1827,111 +1827,5 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"videosProcessed": "视频已处理",
|
||||
"totalVideos": "视频总数",
|
||||
"skippedVideos": "已跳过的视频",
|
||||
"videoStreamingDescriptionLine1": "在任何设备上立即播放视频。",
|
||||
"videoStreamingDescriptionLine2": "启用以处理此设备上的视频流。",
|
||||
"videoStreamingNote": "此设备仅处理过去 60 天内时长不超过 1 分钟的视频。对于更早/更长的视频,请在桌面应用中启用流式传输。",
|
||||
"createStream": "创建流",
|
||||
"recreateStream": "重建流",
|
||||
"addedToStreamCreationQueue": "已添加到流创建队列",
|
||||
"addedToStreamRecreationQueue": "已添加到流重建队列",
|
||||
"videoPreviewAlreadyExists": "视频预览已存在",
|
||||
"videoAlreadyInQueue": "视频文件已存在于队列中",
|
||||
"addedToQueue": "已添加至队列",
|
||||
"creatingStream": "正在创建流",
|
||||
"similarImages": "相似图片",
|
||||
"findSimilarImages": "查找相似图片",
|
||||
"noSimilarImagesFound": "未找到相似图片",
|
||||
"yourPhotosLookUnique": "您的照片看起来很独特",
|
||||
"similarGroupsFound": "{count, plural, =1{{count} 组已找到} other{{count} 组已找到}}",
|
||||
"@similarGroupsFound": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reviewAndRemoveSimilarImages": "查看并删除相似图片",
|
||||
"deletePhotosWithSize": "删除 {count} 张照片 ({size})",
|
||||
"@deletePhotosWithSize": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
},
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionOptions": "选择选项",
|
||||
"selectExactWithCount": "完全相似 ({count})",
|
||||
"@selectExactWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectExact": "精确选择",
|
||||
"selectSimilarWithCount": "部分相似 ({count})",
|
||||
"@selectSimilarWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilar": "选择相似项",
|
||||
"selectAllWithCount": "所有相似项 ({count})",
|
||||
"@selectAllWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectSimilarImagesTitle": "选择所有相似图片",
|
||||
"chooseSimilarImagesToSelect": "根据视觉相似性选择图像",
|
||||
"clearSelection": "清除选择",
|
||||
"similarImagesCount": "{count} 张相似图片",
|
||||
"@similarImagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteWithCount": "删除 ({count}) 项",
|
||||
"@deleteWithCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deleteFiles": "删除文件",
|
||||
"areYouSureDeleteFiles": "您确定要删除这些文件吗?",
|
||||
"greatJob": "做得好!",
|
||||
"cleanedUpSimilarImages": "您已释放了 {size} 的空间",
|
||||
"@cleanedUpSimilarImages": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"size": "大小",
|
||||
"similarity": "相似度",
|
||||
"processingLocally": "正在本地处理",
|
||||
"useMLToFindSimilarImages": "审查并删除看起来彼此相似的图像。",
|
||||
"all": "全部",
|
||||
"similar": "相似的",
|
||||
"identical": "完全相同",
|
||||
"nothingHereTryAnotherFilter": "此处无内容,请尝试其他过滤器!👀",
|
||||
"related": "相关",
|
||||
"hoorayyyy": "耶~~!",
|
||||
"nothingToTidyUpHere": "这里没什么可清理的"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,6 @@ import "package:flutter/widgets.dart";
|
||||
import 'package:photos/generated/intl/app_localizations.dart';
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
// list of locales which are enabled for photos app.
|
||||
// Add more language to the list only when at least 90% of the strings are
|
||||
// translated in the corresponding language.
|
||||
const List<Locale> appSupportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('es'),
|
||||
Locale('de'),
|
||||
Locale('fr'),
|
||||
Locale('it'),
|
||||
Locale('ja'),
|
||||
Locale("nl"),
|
||||
Locale("no"),
|
||||
Locale("pl"),
|
||||
Locale("pt", "BR"),
|
||||
Locale('pt', 'PT'),
|
||||
Locale("ro"),
|
||||
Locale("ru"),
|
||||
Locale("tr"),
|
||||
Locale("uk"),
|
||||
Locale("vi"),
|
||||
Locale("zh", "CN"),
|
||||
];
|
||||
|
||||
extension AppLocalizationsX on BuildContext {
|
||||
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||
}
|
||||
@@ -35,12 +12,12 @@ Locale? autoDetectedLocale;
|
||||
Locale localResolutionCallBack(deviceLocales, supportedLocales) {
|
||||
_onDeviceLocales = deviceLocales;
|
||||
final Set<String> languageSupport = {};
|
||||
for (Locale supportedLocale in appSupportedLocales) {
|
||||
for (Locale supportedLocale in AppLocalizations.supportedLocales) {
|
||||
languageSupport.add(supportedLocale.languageCode);
|
||||
}
|
||||
for (Locale locale in deviceLocales) {
|
||||
// check if exact local is supported, if yes, return it
|
||||
if (appSupportedLocales.contains(locale)) {
|
||||
if (AppLocalizations.supportedLocales.contains(locale)) {
|
||||
autoDetectedLocale = locale;
|
||||
return locale;
|
||||
}
|
||||
@@ -90,7 +67,7 @@ Future<Locale?> getLocale({
|
||||
} else {
|
||||
savedLocale = Locale(savedValue);
|
||||
}
|
||||
if (appSupportedLocales.contains(savedLocale)) {
|
||||
if (AppLocalizations.supportedLocales.contains(savedLocale)) {
|
||||
return savedLocale;
|
||||
}
|
||||
}
|
||||
@@ -104,7 +81,7 @@ Future<Locale?> getLocale({
|
||||
}
|
||||
|
||||
Future<void> setLocale(Locale locale) async {
|
||||
if (!appSupportedLocales.contains(locale)) {
|
||||
if (!AppLocalizations.supportedLocales.contains(locale)) {
|
||||
throw Exception('Locale $locale is not supported by the app');
|
||||
}
|
||||
final StringBuffer out = StringBuffer(locale.languageCode);
|
||||
|
||||