Compare commits
466 Commits
release_ac
...
remote_db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38503e8673 | ||
|
|
91785d8c90 | ||
|
|
210c18d244 | ||
|
|
6636849838 | ||
|
|
5500315351 | ||
|
|
562292e642 | ||
|
|
4aa80edbcf | ||
|
|
9524a639cd | ||
|
|
b8eb793c16 | ||
|
|
4b514f1e1a | ||
|
|
772121c22e | ||
|
|
f2e51893ad | ||
|
|
c08b78c775 | ||
|
|
73ab50f113 | ||
|
|
4a2346fe93 | ||
|
|
68b5cce158 | ||
|
|
e907a9e8cb | ||
|
|
92a40afca2 | ||
|
|
0c2b38c059 | ||
|
|
19650bcd57 | ||
|
|
2b9ca073ce | ||
|
|
cf4b87dad9 | ||
|
|
3fd0db6a90 | ||
|
|
ce1701d211 | ||
|
|
aaed336991 | ||
|
|
0b85dfe7e4 | ||
|
|
68422b172f | ||
|
|
db99dae3e1 | ||
|
|
3717a156d3 | ||
|
|
ca9930e01b | ||
|
|
eb23a4e770 | ||
|
|
e03303e5b3 | ||
|
|
2ad27f1c6e | ||
|
|
202e6a9f7c | ||
|
|
ceaedad327 | ||
|
|
fd963a1c8e | ||
|
|
b40b5bb1ae | ||
|
|
91827626b2 | ||
|
|
42318335ae | ||
|
|
858db62385 | ||
|
|
46e36612d3 | ||
|
|
62cf236e3b | ||
|
|
c2b1ab86f2 | ||
|
|
43adf42281 | ||
|
|
1e2a65281c | ||
|
|
70eb68b13c | ||
|
|
fa86b19307 | ||
|
|
e632dc7771 | ||
|
|
7fa9adb636 | ||
|
|
83f885f158 | ||
|
|
a295f223b6 | ||
|
|
cc64ef8035 | ||
|
|
69dd7b6233 | ||
|
|
bcc9f1be73 | ||
|
|
296b2a2a6c | ||
|
|
6b48c9bc34 | ||
|
|
6a951bcc72 | ||
|
|
38914981a1 | ||
|
|
66f4d5b1a6 | ||
|
|
9ee3781320 | ||
|
|
907d1d2bb8 | ||
|
|
8218283463 | ||
|
|
23dc809589 | ||
|
|
f72c9fa068 | ||
|
|
bc6506cb10 | ||
|
|
f2a26ba391 | ||
|
|
c5319f2ba8 | ||
|
|
5d851d8f90 | ||
|
|
dd76317bb0 | ||
|
|
5519981f9c | ||
|
|
1c249560c0 | ||
|
|
130cc9137c | ||
|
|
95f2d282b5 | ||
|
|
e6668606db | ||
|
|
316c767ffa | ||
|
|
c08b0f7aa4 | ||
|
|
b5a4bcf98f | ||
|
|
dc06a2e193 | ||
|
|
c29446e00a | ||
|
|
5aa36921d8 | ||
|
|
3b3af65d74 | ||
|
|
9ad88d3908 | ||
|
|
012e9091f0 | ||
|
|
8ff0f237e7 | ||
|
|
c80e4a65b8 | ||
|
|
c317f2494f | ||
|
|
264b0b151a | ||
|
|
e5cb3e7005 | ||
|
|
adc1939638 | ||
|
|
2cc1c36b7b | ||
|
|
b99450615e | ||
|
|
5de1f0c93b | ||
|
|
ee902a5ccb | ||
|
|
9e56afaa73 | ||
|
|
77f8c3f712 | ||
|
|
f7a0a414db | ||
|
|
bd49b8464b | ||
|
|
0cbfa319ba | ||
|
|
95e05f167c | ||
|
|
84ef4c2d0b | ||
|
|
53c14dff01 | ||
|
|
02ef29fd8f | ||
|
|
1848f1a94b | ||
|
|
276c38236f | ||
|
|
5e3e3b4427 | ||
|
|
b8dab3ea1c | ||
|
|
2dc32f5339 | ||
|
|
9fd6bc4974 | ||
|
|
3740e9e29d | ||
|
|
118dde38a2 | ||
|
|
cd15fe86c6 | ||
|
|
693a40cc24 | ||
|
|
2cfa8497da | ||
|
|
3f6b9d7ae6 | ||
|
|
e71b6dbb63 | ||
|
|
44722c40c2 | ||
|
|
bfd13b99d2 | ||
|
|
e82d878fa8 | ||
|
|
0587813c70 | ||
|
|
b7712a7c51 | ||
|
|
213f3cd122 | ||
|
|
d103274da5 | ||
|
|
28b244ebc5 | ||
|
|
7d24dea7bc | ||
|
|
4a358a7793 | ||
|
|
699249cd26 | ||
|
|
7125ac7419 | ||
|
|
de67b6e9fc | ||
|
|
39868de5d9 | ||
|
|
751550d469 | ||
|
|
6f0034dd9d | ||
|
|
7bd6180ebf | ||
|
|
5a3ae5f97c | ||
|
|
e447058573 | ||
|
|
8a7e8b8237 | ||
|
|
ed7b1be591 | ||
|
|
9a5ef8b634 | ||
|
|
c2668387b5 | ||
|
|
d265cf62c7 | ||
|
|
a67c3b0624 | ||
|
|
306c78fbb0 | ||
|
|
e4f9dd6b33 | ||
|
|
5fec59f0fe | ||
|
|
53050ca25e | ||
|
|
926aa42168 | ||
|
|
f4f8141b99 | ||
|
|
3d044dd3d4 | ||
|
|
8699ad2f01 | ||
|
|
01aad531f1 | ||
|
|
6340d9f646 | ||
|
|
0f944b1796 | ||
|
|
1d8533168f | ||
|
|
2fca1ba534 | ||
|
|
2d386c769b | ||
|
|
1b41d81839 | ||
|
|
487156c7df | ||
|
|
35c54111e7 | ||
|
|
d71340fbdd | ||
|
|
f32ea85ee2 | ||
|
|
6397ab888a | ||
|
|
5a73043b63 | ||
|
|
f258c40e98 | ||
|
|
36b6476049 | ||
|
|
c4c5ea150f | ||
|
|
1956b3788b | ||
|
|
72cbddff6d | ||
|
|
17670d5538 | ||
|
|
8cfd80663e | ||
|
|
f285e2d706 | ||
|
|
f60e074dd5 | ||
|
|
140eae6859 | ||
|
|
23ee022472 | ||
|
|
b1cf3f9fb0 | ||
|
|
dbbb80a817 | ||
|
|
91a10634cc | ||
|
|
0a2c230254 | ||
|
|
6745b110df | ||
|
|
7061161181 | ||
|
|
f0026f0a81 | ||
|
|
acec985bcb | ||
|
|
c8103a9e06 | ||
|
|
02f64ad45f | ||
|
|
d0931d1d0e | ||
|
|
5c78de5355 | ||
|
|
1aa9f61419 | ||
|
|
0f2b51d1a5 | ||
|
|
9fef560d15 | ||
|
|
09c7bfd717 | ||
|
|
2ff059a701 | ||
|
|
70b043d34a | ||
|
|
15a00379b5 | ||
|
|
7246ade2ae | ||
|
|
7cad0a83d2 | ||
|
|
e6703aef65 | ||
|
|
662dfad7ca | ||
|
|
466ab30f8b | ||
|
|
12c1845a5f | ||
|
|
2d0202df36 | ||
|
|
2ec4f5a7e5 | ||
|
|
6723bed1b0 | ||
|
|
84e8ce519e | ||
|
|
1b50528181 | ||
|
|
08dff77ad4 | ||
|
|
713abce89a | ||
|
|
da15593a47 | ||
|
|
fc31cc61d1 | ||
|
|
9c6259b713 | ||
|
|
ed603232a5 | ||
|
|
ccd89d3451 | ||
|
|
647b2ef4a7 | ||
|
|
f54c79462e | ||
|
|
eb81d96ddf | ||
|
|
57363a24ef | ||
|
|
9431995e8c | ||
|
|
64b86376f6 | ||
|
|
a825367c49 | ||
|
|
1ffbb27ac5 | ||
|
|
d4add9f7ef | ||
|
|
541494613f | ||
|
|
2b3427e40b | ||
|
|
a57c9e881d | ||
|
|
d15f1e15ce | ||
|
|
0411f8ad40 | ||
|
|
2981816c90 | ||
|
|
a6de98ef68 | ||
|
|
18156ce8bc | ||
|
|
458c1cf86d | ||
|
|
90c0874608 | ||
|
|
928ffba4d7 | ||
|
|
0701212540 | ||
|
|
347bf4d2e0 | ||
|
|
2729edfded | ||
|
|
4e8d2c5cea | ||
|
|
84e9336672 | ||
|
|
cecdea3f93 | ||
|
|
37674deba0 | ||
|
|
733be57df8 | ||
|
|
74df52baf1 | ||
|
|
b817c4475e | ||
|
|
f95dac31d2 | ||
|
|
151289b24a | ||
|
|
5dac9d4dd6 | ||
|
|
43b9dbdc54 | ||
|
|
9d3caaa5d5 | ||
|
|
7eda2ed24e | ||
|
|
30df5271b4 | ||
|
|
4b1f7612a3 | ||
|
|
bf6521e8d5 | ||
|
|
4bac1bcb1d | ||
|
|
b123635584 | ||
|
|
d815143bb4 | ||
|
|
ff6228497f | ||
|
|
7469578e77 | ||
|
|
76afef6149 | ||
|
|
2b3178495a | ||
|
|
d6f3ff8db3 | ||
|
|
7b0ef2b0c0 | ||
|
|
35f95010ea | ||
|
|
233f0ec1e1 | ||
|
|
64820ff5fa | ||
|
|
86ffd4e1e6 | ||
|
|
436a02d352 | ||
|
|
0f3b8bae48 | ||
|
|
0f270a379f | ||
|
|
609f6b8e18 | ||
|
|
7896b397c2 | ||
|
|
097078bd24 | ||
|
|
e43e3c4230 | ||
|
|
439f1ff0fb | ||
|
|
e5d78cfd99 | ||
|
|
bd76e66abf | ||
|
|
9e6d7908a9 | ||
|
|
281735e172 | ||
|
|
bc93aca110 | ||
|
|
df6e409ca1 | ||
|
|
7489821434 | ||
|
|
52e0f04ec2 | ||
|
|
ad88ce632c | ||
|
|
26222ec836 | ||
|
|
beeaee4fd9 | ||
|
|
dc98c7bcf5 | ||
|
|
82497563c2 | ||
|
|
4a9b4520d2 | ||
|
|
756c8e5b7d | ||
|
|
dffb920cab | ||
|
|
fa7ddbba0c | ||
|
|
13a068969c | ||
|
|
717e8c8b7e | ||
|
|
015adb595c | ||
|
|
91635d2e7d | ||
|
|
cd3499a004 | ||
|
|
92a964cda6 | ||
|
|
a9ba615962 | ||
|
|
f2b0c11622 | ||
|
|
83b89b6bbf | ||
|
|
ee0a858302 | ||
|
|
b34c923a66 | ||
|
|
fd927d038b | ||
|
|
64e9902f57 | ||
|
|
c3af79d113 | ||
|
|
d87e679650 | ||
|
|
7e2242dc69 | ||
|
|
9adc207b02 | ||
|
|
36049f6633 | ||
|
|
9341bc95ee | ||
|
|
252dca1a01 | ||
|
|
70501054d2 | ||
|
|
be012e0a28 | ||
|
|
340a0c097f | ||
|
|
7fc8649455 | ||
|
|
c97a313edb | ||
|
|
480fdc84dc | ||
|
|
c92ef45c9a | ||
|
|
ca62012a6f | ||
|
|
151a0d13a4 | ||
|
|
747b1b84c6 | ||
|
|
e060fb9823 | ||
|
|
cd377149bc | ||
|
|
d9e22a489b | ||
|
|
524db74bf5 | ||
|
|
e1222d51a9 | ||
|
|
1c68f0bb60 | ||
|
|
f6419caf5c | ||
|
|
441bcbd187 | ||
|
|
4ad3927348 | ||
|
|
4ae15e5966 | ||
|
|
d67d1d3df8 | ||
|
|
07e1d33ca8 | ||
|
|
50e15fa56c | ||
|
|
3f262c5ba2 | ||
|
|
7f34870e3a | ||
|
|
8ba5013926 | ||
|
|
e9a24efecb | ||
|
|
eaf74e4059 | ||
|
|
e9e1c3ca27 | ||
|
|
eb34533aed | ||
|
|
cd042e741e | ||
|
|
6e944b0b55 | ||
|
|
18b6b499dd | ||
|
|
9205ef8219 | ||
|
|
1ecdbdb88e | ||
|
|
e2bb4d723e | ||
|
|
cfe32c47f0 | ||
|
|
a7f6b6589d | ||
|
|
715e305e09 | ||
|
|
f7330be52c | ||
|
|
4cce54a0c6 | ||
|
|
1850e9a2a6 | ||
|
|
21e2b589cc | ||
|
|
b7f8deb452 | ||
|
|
b6ffb3ca22 | ||
|
|
9351a52800 | ||
|
|
c7510024c0 | ||
|
|
63d3b1c94b | ||
|
|
cec27b40a4 | ||
|
|
2f8d0d1957 | ||
|
|
7f0a36f110 | ||
|
|
355367a601 | ||
|
|
c66da422cd | ||
|
|
6f90fad4a2 | ||
|
|
bcd6f55376 | ||
|
|
b2766a0d4f | ||
|
|
ec1b95b0cd | ||
|
|
4369317a4d | ||
|
|
b18298dc62 | ||
|
|
b3e467a1a4 | ||
|
|
f8cd3d9fb4 | ||
|
|
6d4756ca4b | ||
|
|
676bbb4d88 | ||
|
|
da8edfd34e | ||
|
|
bf453cfaac | ||
|
|
35f41f044e | ||
|
|
acff269695 | ||
|
|
2b4f96dbb7 | ||
|
|
d5796e2abb | ||
|
|
afe9690891 | ||
|
|
fc619bbd03 | ||
|
|
a198331ffd | ||
|
|
c58fe5358d | ||
|
|
defc5164b9 | ||
|
|
97d4fb0693 | ||
|
|
e708564cb9 | ||
|
|
92068e026b | ||
|
|
8a079ab4f4 | ||
|
|
b41c57cb8d | ||
|
|
87167e49fc | ||
|
|
089d1dcd10 | ||
|
|
db02e66124 | ||
|
|
964066bf31 | ||
|
|
643220e595 | ||
|
|
e871498161 | ||
|
|
31a88b74df | ||
|
|
1801258fea | ||
|
|
27acc2125b | ||
|
|
60b7a91756 | ||
|
|
b38a01820d | ||
|
|
347d5a7a72 | ||
|
|
82979ac729 | ||
|
|
5768eeb1fe | ||
|
|
7a9ff9877a | ||
|
|
3565540a61 | ||
|
|
06d78e5d6a | ||
|
|
6c11b76c11 | ||
|
|
749cfde7d8 | ||
|
|
6bcaa8ae26 | ||
|
|
99c6318b0e | ||
|
|
abf789a4aa | ||
|
|
f5d3712cbb | ||
|
|
45dd540abc | ||
|
|
865a736bdd | ||
|
|
75bd1bfef6 | ||
|
|
4cf67fe171 | ||
|
|
65895328dc | ||
|
|
a1c20b9c8a | ||
|
|
de9def5370 | ||
|
|
9cc723a280 | ||
|
|
16beae2a82 | ||
|
|
2e25c38324 | ||
|
|
7e691f84e4 | ||
|
|
d99593fc85 | ||
|
|
d4877ea446 | ||
|
|
5f4c748886 | ||
|
|
eaab58c62a | ||
|
|
c1c020402e | ||
|
|
028f4e61d2 | ||
|
|
abc6f56247 | ||
|
|
822eb59761 | ||
|
|
fade7859ab | ||
|
|
f85047fb28 | ||
|
|
3f81c9beae | ||
|
|
b7fa8d7c89 | ||
|
|
5f75c5fc3f | ||
|
|
98eaee3b9e | ||
|
|
59bd039bed | ||
|
|
3e90126a55 | ||
|
|
dabcc0aeb5 | ||
|
|
c4b99af0e2 | ||
|
|
679c12bb90 | ||
|
|
3ef8ece8c0 | ||
|
|
24f8cf188a | ||
|
|
233838da3e | ||
|
|
06c4866c75 | ||
|
|
fa71acf91a | ||
|
|
81d40826b3 | ||
|
|
2a14b5e5a3 | ||
|
|
63bbca09f3 | ||
|
|
cbff68bc42 | ||
|
|
070ab80be9 | ||
|
|
0efbf407d3 | ||
|
|
ab22b28695 | ||
|
|
d3466d7efe | ||
|
|
5945f2aaad | ||
|
|
4c79b9cb92 | ||
|
|
4676c363d2 | ||
|
|
f75807d8f0 | ||
|
|
578541308a | ||
|
|
30cded4d3d | ||
|
|
be2aee6baa | ||
|
|
fae23df6eb | ||
|
|
2a1f2aded1 | ||
|
|
6e85d24286 | ||
|
|
0bcc676e44 | ||
|
|
b5a9bab5c6 | ||
|
|
5849d14cd9 | ||
|
|
77cde87927 | ||
|
|
670d6e8470 |
37
.github/workflows/mobile-daily-internal.yml
vendored
37
.github/workflows/mobile-daily-internal.yml
vendored
@@ -27,6 +27,38 @@ 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:
|
||||
@@ -39,11 +71,6 @@ 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
77
.github/workflows/mobile-internal-release.yml
vendored
@@ -1,77 +0,0 @@
|
||||
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
38
.github/workflows/mobile-release.yml
vendored
@@ -28,6 +28,38 @@ 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:
|
||||
@@ -40,6 +72,12 @@ 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:
|
||||
|
||||
@@ -1236,6 +1236,12 @@
|
||||
"title": "Parqet",
|
||||
"slug": "parqet"
|
||||
},
|
||||
{
|
||||
"title": "Parallels",
|
||||
"slug": "parallels",
|
||||
"hex": "#E61E25",
|
||||
"altNames": ["Parallels Desktop", "Parallels VM"]
|
||||
},
|
||||
{
|
||||
"title": "Parsec"
|
||||
},
|
||||
|
||||
4
mobile/apps/auth/assets/custom-icons/icons/parallels.svg
Normal file
4
mobile/apps/auth/assets/custom-icons/icons/parallels.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 207 B |
@@ -7,8 +7,7 @@ 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/colors.dart';
|
||||
import 'package:ente_ui/theme/ente_theme_data.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";
|
||||
@@ -87,37 +86,14 @@ 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: lightTheme,
|
||||
dark: darkTheme,
|
||||
light: lightThemeData,
|
||||
dark: darkThemeData,
|
||||
initial: AdaptiveThemeMode.system,
|
||||
builder: (lightTheme, dartTheme) => MaterialApp(
|
||||
title: "ente",
|
||||
@@ -142,8 +118,8 @@ class _AppState extends State<App>
|
||||
return MaterialApp(
|
||||
title: "ente",
|
||||
themeMode: ThemeMode.system,
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme,
|
||||
theme: lightThemeData,
|
||||
darkTheme: darkThemeData,
|
||||
debugShowCheckedModeBanner: false,
|
||||
locale: locale,
|
||||
supportedLocales: appSupportedLocales,
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -103,6 +104,8 @@ 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 [
|
||||
|
||||
@@ -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 videos.
|
||||
ente es una aplicación simple para hacer copias de seguridad y compartir tus fotos y vídeos.
|
||||
|
||||
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. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
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.
|
||||
|
||||
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
|
||||
- Collaborative albums, so you can pool together photos after a trip
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Album kolaboratif, sehingga anda dapat mengumpulkan foto bersama setelah sebuah perjalanan
|
||||
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
|
||||
- Link album, yang bisa dilindungi dengan sandi
|
||||
Kemampuan untuk membebaskan kapasitas, dengan menghilangkan files yang sudah di back-up dengan aman
|
||||
- Human support, because you're worth it
|
||||
- Descriptions, so you can caption your memories and find them easily
|
||||
- Dukungan manusia, karena anda layak mendapatkannya
|
||||
- Deskripsi, sehingga anda dapat memberi keterangan pada memori anda dan menemukannya dengan mudah
|
||||
- Editor gambar, untuk menyempurnakan fotomu
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
|
||||
- Pengimporan mudah dari Google, Apple, hard drive-mu, dan lainnya
|
||||
- Dark theme, because your photos look good in it
|
||||
- Tema gelap, karena foto anda terlihat bagus di dalamnya
|
||||
- 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. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
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.
|
||||
|
||||
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
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
|
||||
- Link album, yang bisa dilindungi dengan sandi dan diatur waktu kedaluwarsanya
|
||||
- Ability to free up space, by removing files that have been safely backed up
|
||||
- Kemampuan untuk mengosongkan ruang, dengan menghapus file yang telah dicadangkan dengan aman
|
||||
- Editor gambar, untuk menyempurnakan fotomu
|
||||
- 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
|
||||
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
|
||||
- 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
|
||||
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.
|
||||
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.
|
||||
|
||||
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. We take posterity seriously and make it easy to ensure that your memories outlive you.
|
||||
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.
|
||||
|
||||
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
|
||||
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
|
||||
- Folder bersama, jika anda ingin pasangan anda menikmati hasil jepretan "Kamera" anda
|
||||
- Link album, yang bisa dilindungi dengan sandi dan diatur waktu kedaluwarsanya
|
||||
- Ability to free up space, by removing files that have been safely backed up
|
||||
- Kemampuan untuk mengosongkan ruang, dengan menghapus file yang telah dicadangkan dengan aman
|
||||
- Editor gambar, untuk menyempurnakan fotomu
|
||||
- Favorite, hide and relive your memories, for they are precious
|
||||
- Favoritkan, sembunyikan, dan kenang kembali memori anda, karena itu sangat berharga
|
||||
- Pengimporan mudah dari Google, Apple, hard drive-mu, dan lainnya
|
||||
- Dark theme, because your photos look good in it
|
||||
- Tema gelap, karena foto anda terlihat bagus di dalamnya
|
||||
- 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
|
||||
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.
|
||||
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.
|
||||
@@ -181,6 +181,8 @@ 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):
|
||||
@@ -291,6 +293,7 @@ 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`)
|
||||
@@ -309,7 +312,7 @@ DEPENDENCIES:
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios:
|
||||
https://github.com/ente-io/ffmpeg-kit-custom-repo-ios.git:
|
||||
- ffmpeg_kit_custom
|
||||
trunk:
|
||||
- Firebase
|
||||
@@ -418,6 +421,8 @@ 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:
|
||||
@@ -452,84 +457,85 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/workmanager/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7
|
||||
battery_info: 83f3aae7be2fccefab1d2bf06b8aa96f11c8bcdd
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
emoji_picker_flutter: ed468d9746c21711e66b2788880519a9de5de211
|
||||
app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6
|
||||
battery_info: b6c551049266af31556b93c9d9b9452cfec0219f
|
||||
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
|
||||
dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
emoji_picker_flutter: fe2e6151c5b548e975d546e6eeb567daf0962a58
|
||||
ffmpeg_kit_custom: 682b4f2f1ff1f8abae5a92f6c3540f2441d5be99
|
||||
ffmpeg_kit_flutter: 915b345acc97d4142e8a9a8549d177ff10f043f5
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
ffmpeg_kit_flutter: 9dce4803991478c78c6fb9f972703301101095fe
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e
|
||||
firebase_core: ece862f94b2bc72ee0edbeec7ab5c7cb09fe1ab5
|
||||
firebase_messaging: e1a5fae495603115be1d0183bc849da748734e2b
|
||||
firebase_core: cf4d42a8ac915e51c0c2dc103442f3036d941a2d
|
||||
firebase_messaging: fee490327c1aae28a0da1e65fca856547deca493
|
||||
FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e
|
||||
FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4
|
||||
FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843
|
||||
FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
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
|
||||
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
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
in_app_purchase_storekit: d1a48cb0f8b29dbf5f85f782f5dd79b21b90a5e6
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
launcher_icon_switcher: 84c218d233505aa7d8655d8fa61a3ba802c022da
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
in_app_purchase_storekit: a1ce04056e23eecc666b086040239da7619cd783
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
launcher_icon_switcher: 8e0ad2131a20c51c1dd939896ee32e70cd845b37
|
||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451
|
||||
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
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
|
||||
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
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
native_video_player: 6809dec117e8997161dbfb42a6f90d6df71a504d
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2
|
||||
native_video_player: 29ab24a926804ac8c4a57eb6d744c7d927c2bc3e
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997
|
||||
onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c
|
||||
onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b
|
||||
open_mail_app: 7314a609e88eed22d53671279e189af7a0ab0f11
|
||||
open_mail_app: 70273c53f768beefdafbe310c3d9086e4da3cb02
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62
|
||||
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: 81954a1bf804b6e882d0453b3b6bc7fad7b47d3d
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||
rust_lib_photos: 04d3901908d2972192944083310b65abf410774c
|
||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
||||
rive_common: 4743dbfd2911c99066547a3c6454681e0fa907df
|
||||
rust_lib_photos: 8813b31af48ff02ca75520cbc81a363a13d51a84
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
|
||||
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sentry_flutter: 2df8b0aab7e4aba81261c230cbea31c82a62dd1b
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
sqlite3: 1d85290c3321153511f6e900ede7a1608718bbd5
|
||||
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
|
||||
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
|
||||
|
||||
PODFILE CHECKSUM: cce2cd3351d3488dca65b151118552b680e23635
|
||||
|
||||
|
||||
@@ -565,6 +565,7 @@
|
||||
"${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",
|
||||
@@ -662,6 +663,7 @@
|
||||
"${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",
|
||||
|
||||
@@ -142,7 +142,7 @@ class _EnteAppState extends State<EnteApp> with WidgetsBindingObserver {
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: EasyLoading.init(),
|
||||
locale: locale,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
supportedLocales: appSupportedLocales,
|
||||
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: AppLocalizations.supportedLocales,
|
||||
supportedLocales: appSupportedLocales,
|
||||
localeListResolutionCallback: localResolutionCallBack,
|
||||
localizationsDelegates: const [
|
||||
...AppLocalizations.localizationsDelegates,
|
||||
|
||||
@@ -29,6 +29,10 @@ class LRUMap<K, V> {
|
||||
}
|
||||
}
|
||||
|
||||
bool containsKey(K key) {
|
||||
return _map.containsKey(key);
|
||||
}
|
||||
|
||||
void remove(K key) {
|
||||
_map.remove(key);
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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,11 +11,9 @@ 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';
|
||||
@@ -23,14 +21,16 @@ 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,10 +1,12 @@
|
||||
import "package:flutter/foundation.dart";
|
||||
|
||||
const int thumbnailSmallSize = 256;
|
||||
const int thumbnailQuality = 50;
|
||||
const int thumbnailLargeSize = 512;
|
||||
const int compressedThumbnailResolution = 1080;
|
||||
const int thumbnailDataLimit = 100 * 1024;
|
||||
// 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 String sentryDSN =
|
||||
"https://2235e5c99219488ea93da34b9ac1cb68@sentry.ente.io/4";
|
||||
const String sentryDebugDSN =
|
||||
@@ -109,4 +111,4 @@ final tempDirCleanUpInterval = kDebugMode
|
||||
const kFilterChipHeight = 32.0;
|
||||
const kMaxAppbarFilters = 14;
|
||||
|
||||
const kLivePhotoHashSeparator = ':';
|
||||
const kHashSeprator = ':';
|
||||
|
||||
13
mobile/apps/photos/lib/core/exceptions.dart
Normal file
13
mobile/apps/photos/lib/core/exceptions.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
/// 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';
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
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 const _params = {};
|
||||
static final _params = {};
|
||||
|
||||
static String getParams(int count) {
|
||||
String getParams(int count) {
|
||||
if (!_params.containsKey(count)) {
|
||||
final params = List.generate(count, (_) => "?").join(", ");
|
||||
_params[count] = params;
|
||||
@@ -14,9 +14,13 @@ mixin SqlDbBase {
|
||||
|
||||
Future<void> migrate(
|
||||
SqliteDatabase database,
|
||||
List<String> migrationScripts,
|
||||
) async {
|
||||
List<String> migrationScripts, {
|
||||
bool onForeignKey = false,
|
||||
}) 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;
|
||||
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
333
mobile/apps/photos/lib/db/local/db.dart
Normal file
333
mobile/apps/photos/lib/db/local/db.dart
Normal file
@@ -0,0 +1,333 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
100
mobile/apps/photos/lib/db/local/mappers.dart
Normal file
100
mobile/apps/photos/lib/db/local/mappers.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
253
mobile/apps/photos/lib/db/local/schema.dart
Normal file
253
mobile/apps/photos/lib/db/local/schema.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
mobile/apps/photos/lib/db/local/table/device_albums.dart
Normal file
33
mobile/apps/photos/lib/db/local/table/device_albums.dart
Normal file
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
92
mobile/apps/photos/lib/db/local/table/path_config_table.dart
Normal file
92
mobile/apps/photos/lib/db/local/table/path_config_table.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
69
mobile/apps/photos/lib/db/local/table/shared_assets.dart
Normal file
69
mobile/apps/photos/lib/db/local/table/shared_assets.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
148
mobile/apps/photos/lib/db/local/table/upload_queue_table.dart
Normal file
148
mobile/apps/photos/lib/db/local/table/upload_queue_table.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
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<int, int>> faceIndexedFileIds({int minimumMlVersion});
|
||||
Future<Map<T, 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>> getFaceInfoForClustering({
|
||||
Future<List<FaceDbInfoForClustering<T>>> getFaceInfoForClustering({
|
||||
int maxFaces,
|
||||
int offset,
|
||||
int batchSize,
|
||||
@@ -112,9 +112,9 @@ abstract class IMLDataDB<T> {
|
||||
});
|
||||
|
||||
Future<List<EmbeddingVector>> getAllClipVectors();
|
||||
Future<Map<int, int>> clipIndexedFileWithVersion();
|
||||
Future<Map<T, int>> clipIndexedFileWithVersion();
|
||||
Future<int> getClipIndexedFileCount({int minimumMlVersion});
|
||||
Future<void> putClip(List<ClipEmbedding> embeddings);
|
||||
Future<void> putClip<T>(List<ClipEmbedding<T>> embeddings);
|
||||
Future<void> deleteClipEmbeddings(List<T> fileIDs);
|
||||
Future<void> deleteClipIndexes();
|
||||
}
|
||||
|
||||
@@ -72,25 +72,26 @@ class ClipVectorDB {
|
||||
_migrationDone = true;
|
||||
}
|
||||
|
||||
Future<void> insertEmbedding({
|
||||
required int fileID,
|
||||
Future<void> insertEmbedding<T>({
|
||||
required T fileID,
|
||||
required List<double> embedding,
|
||||
}) async {
|
||||
final db = await _vectorDB;
|
||||
try {
|
||||
await db.addVector(key: BigInt.from(fileID), vector: embedding);
|
||||
final id = fileID as int;
|
||||
await db.addVector(key: BigInt.from(id), vector: embedding);
|
||||
} catch (e, s) {
|
||||
_logger.severe("Error inserting embedding", e, s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> bulkInsertEmbeddings({
|
||||
required List<int> fileIDs,
|
||||
Future<void> bulkInsertEmbeddings<T>({
|
||||
required List<T> fileIDs,
|
||||
required List<Float32List> embeddings,
|
||||
}) async {
|
||||
final db = await _vectorDB;
|
||||
final bigKeys = Uint64List.fromList(fileIDs);
|
||||
final bigKeys = Uint64List.fromList(fileIDs.map((e) => e as int).toList());
|
||||
try {
|
||||
await db.bulkAddVectors(keys: bigKeys, vectors: embeddings);
|
||||
} catch (e, s) {
|
||||
@@ -279,17 +280,23 @@ class ClipVectorDB {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteIndexFile() async {
|
||||
Future<void> deleteIndexFile({bool undoMigration = false}) async {
|
||||
try {
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory();
|
||||
final String dbPath =
|
||||
join(documentsDirectory.path, _databaseName);
|
||||
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;
|
||||
|
||||
@@ -53,13 +53,13 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
static final MLDataDB instance = MLDataDB._privateConstructor();
|
||||
|
||||
static final _migrationScripts = [
|
||||
createFacesTable,
|
||||
getCreateFacesTable(false),
|
||||
createFaceClustersTable,
|
||||
createClusterPersonTable,
|
||||
createClusterSummaryTable,
|
||||
createNotPersonFeedbackTable,
|
||||
fcClusterIDIndex,
|
||||
createClipEmbeddingsTable,
|
||||
getCreateClipEmbeddingsTable(false),
|
||||
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("MLDataDB: Starting migration");
|
||||
_logger.info("$runtimeType: Starting migration");
|
||||
await migrate(asyncDBConnection, _migrationScripts);
|
||||
_logger.info(
|
||||
"MLDataDB Migration took ${stopwatch.elapsedMilliseconds} ms",
|
||||
"$runtimeType Migration took ${stopwatch.elapsedMilliseconds} ms",
|
||||
);
|
||||
stopwatch.stop();
|
||||
|
||||
@@ -360,10 +360,10 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
(element) => (element[fileIDColumn] as int) == avatarFileId,
|
||||
);
|
||||
if (row != null) {
|
||||
return mapRowToFace(row);
|
||||
return mapRowToFace<int>(row);
|
||||
}
|
||||
}
|
||||
return mapRowToFace(faceMaps.first);
|
||||
return mapRowToFace<int>(faceMaps.first);
|
||||
}
|
||||
}
|
||||
if (clusterID != null) {
|
||||
@@ -411,7 +411,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
if (maps.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return maps.map((e) => mapRowToFace(e)).toList();
|
||||
return maps.map((e) => mapRowToFace<int>(e)).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -428,7 +428,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
final result = <int, List<FaceWithoutEmbedding>>{};
|
||||
for (final map in maps) {
|
||||
final face = mapRowToFaceWithoutEmbedding(map);
|
||||
final face = mapRowToFaceWithoutEmbedding<int>(map);
|
||||
final fileID = map[fileIDColumn] as int;
|
||||
result.putIfAbsent(fileID, () => <FaceWithoutEmbedding>[]).add(face);
|
||||
}
|
||||
@@ -726,7 +726,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FaceDbInfoForClustering>> getFaceInfoForClustering({
|
||||
Future<List<FaceDbInfoForClustering<int>>> getFaceInfoForClustering({
|
||||
int maxFaces = 20000,
|
||||
int offset = 0,
|
||||
int batchSize = 10000,
|
||||
@@ -738,7 +738,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
);
|
||||
final db = await instance.asyncDB;
|
||||
|
||||
final List<FaceDbInfoForClustering> result = <FaceDbInfoForClustering>[];
|
||||
final List<FaceDbInfoForClustering<int>> result =
|
||||
<FaceDbInfoForClustering<int>>[];
|
||||
while (true) {
|
||||
// Query a batch of rows
|
||||
final List<Map<String, dynamic>> maps = await db.getAll(
|
||||
@@ -758,7 +759,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(
|
||||
final faceInfo = FaceDbInfoForClustering<int>(
|
||||
faceID: faceID,
|
||||
clusterId: faceIdToClusterId[faceID],
|
||||
embeddingBytes: map[embeddingColumn] as Uint8List,
|
||||
@@ -1135,7 +1136,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
final db = await instance.asyncDB;
|
||||
if (faces) {
|
||||
await db.execute(deleteFacesTable);
|
||||
await db.execute(createFacesTable);
|
||||
await db.execute(getCreateFacesTable(false));
|
||||
await db.execute(deleteFaceClustersTable);
|
||||
await db.execute(createFaceClustersTable);
|
||||
await db.execute(fcClusterIDIndex);
|
||||
@@ -1292,8 +1293,11 @@ 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");
|
||||
@@ -1323,14 +1327,17 @@ 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(fileIDs: fileIDs, embeddings: embeddings);
|
||||
await ClipVectorDB.instance.bulkInsertEmbeddings<int>(
|
||||
fileIDs: fileIDs, embeddings: embeddings);
|
||||
_logger.info("Inserted ${fileIDs.length} embeddings to ClipVectorDB");
|
||||
processedCount += fileIDs.length;
|
||||
offset += batchSize;
|
||||
@@ -1349,7 +1356,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, flag file created");
|
||||
_logger.info("ClipVectorDB migration done");
|
||||
} catch (e, s) {
|
||||
_logger.severe(
|
||||
"Error migrating ClipVectorDB after ${stopwatch.elapsed.inMilliseconds} ms, clearing out DB again",
|
||||
@@ -1360,6 +1367,8 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
rethrow;
|
||||
} finally {
|
||||
stopwatch.stop();
|
||||
// Make sure compute can run again
|
||||
computeController.unblockCompute(blocker: migrationKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1388,7 +1397,7 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putClip(List<ClipEmbedding> embeddings) async {
|
||||
Future<void> putClip<int>(List<ClipEmbedding<int>> embeddings) async {
|
||||
if (embeddings.isEmpty) return;
|
||||
final db = await instance.asyncDB;
|
||||
if (embeddings.length == 1) {
|
||||
@@ -1398,8 +1407,9 @@ class MLDataDB with SqlDbBase implements IMLDataDB<int> {
|
||||
);
|
||||
if (flagService.enableVectorDb &&
|
||||
await ClipVectorDB.instance.checkIfMigrationDone()) {
|
||||
final e = embeddings.first.fileID;
|
||||
await ClipVectorDB.instance.insertEmbedding(
|
||||
fileID: embeddings.first.fileID,
|
||||
fileID: e,
|
||||
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(Face face) {
|
||||
Map<String, dynamic> mapRemoteToFaceDB<T>(Face<T> face) {
|
||||
return {
|
||||
faceIDColumn: face.faceID,
|
||||
fileIDColumn: face.fileID,
|
||||
@@ -24,10 +24,10 @@ Map<String, dynamic> mapRemoteToFaceDB(Face face) {
|
||||
};
|
||||
}
|
||||
|
||||
Face mapRowToFace(Map<String, dynamic> row) {
|
||||
Face mapRowToFace<T>(Map<String, dynamic> row) {
|
||||
return Face(
|
||||
row[faceIDColumn] as String,
|
||||
row[fileIDColumn] as int,
|
||||
row[fileIDColumn] as T,
|
||||
EVector.fromBuffer(row[embeddingColumn] as List<int>).values,
|
||||
row[faceScore] as double,
|
||||
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
|
||||
@@ -39,10 +39,12 @@ Face mapRowToFace(Map<String, dynamic> row) {
|
||||
);
|
||||
}
|
||||
|
||||
FaceWithoutEmbedding mapRowToFaceWithoutEmbedding(Map<String, dynamic> row) {
|
||||
return FaceWithoutEmbedding(
|
||||
FaceWithoutEmbedding<T> mapRowToFaceWithoutEmbedding<T>(
|
||||
Map<String, dynamic> row,
|
||||
) {
|
||||
return FaceWithoutEmbedding<T>(
|
||||
row[faceIDColumn] as String,
|
||||
row[fileIDColumn] as int,
|
||||
row[fileIDColumn] as T,
|
||||
row[faceScore] as double,
|
||||
Detection.fromJson(json.decode(row[faceDetectionColumn] as String)),
|
||||
row[faceBlur] as double,
|
||||
|
||||
1266
mobile/apps/photos/lib/db/ml/offlinedb.dart
Normal file
1266
mobile/apps/photos/lib/db/ml/offlinedb.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,9 @@ const personIdColumn = 'person_id';
|
||||
const clusterIDColumn = 'cluster_id';
|
||||
const personOrClusterIdColumn = 'person_or_cluster_id';
|
||||
|
||||
const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
String getCreateFacesTable(bool isOfflineDB) {
|
||||
return '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
$fileIDColumn ${isOfflineDB ? 'TEXT' : 'INTEGER'} NOT NULL,
|
||||
$faceIDColumn TEXT NOT NULL UNIQUE,
|
||||
$faceDetectionColumn TEXT NOT NULL,
|
||||
$embeddingColumn BLOB NOT NULL,
|
||||
@@ -31,6 +32,7 @@ const createFacesTable = '''CREATE TABLE IF NOT EXISTS $facesTable (
|
||||
PRIMARY KEY($fileIDColumn, $faceIDColumn)
|
||||
);
|
||||
''';
|
||||
}
|
||||
|
||||
const deleteFacesTable = 'DELETE FROM $facesTable';
|
||||
// End of Faces Table Fields & Schema Queries
|
||||
@@ -98,18 +100,20 @@ const deleteNotPersonFeedbackTable = 'DELETE FROM $notPersonFeedback';
|
||||
// ## CLIP EMBEDDINGS TABLE
|
||||
const clipTable = 'clip';
|
||||
|
||||
const createClipEmbeddingsTable = '''
|
||||
CREATE TABLE IF NOT EXISTS $clipTable (
|
||||
$fileIDColumn INTEGER NOT NULL,
|
||||
String getCreateClipEmbeddingsTable(bool isOfflineDB) {
|
||||
return '''CREATE TABLE IF NOT EXISTS $clipTable (
|
||||
$fileIDColumn ${isOfflineDB ? 'TEXT' : '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,
|
||||
|
||||
193
mobile/apps/photos/lib/db/remote/db.dart
Normal file
193
mobile/apps/photos/lib/db/remote/db.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
114
mobile/apps/photos/lib/db/remote/mappers.dart
Normal file
114
mobile/apps/photos/lib/db/remote/mappers.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
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;
|
||||
}
|
||||
235
mobile/apps/photos/lib/db/remote/schema.dart
Normal file
235
mobile/apps/photos/lib/db/remote/schema.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
288
mobile/apps/photos/lib/db/remote/table/collection_files.dart
Normal file
288
mobile/apps/photos/lib/db/remote/table/collection_files.dart
Normal file
@@ -0,0 +1,288 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
156
mobile/apps/photos/lib/db/remote/table/files_table.dart
Normal file
156
mobile/apps/photos/lib/db/remote/table/files_table.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
119
mobile/apps/photos/lib/db/remote/table/mapping_table.dart
Normal file
119
mobile/apps/photos/lib/db/remote/table/mapping_table.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
49
mobile/apps/photos/lib/db/remote/table/trash.dart
Normal file
49
mobile/apps/photos/lib/db/remote/table/trash.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class FileCaptionUpdatedEvent extends Event {
|
||||
final int fileGeneratedID;
|
||||
final String fileTag;
|
||||
|
||||
FileCaptionUpdatedEvent(this.fileGeneratedID);
|
||||
FileCaptionUpdatedEvent(this.fileTag);
|
||||
}
|
||||
|
||||
10
mobile/apps/photos/lib/events/v1/LocalAssetChangedEvent.dart
Normal file
10
mobile/apps/photos/lib/events/v1/LocalAssetChangedEvent.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import "package:photos/events/event.dart";
|
||||
|
||||
class LocalAssetChangedEvent extends Event {
|
||||
final String source;
|
||||
|
||||
LocalAssetChangedEvent(this.source);
|
||||
|
||||
@override
|
||||
String get reason => '$runtimeType{"via": $source}';
|
||||
}
|
||||
@@ -39,20 +39,26 @@ class EnteWatch extends Stopwatch {
|
||||
class TimeLogger {
|
||||
final String context;
|
||||
final int logThreshold;
|
||||
DateTime _start;
|
||||
TimeLogger({this.context = "TLog", this.logThreshold = 5})
|
||||
final DateTime _start;
|
||||
DateTime _toStringStart = DateTime.now();
|
||||
TimeLogger({this.context = "TLog:", this.logThreshold = 5})
|
||||
: _start = DateTime.now();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
final int diff = DateTime.now().difference(_toStringStart).inMilliseconds;
|
||||
late String res;
|
||||
if (diff > logThreshold) {
|
||||
res = "[$context: $diff ms]";
|
||||
res = "[$context$diff ms]";
|
||||
} else {
|
||||
res = "[]";
|
||||
}
|
||||
_start = DateTime.now();
|
||||
_toStringStart = DateTime.now();
|
||||
return res;
|
||||
}
|
||||
|
||||
String get elapsed {
|
||||
final int diff = DateTime.now().difference(_start).inMilliseconds;
|
||||
return "[$context$diff ms]";
|
||||
}
|
||||
}
|
||||
|
||||
90
mobile/apps/photos/lib/image/in_memory_image_cache.dart
Normal file
90
mobile/apps/photos/lib/image/in_memory_image_cache.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
193
mobile/apps/photos/lib/image/provider/local_thumbnail_img.dart
Normal file
193
mobile/apps/photos/lib/image/provider/local_thumbnail_img.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
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 {ذكريتان} few {{formattedCount} ذكريات} many {{formattedCount} ذكرى} other {{formattedCount} ذكرى}}",
|
||||
"memoryCount": "{count, plural, =0 {لا توجد ذكريات} one {ذكرى واحدة} two {ذكريتان} 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 {حذف عنصرين} few {حذف {count} عناصر} many {حذف {count} عنصرًا} other {حذف {count} عنصرًا}}",
|
||||
"deleteItemCount": "{count, plural, =1 {حذف عنصر واحد} two {حذف عنصرين} 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 {قبل سنتين} few {قبل {count} سنوات} many {قبل {count} سنة} other {قبل {count} سنة}}",
|
||||
"yearsAgo": "{count, plural, one {قبل سنة} two {قبل سنتين} other {قبل {count} سنة}}",
|
||||
"backupSettings": "إعدادات النسخ الاحتياطي",
|
||||
"backupStatus": "حالة النسخ الاحتياطي",
|
||||
"backupStatusDescription": "ستظهر العناصر التي تم نسخها احتياطيًا هنا",
|
||||
@@ -543,7 +543,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "تذكر أيضًا إفراغ \"سلة المهملات\" لاستعادة المساحة المحررة.",
|
||||
"sparkleSuccess": "✨ نجاح",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} few {{count} ملفات مكررة} many {{count} ملفًا مكررًا} other {{count} ملفًا مكررًا}}، مما وفر {storageSaved}!",
|
||||
"duplicateFileCountWithStorageSaved": "لقد قمت بتنظيف {count, plural, one {ملف مكرر واحد} two {ملفين مكررين} 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 {نقل عنصرين} few {نقل {count} عناصر} many {نقل {count} عنصرًا} other {نقل {count} عنصرًا}}",
|
||||
"moveItem": "{count, plural, =1 {نقل عنصر} two {نقل عنصرين} other {نقل {count} عنصرًا}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} few {إضافة {count} عناصر} many {إضافة {count} عنصرًا} other {إضافة {count} عنصرًا}}",
|
||||
"addItem": "{count, plural, =1 {إضافة عنصر} two {إضافة عنصرين} 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 {يومان} few {{count} أيام} many {{count} يومًا} other {{count} يومًا}}",
|
||||
"trashDaysLeft": "{count, plural, =0 {قريبًا} =1 {يوم واحد} two {يومان} 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}} few {يمكن حذفها من الجهاز لتحرير {formattedSize}} many {يمكن حذفها من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} في هذا الألبوم تم نسخه احتياطيًا بأمان",
|
||||
"freeUpSpaceSaving": "{count, plural, =1 {يمكن حذفه من الجهاز لتحرير {formattedSize}} two {يمكن حذفهما من الجهاز لتحرير {formattedSize}} other {يمكن حذفها من الجهاز لتحرير {formattedSize}}}",
|
||||
"filesBackedUpInAlbum": "{count, plural, one {ملف واحد} two {ملفان} 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 {ملفان} few {{formattedNumber} ملفات} many {{formattedNumber} ملفًا} other {{formattedNumber} ملفًا}} على هذا الجهاز تم نسخه احتياطيًا بأمان",
|
||||
"filesBackedUpFromDevice": "{count, plural, one {ملف واحد} two {ملفان} 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, one{{count} النتائج التي تم العثور عليها} other{{count} النتائج التي تم العثور عليها}}",
|
||||
"searchResultCount": "{count, plural, 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 {إضافة مشاهدين} few {إضافة {count} مشاهدين} many {إضافة {count} مشاهدًا} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} few {إضافة {count} متعاونين} many {إضافة {count} متعاونًا} other {إضافة {count} متعاونًا}}",
|
||||
"addViewers": "{count, plural, =0 {إضافة مشاهد} =1 {إضافة مشاهد} two {إضافة مشاهدين} other {إضافة {count} مشاهدًا}}",
|
||||
"addCollaborators": "{count, plural, =0 {إضافة متعاون} =1 {إضافة متعاون} two {إضافة متعاونين} other {إضافة {count} متعاونًا}}",
|
||||
"longPressAnEmailToVerifyEndToEndEncryption": "اضغط مطولاً على بريد إلكتروني للتحقق من التشفير من طرف إلى طرف.",
|
||||
"developerSettingsWarning": "هل أنت متأكد من رغبتك في تعديل إعدادات المطور؟",
|
||||
"developerSettings": "إعدادات المطور",
|
||||
@@ -1403,7 +1403,7 @@
|
||||
"enableMachineLearningBanner": "قم بتمكين تعلم الآلة للبحث السحري والتعرف على الوجوه.",
|
||||
"searchDiscoverEmptySection": "سيتم عرض الصور هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"searchPersonsEmptySection": "سيتم عرض الأشخاص هنا بمجرد اكتمال المعالجة والمزامنة.",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} few {تمت إضافة {count} مشاهدين} many {تمت إضافة {count} مشاهدًا} other {تمت إضافة {count} مشاهدًا}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 مشاهدين} =1 {تمت إضافة مشاهد واحد} two {تمت إضافة مشاهدين} 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 {تمت إضافة متعاونين} few {تمت إضافة {count} متعاونين} many {تمت إضافة {count} متعاونًا} other {تمت إضافة {count} متعاونًا}}",
|
||||
"collaboratorsSuccessfullyAdded": "{count, plural, =0 {تمت إضافة 0 متعاونين} =1 {تمت إضافة متعاون واحد} two {تمت إضافة متعاونين} other {تمت إضافة {count} متعاونًا}}",
|
||||
"@collaboratorsSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "قيد التشغيل حاليًا",
|
||||
"ignored": "تم التجاهل",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"photosCount": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,7 +1686,7 @@
|
||||
"moveSelectedPhotosToOneDate": "نقل الصور المحددة إلى تاريخ واحد",
|
||||
"shiftDatesAndTime": "تغيير التواريخ والوقت",
|
||||
"photosKeepRelativeTimeDifference": "تحتفظ الصور بالفرق الزمني النسبي",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} few {{count} صور} many {{count} صورة} other {{count} صورة}}",
|
||||
"photocountPhotos": "{count, plural, =0 {لا توجد صور} =1 {صورة واحدة} two {صورتان} other {{count} صورة}}",
|
||||
"@photocountPhotos": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1700,7 +1700,7 @@
|
||||
"selectedItemsWillBeRemovedFromThisPerson": "سيتم إزالة العناصر المحددة من هذا الشخص، ولكن لن يتم حذفها من مكتبتك.",
|
||||
"throughTheYears": "{dateFormat} عبر السنين",
|
||||
"thisWeekThroughTheYears": "هذا الأسبوع عبر السنين",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} few {هذا الأسبوع، قبل {count} سنوات} many {هذا الأسبوع، قبل {count} سنة} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {هذا الأسبوع، قبل سنة} two {هذا الأسبوع، قبل سنتين} other {هذا الأسبوع، قبل {count} سنة}}",
|
||||
"youAndThem": "أنت و {name}",
|
||||
"admiringThem": "الإعجاب بـ {name}",
|
||||
"embracingThem": "معانقة {name}",
|
||||
@@ -1821,4 +1821,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"faq": "Často kladené dotazy",
|
||||
"faq": "Často kladené dotazy (FAQ)",
|
||||
"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} few{{formattedCount} vzpomínky} other{{formattedCount} vzpomínek}}",
|
||||
"memoryCount": "{count, plural, =0{žádné vzpomínky} one{{formattedCount} vzpomínka} 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} 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}}",
|
||||
"itemCount": "{count, plural, one{{count} položka} other{{count} položek}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Smazat {count} položku} 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í emailem",
|
||||
"emailVerificationToggle": "Ověření pomocí e-mailu",
|
||||
"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} few{{count} duplicitní soubory} other{{count} duplicitních souborů}}, a ušetřili jste {storageSaved}!",
|
||||
"duplicateFileCountWithStorageSaved": "Vyčistili jste {count, plural, one{{count} duplicitní soubor} 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": "Podle systému",
|
||||
"systemTheme": "Systém",
|
||||
"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} few{{count} dny} other{{count} dní}}",
|
||||
"trashDaysLeft": "{count, plural, =0{Brzy} =1{1 den} 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} 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": "{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": {
|
||||
"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} 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": "{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": {
|
||||
"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} few{Nalezeny {count} výsledky} other{Nalezeno {count} výsledků}}",
|
||||
"searchResultCount": "{count, plural, one{Nalezen {count} výsledek} 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} few {Přidáni {count} pozorovatelé} other {Přidáno {count} pozorovatelů}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =0 {Přidáno 0 pozorovatelů} =1 {Přidán 1 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} few {Přidáni {count} spolupracovníci} other {Přidáno {count} spolupracovníků}}",
|
||||
"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": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1488,7 @@
|
||||
},
|
||||
"currentlyRunning": "aktuálně běží",
|
||||
"ignored": "ignorováno",
|
||||
"photosCount": "{count, plural, =0 {0 fotografií} =1 {1 fotografie} few {{count} fotografie} other {{count} fotografií}}",
|
||||
"photosCount": "{count, plural, =0 {0 fotografií} =1 {1 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} few {{count} fotografie} other {{count} fotografií}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Žádné fotografie} =1 {1 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} few {Tento týden, {count} roky nazpět} other {Tento týden, {count} let nazpět}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {Tento týden, {count} rok nazpět} other {Tento týden, {count} let nazpět}}",
|
||||
"youAndThem": "Vy a {name}",
|
||||
"admiringThem": "Obdiv k {name}",
|
||||
"embracingThem": "Objímání {name}",
|
||||
@@ -1827,5 +1827,98 @@
|
||||
"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,16 +207,6 @@
|
||||
"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,5 +1827,111 @@
|
||||
"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"
|
||||
}
|
||||
@@ -1843,14 +1843,6 @@
|
||||
"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",
|
||||
@@ -1933,11 +1925,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",
|
||||
@@ -1946,5 +1938,6 @@
|
||||
"nothingHereTryAnotherFilter": "Nothing here, try another filter! 👀",
|
||||
"related": "Related",
|
||||
"hoorayyyy": "Hoorayyyy!",
|
||||
"nothingToTidyUpHere": "Nothing to tidy up here"
|
||||
"nothingToTidyUpHere": "Nothing to tidy up here",
|
||||
"deletingDash": "Deleting - "
|
||||
}
|
||||
|
||||
@@ -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 videos al álbum compartido.",
|
||||
"collaboratorsCanAddPhotosAndVideosToTheSharedAlbum": "Colaboradores pueden añadir fotos y vídeos 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 videos en calidad original.\n\nhttps://ente.io",
|
||||
"shareTextRecommendUsingEnte": "Descarga Ente para que podamos compartir fácilmente fotos y vídeos 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 video",
|
||||
"toHideAPhotoOrVideo": "Para ocultar una foto o vídeo",
|
||||
"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, \"Videos\", \".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, \"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\")",
|
||||
"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 video",
|
||||
"pressAndHoldToPlayVideoDetailed": "Mantén pulsada la imagen para reproducir el video",
|
||||
"pressAndHoldToPlayVideo": "Presiona y mantén presionado para reproducir el vídeo",
|
||||
"pressAndHoldToPlayVideoDetailed": "Mantén pulsada la imagen para reproducir el vídeo",
|
||||
"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 video...",
|
||||
"decryptingVideo": "Descifrando vídeo...",
|
||||
"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": "Videos remotos",
|
||||
"remoteVideos": "Vídeos 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": "Activar Mapas",
|
||||
"enableMaps": "Habilitar 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 video",
|
||||
"videoInfo": "Información de vídeo",
|
||||
"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 video",
|
||||
"failedToPlayVideo": "Error al reproducir el vídeo",
|
||||
"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 video? Mantén pulsado aquí para probar un reproductor diferente.",
|
||||
"useDifferentPlayerInfo": "¿Tienes problemas para reproducir este vídeo? 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 streaming",
|
||||
"videoStreaming": "Vídeos en transmisión",
|
||||
"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, one {}=1{Añadido con éxito a 1 álbum} other{Añadido con éxito a {count} álbumes}}",
|
||||
"addedToAlbums": "{count, plural, =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,5 +1827,111 @@
|
||||
"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, 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}}}",
|
||||
"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}}}",
|
||||
"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, one {}=1 {l'} other {les}} accéder sur Ente tant que vous avez un abonnement actif",
|
||||
"freeUpAccessPostDelete": "Vous pouvez toujours {count, plural, =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, 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}}",
|
||||
"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}}",
|
||||
"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, one {}=0 {0 spectateur ajouté} =1 {Un spectateur ajouté} other {{count} spectateurs ajoutés}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =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, one {}=1{Ajouté avec succès à 1 album} other{Ajouté avec succès à {count} albums}}",
|
||||
"addedToAlbums": "{count, plural, =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,5 +1827,103 @@
|
||||
"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} פריט} two {{count} פריטים} many {{count} פריטים} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {count} פריט} two {מחק {count} פריטים} other {מחק {count} פריטים}}",
|
||||
"itemCount": "{count, plural, one{{count} פריט} other{{count} פריטים}}",
|
||||
"deleteItemCount": "{count, plural, =1 {מחק {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} שנה} two {לפני {count} שנים} many {לפני {count} שנים} other{לפני {count} שנים}}",
|
||||
"yearsAgo": "{count, plural, one{לפני {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, one{{count} elem} other{{count} elem}}",
|
||||
"itemCount": "{count, plural, 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,6 +189,7 @@
|
||||
"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",
|
||||
@@ -355,6 +356,7 @@
|
||||
"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",
|
||||
@@ -370,6 +372,21 @@
|
||||
"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",
|
||||
@@ -419,6 +436,7 @@
|
||||
"discover_receipts": "Tanda Terima",
|
||||
"discover_notes": "Catatan",
|
||||
"discover_memes": "Meme",
|
||||
"discover_visiting_cards": "Kartu Nama",
|
||||
"discover_babies": "Bayi",
|
||||
"discover_pets": "Hewan",
|
||||
"discover_selfies": "Swafoto",
|
||||
@@ -427,6 +445,7 @@
|
||||
"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...",
|
||||
@@ -442,6 +461,21 @@
|
||||
"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",
|
||||
@@ -492,6 +526,7 @@
|
||||
"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",
|
||||
@@ -658,6 +693,7 @@
|
||||
"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.",
|
||||
@@ -714,6 +750,7 @@
|
||||
"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": {
|
||||
@@ -731,6 +768,7 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -740,15 +778,30 @@
|
||||
"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",
|
||||
@@ -756,18 +809,33 @@
|
||||
"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",
|
||||
@@ -782,13 +850,16 @@
|
||||
"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",
|
||||
@@ -796,6 +867,7 @@
|
||||
"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...",
|
||||
@@ -804,6 +876,7 @@
|
||||
"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",
|
||||
@@ -814,12 +887,19 @@
|
||||
"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",
|
||||
@@ -850,11 +930,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@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",
|
||||
@@ -865,12 +958,35 @@
|
||||
"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",
|
||||
@@ -885,6 +1001,8 @@
|
||||
"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.",
|
||||
@@ -894,12 +1012,17 @@
|
||||
"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",
|
||||
@@ -1111,4 +1234,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, one {}=1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"moveItem": "{count, plural, =1 {Sposta elemento} other {Sposta elementi}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, one {}=1 {Aggiungi elemento} other {Aggiungi elementi}}",
|
||||
"addItem": "{count, plural, =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, one {}=1 {Può essere cancellato per liberare {formattedSize}} other {Possono essere cancellati per liberare {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =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, one {}=0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, one {}=0 {Aggiungi collaboratore} =1 {Aggiungi collaboratore} other {Aggiungi collaboratori}}",
|
||||
"addViewers": "{count, plural, =0 {Aggiungi visualizzatore} =1 {Add viewer} other {Aggiungi visualizzatori}}",
|
||||
"addCollaborators": "{count, plural, =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, one {}=0 {Added 0 visualizzatori} =1 {Added 1 visualizzatore} other {Added {count} visualizzatori}}",
|
||||
"viewersSuccessfullyAdded": "{count, plural, =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, one {}=0 {0 foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photosCount": "{count, plural, =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, one {}=0 {Nessuna foto} =1 {1 foto} other {{count} foto}}",
|
||||
"photocountPhotos": "{count, plural, =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, one {}=1 {Questa settimana, {count} anno fa} other {Questa settimana, {count} anni fa}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =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, one{{count} 年前} other{{count} 年前}}",
|
||||
"yearsAgo": "{count, plural, other{{count} 年前}}",
|
||||
"backupSettings": "バックアップ設定",
|
||||
"backupStatus": "バックアップの状態",
|
||||
"backupStatusDescription": "バックアップされたアイテムがここに表示されます",
|
||||
@@ -527,7 +527,7 @@
|
||||
},
|
||||
"remindToEmptyEnteTrash": "「ゴミ箱」も空にするとアカウントのストレージが解放されます",
|
||||
"sparkleSuccess": "成功✨",
|
||||
"duplicateFileCountWithStorageSaved": "お掃除しました {count, plural, one{{count} 個の重複ファイル} other{{count} 個の重複ファイル}}, ({storageSaved}が開放されます!)",
|
||||
"duplicateFileCountWithStorageSaved": "お掃除しました {count, plural, 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, one{{count} 個の結果} other{{count} 個の結果}}",
|
||||
"searchResultCount": "{count, plural, 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,11 +794,7 @@
|
||||
"share": "Bendrinti",
|
||||
"unhideToAlbum": "Rodyti į albumą",
|
||||
"restoreToAlbum": "Atkurti į albumą",
|
||||
"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": "{count, plural, =1 {Pridėti elementą} other {Pridėti elementų}}",
|
||||
"@addItem": {
|
||||
"description": "Page title while adding one or more items to album"
|
||||
},
|
||||
@@ -900,7 +896,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} 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": "{count, plural, one {{formattedNumber} failas šiame albume saugiai sukurta atsarginė kopija} 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": {
|
||||
@@ -915,7 +911,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "{count, plural, one {{formattedNumber} failas šiame įrenginyje saugiai sukurta atsarginė kopija} 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": {
|
||||
@@ -1403,7 +1399,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, 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": "{count, plural, =0 {Įtraukta 0 žiūrėtojų} =1 {Įtrauktas 1 žiūrėtojas} other {Įtraukta {count} žiūrėtojų}}",
|
||||
"@viewersSuccessfullyAdded": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1488,7 +1484,7 @@
|
||||
},
|
||||
"currentlyRunning": "šiuo metu vykdoma",
|
||||
"ignored": "ignoruota",
|
||||
"photosCount": "{count, plural, one {{count} nuotrauka} few {{count} nuotraukos} many {{count} nuotraukos} =0 {0 nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"photosCount": "{count, plural, =0 {0 nuotraukų} =1 {1 nuotrauka} other {{count} nuotraukų}}",
|
||||
"@photosCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
@@ -1686,21 +1682,11 @@
|
||||
"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}",
|
||||
@@ -1818,5 +1804,16 @@
|
||||
"brushColor": "Teptuko spalva",
|
||||
"font": "Šriftas",
|
||||
"background": "Fonas",
|
||||
"align": "Lygiuoti"
|
||||
}
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Toon herinneringen",
|
||||
"yearsAgo": "{count, plural, one{{count} jaar geleden} other{{count} jaar geleden}}",
|
||||
"yearsAgo": "{count, plural, 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} one{{formattedCount} minne} other{{formattedCount} minne}}",
|
||||
"memoryCount": "{count, plural, =0{ingen 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, one{{count} år siden} other{{count} år siden}}",
|
||||
"yearsAgo": "{count, plural, 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} few{{formattedCount} wspomnienia} many{{formattedCount} wspomnień} other{{formattedCount} wspomnień}}",
|
||||
"memoryCount": "{count, plural, =0{brak wspomnień} one{{formattedCount} wspomnienie} 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} 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}}",
|
||||
"itemCount": "{count, plural, one{{count} element} other{{count} elementu}}",
|
||||
"deleteItemCount": "{count, plural, =1 {Usuń {count} element} 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} few {{count} lata temu} many {{count} lat temu} other{{count} lata temu}}",
|
||||
"yearsAgo": "{count, plural, one{{count} rok 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} few {Przenieś elementy} many {Przenieś elementów} other {Przenieś elementów}}",
|
||||
"moveItem": "{count, plural, =1 {Przenieś element} other {Przenieś elementów}}",
|
||||
"@moveItem": {
|
||||
"description": "Page title while moving one or more items to an album"
|
||||
},
|
||||
"addItem": "{count, plural, =1 {Dodaj element} few {Dodaj elementy} many {Dodaj elementów} other {Dodaj elementów}}",
|
||||
"addItem": "{count, plural, =1 {Dodaj element} 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ń} few {{count} dni} other{{count} dni}}",
|
||||
"trashDaysLeft": "{count, plural, =0 {Wkrótce} =1{1 dzień} 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}} many {Może być usuniętych 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}} 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} few {Znaleziono {count} wyniki} other{Znaleziono {count} wyników}}",
|
||||
"searchResultCount": "{count, plural, one{Znaleziono {count} wynik} 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} few {{count} zdjęcia} many {{count} zdjęć} other {{count} zdjęć}}",
|
||||
"photosCount": "{count, plural, =0 {0 zdjęć} =1 {1 zdjęcie} 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} few {{count} zdjęcia} many {{count} zdjęć} other {{count} zdjęć}}",
|
||||
"photocountPhotos": "{count, plural, =0 {Brak zdjęć} =1 {1 zdjęcie} 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} few {W tym tygodniu, {count} lata temu} many {W tym tygodniu, {count} lat temu} other {W tym tygodniu, {count} lat temu}}",
|
||||
"thisWeekXYearsAgo": "{count, plural, =1 {W tym tygodniu, {count} rok temu} other {W tym tygodniu, {count} lat temu}}",
|
||||
"youAndThem": "Ty i {name}",
|
||||
"admiringThem": "Podziwianie {name}",
|
||||
"embracingThem": "Obejmowanie {name}",
|
||||
@@ -1828,4 +1828,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1220,15 +1220,17 @@
|
||||
"@findThemQuickly": {
|
||||
"description": "Subtitle to indicate that the user can find people quickly by name"
|
||||
},
|
||||
"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}",
|
||||
"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}",
|
||||
"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, one {}=0 {Compartilhe com pessoas específicas} =1 {Compartilhado com 1 pessoa} other {Compartilhado com {numberOfPeople} pessoas}}",
|
||||
"shareWithPeopleSectionTitle": "{numberOfPeople, plural, =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, one {}=1 {Pode eliminá-lo do aparelho para esvaziar {formattedSize}} other {Pode eliminá-los do aparelho para esvaziar {formattedSize}}}",
|
||||
"freeUpSpaceSaving": "{count, plural, =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} few {{count} articole} other{{count} de articole}}",
|
||||
"itemCount": "{count, plural, one{{count} articol} 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} few {acum {count} ani} other{acum {count} de ani}}",
|
||||
"yearsAgo": "{count, plural, one{acum {count} an} 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ă} few {{count} dubluri} other{{count} de dubluri}}, economisind ({storageSaved}!)",
|
||||
"duplicateFileCountWithStorageSaved": "Ați curățat {count, plural, one{{count} dublură} 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ță} 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": "{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": {
|
||||
"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ță} 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": "{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": {
|
||||
"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} few {{count} rezultate găsite} other{{count} de rezultate găsite}}",
|
||||
"searchResultCount": "{count, plural, one{{count} rezultat găsit} 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} год назад} few{{count} года назад} other{{count} лет назад}}",
|
||||
"yearsAgo": "{count, plural, one{{count} год назад} other{{count} лет назад}}",
|
||||
"backupSettings": "Настройки резервного копирования",
|
||||
"backupStatus": "Статус резервного копирования",
|
||||
"backupStatusDescription": "Элементы, сохранённые в резервной копии, появятся здесь",
|
||||
@@ -1786,4 +1786,4 @@
|
||||
"day": "День",
|
||||
"filter": "Фильтр",
|
||||
"font": "Шрифт"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,7 +445,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "Прикажи успомене",
|
||||
"yearsAgo": "{count, plural, one{{count} година уназад} few {{count} године уназад} other{{count} година уназад}}",
|
||||
"yearsAgo": "{count, plural, other{{count} година уназад}}",
|
||||
"backupStatus": "Статус резервних копија",
|
||||
"backupOverMobileData": "Копирај користећи мобилни интернет",
|
||||
"backupVideos": "Копирај видео снимке",
|
||||
@@ -496,7 +496,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"duplicateFileCountWithStorageSaved": "Обрисали сте {count, plural, one{{count} дупликат} few {{count} дупликата} 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",
|
||||
@@ -921,4 +921,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@
|
||||
"selectAll": "Markera allt",
|
||||
"skip": "Hoppa över",
|
||||
"updatingFolderSelection": "Uppdaterar mappval...",
|
||||
"itemCount": "{count, plural, one{{count} objekt} other{{count} objekt}}",
|
||||
"itemCount": "{count, plural, 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, one{{count} år sedan} other{{count} år sedan}}",
|
||||
"yearsAgo": "{count, plural, 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, one{{count} resultat hittades} other{{count} resultat hittades}}",
|
||||
"searchResultCount": "{count, plural, 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} one{{formattedCount} anı} other{{formattedCount} anı}}",
|
||||
"memoryCount": "{count, plural, =0{hiç anı yok} 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, one{{count} yıl önce} other{{count} yıl önce}}",
|
||||
"yearsAgo": "{count, plural, 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, one{{count} yıl önce} other{{count} yıl önce}}",
|
||||
"searchResultCount": "{count, plural, 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} елемент} few {{count} елементи} many {{count} елементів} other{{count} елементів}}",
|
||||
"itemCount": "{count, plural, one{{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} результат} few {Знайдено {count} результати} many {Знайдено {count} результатів} 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": {
|
||||
@@ -1510,4 +1510,4 @@
|
||||
"legacyInvite": "{email} запросив вас стати довіреною особою",
|
||||
"authToManageLegacy": "Авторизуйтесь, щоби керувати довіреними контактами",
|
||||
"useDifferentPlayerInfo": "Виникли проблеми з відтворенням цього відео? Натисніть і утримуйте тут, щоб спробувати інший плеєр."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1827,5 +1827,117 @@
|
||||
"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, one{{count} 个项目} other{{count} 个项目}}",
|
||||
"itemCount": "{count, plural, other{{count} 个项目}}",
|
||||
"deleteItemCount": "{count, plural, =1 {删除 {count} 个项目} other {删除 {count} 个项目}}",
|
||||
"duplicateItemsGroup": "{count} 个文件,每个文件 {formattedSize}",
|
||||
"@duplicateItemsGroup": {
|
||||
@@ -477,7 +477,7 @@
|
||||
}
|
||||
},
|
||||
"showMemories": "显示回忆",
|
||||
"yearsAgo": "{count, plural, one{{count} 年前} other{{count} 年前}}",
|
||||
"yearsAgo": "{count, plural, other{{count} 年前}}",
|
||||
"backupSettings": "备份设置",
|
||||
"backupStatus": "备份状态",
|
||||
"backupStatusDescription": "已备份的项目将显示在此处",
|
||||
@@ -1827,5 +1827,111 @@
|
||||
"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,6 +2,29 @@ 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);
|
||||
}
|
||||
@@ -12,12 +35,12 @@ Locale? autoDetectedLocale;
|
||||
Locale localResolutionCallBack(deviceLocales, supportedLocales) {
|
||||
_onDeviceLocales = deviceLocales;
|
||||
final Set<String> languageSupport = {};
|
||||
for (Locale supportedLocale in AppLocalizations.supportedLocales) {
|
||||
for (Locale supportedLocale in appSupportedLocales) {
|
||||
languageSupport.add(supportedLocale.languageCode);
|
||||
}
|
||||
for (Locale locale in deviceLocales) {
|
||||
// check if exact local is supported, if yes, return it
|
||||
if (AppLocalizations.supportedLocales.contains(locale)) {
|
||||
if (appSupportedLocales.contains(locale)) {
|
||||
autoDetectedLocale = locale;
|
||||
return locale;
|
||||
}
|
||||
@@ -67,7 +90,7 @@ Future<Locale?> getLocale({
|
||||
} else {
|
||||
savedLocale = Locale(savedValue);
|
||||
}
|
||||
if (AppLocalizations.supportedLocales.contains(savedLocale)) {
|
||||
if (appSupportedLocales.contains(savedLocale)) {
|
||||
return savedLocale;
|
||||
}
|
||||
}
|
||||
@@ -81,7 +104,7 @@ Future<Locale?> getLocale({
|
||||
}
|
||||
|
||||
Future<void> setLocale(Locale locale) async {
|
||||
if (!AppLocalizations.supportedLocales.contains(locale)) {
|
||||
if (!appSupportedLocales.contains(locale)) {
|
||||
throw Exception('Locale $locale is not supported by the app');
|
||||
}
|
||||
final StringBuffer out = StringBuffer(locale.languageCode);
|
||||
|
||||
3
mobile/apps/photos/lib/log/devlog.dart
Normal file
3
mobile/apps/photos/lib/log/devlog.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import "dart:developer";
|
||||
|
||||
var devLog = log;
|
||||
@@ -25,12 +25,14 @@ import "package:photos/db/ml/db.dart";
|
||||
import 'package:photos/ente_theme_data.dart';
|
||||
import "package:photos/extensions/stop_watch.dart";
|
||||
import "package:photos/l10n/l10n.dart";
|
||||
import 'package:photos/module/upload/service/file_uploader.dart';
|
||||
import "package:photos/service_locator.dart";
|
||||
import "package:photos/services/account/user_service.dart";
|
||||
import 'package:photos/services/app_lifecycle_service.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/favorites_service.dart';
|
||||
import 'package:photos/services/home_widget_service.dart';
|
||||
import "package:photos/services/local/import/local_import.dart";
|
||||
import 'package:photos/services/local_file_update_service.dart';
|
||||
import "package:photos/services/machine_learning/face_ml/person/person_service.dart";
|
||||
import 'package:photos/services/machine_learning/ml_service.dart';
|
||||
@@ -38,7 +40,6 @@ import 'package:photos/services/machine_learning/semantic_search/semantic_search
|
||||
import "package:photos/services/notification_service.dart";
|
||||
import 'package:photos/services/push_service.dart';
|
||||
import 'package:photos/services/search_service.dart';
|
||||
import 'package:photos/services/sync/local_sync_service.dart';
|
||||
import 'package:photos/services/sync/remote_sync_service.dart';
|
||||
import "package:photos/services/sync/sync_service.dart";
|
||||
import "package:photos/services/video_preview_service.dart";
|
||||
@@ -47,7 +48,6 @@ import "package:photos/src/rust/frb_generated.dart";
|
||||
import 'package:photos/ui/tools/app_lock.dart';
|
||||
import 'package:photos/ui/tools/lock_screen.dart';
|
||||
import "package:photos/utils/email_util.dart";
|
||||
import 'package:photos/utils/file_uploader.dart';
|
||||
import "package:photos/utils/lock_screen_settings.dart";
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -167,7 +167,7 @@ Future<void> _runMinimally(String taskId, TimeLogger tlog) async {
|
||||
// Upload & Sync Related
|
||||
await FileUploader.instance.init(prefs, true);
|
||||
LocalFileUpdateService.instance.init(prefs);
|
||||
await LocalSyncService.instance.init(prefs);
|
||||
await LocalImportService.instance.init(prefs);
|
||||
RemoteSyncService.instance.init(prefs);
|
||||
await SyncService.instance.init(prefs);
|
||||
|
||||
@@ -258,7 +258,7 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
|
||||
_logger.info("FileUploader init done $tlog");
|
||||
|
||||
_logger.info("LocalSyncService init $tlog");
|
||||
await LocalSyncService.instance.init(preferences);
|
||||
await LocalImportService.instance.init(preferences);
|
||||
_logger.info("LocalSyncService init done $tlog");
|
||||
|
||||
RemoteSyncService.instance.init(preferences);
|
||||
@@ -349,9 +349,9 @@ Future runWithLogs(Function() function, {String prefix = ""}) async {
|
||||
body: function,
|
||||
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
|
||||
maxLogFiles: 5,
|
||||
sentryDsn: kDebugMode ? sentryDebugDSN : sentryDSN,
|
||||
sentryDsn: kDebugMode ? null : sentryDSN,
|
||||
tunnel: sentryTunnel,
|
||||
enableInDebugMode: true,
|
||||
enableInDebugMode: !kDebugMode, // todo: rewrite neeraj revert this
|
||||
prefix: prefix,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class CollectionFileItem {
|
||||
final int id;
|
||||
final String encryptedKey;
|
||||
final String keyDecryptionNonce;
|
||||
|
||||
CollectionFileItem(
|
||||
this.id,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
);
|
||||
|
||||
CollectionFileItem copyWith({
|
||||
int? id,
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
}) {
|
||||
return CollectionFileItem(
|
||||
id ?? this.id,
|
||||
encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'encryptedKey': encryptedKey,
|
||||
'keyDecryptionNonce': keyDecryptionNonce,
|
||||
};
|
||||
}
|
||||
|
||||
static fromMap(Map<String, dynamic>? map) {
|
||||
if (map == null) return null;
|
||||
|
||||
return CollectionFileItem(
|
||||
map['id'],
|
||||
map['encryptedKey'],
|
||||
map['keyDecryptionNonce'],
|
||||
);
|
||||
}
|
||||
|
||||
String toJson() => json.encode(toMap());
|
||||
|
||||
factory CollectionFileItem.fromJson(String source) =>
|
||||
CollectionFileItem.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CollectionFileItem(id: $id, encryptedKey: $encryptedKey, keyDecryptionNonce: $keyDecryptionNonce)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object o) {
|
||||
if (identical(this, o)) return true;
|
||||
|
||||
return o is CollectionFileItem &&
|
||||
o.id == id &&
|
||||
o.encryptedKey == encryptedKey &&
|
||||
o.keyDecryptionNonce == keyDecryptionNonce;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^ encryptedKey.hashCode ^ keyDecryptionNonce.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:ente_crypto/ente_crypto.dart";
|
||||
|
||||
class CollectionFileRequest {
|
||||
final int id;
|
||||
final String encryptedKey;
|
||||
final String keyDecryptionNonce;
|
||||
|
||||
CollectionFileRequest(
|
||||
this.id,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
);
|
||||
|
||||
static Map<String, dynamic> req(
|
||||
int id, {
|
||||
required Uint8List encKey,
|
||||
required Uint8List encKeyNonce,
|
||||
}) {
|
||||
return {
|
||||
'id': id,
|
||||
'encryptedKey': CryptoUtil.bin2base64(encKey),
|
||||
'keyDecryptionNonce': CryptoUtil.bin2base64(encKeyNonce),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'encryptedKey': encryptedKey,
|
||||
'keyDecryptionNonce': keyDecryptionNonce,
|
||||
};
|
||||
}
|
||||
}
|
||||
197
mobile/apps/photos/lib/models/api/diff/diff.dart
Normal file
197
mobile/apps/photos/lib/models/api/diff/diff.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photos/models/api/diff/trash_time.dart";
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
|
||||
class Info {
|
||||
final int fileSize;
|
||||
final int thumbSize;
|
||||
|
||||
static Info? fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
return Info(
|
||||
fileSize: json['fileSize'] ?? -1,
|
||||
thumbSize: json['thumbSize'] ?? -1,
|
||||
);
|
||||
}
|
||||
|
||||
Info({required this.fileSize, required this.thumbSize});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'fileSize': fileSize,
|
||||
'thumbSize': thumbSize,
|
||||
};
|
||||
}
|
||||
|
||||
String toEncodedJson() {
|
||||
return jsonEncode(toJson());
|
||||
}
|
||||
|
||||
static Info? fromEncodedJson(String? encodedJson) {
|
||||
if (encodedJson == null) return null;
|
||||
return Info.fromJson(jsonDecode(encodedJson));
|
||||
}
|
||||
}
|
||||
|
||||
class Metadata {
|
||||
final Map<String, dynamic> data;
|
||||
final int version;
|
||||
|
||||
Metadata({required this.data, required this.version});
|
||||
|
||||
static fromJson(Map<String, dynamic> json) {
|
||||
if (json.isEmpty || json['data'] == null) return null;
|
||||
return Metadata(
|
||||
data: json['data'],
|
||||
version: json['version'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'data': data,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
static Metadata? fromEncodedJson(String? encodedJson) {
|
||||
if (encodedJson == null) return null;
|
||||
return Metadata.fromJson(jsonDecode(encodedJson));
|
||||
}
|
||||
|
||||
String toEncodedJson() {
|
||||
return jsonEncode(toJson());
|
||||
}
|
||||
}
|
||||
|
||||
class ApiFileItem {
|
||||
final int fileID;
|
||||
final int ownerID;
|
||||
final Uint8List? thumnailDecryptionHeader;
|
||||
final Uint8List? fileDecryptionHeader;
|
||||
final Metadata? metadata;
|
||||
final Metadata? privMagicMetadata;
|
||||
final Metadata? pubMagicMetadata;
|
||||
final Info? info;
|
||||
|
||||
ApiFileItem({
|
||||
required this.fileID,
|
||||
required this.ownerID,
|
||||
this.thumnailDecryptionHeader,
|
||||
this.fileDecryptionHeader,
|
||||
this.metadata,
|
||||
this.privMagicMetadata,
|
||||
this.pubMagicMetadata,
|
||||
this.info,
|
||||
});
|
||||
|
||||
factory ApiFileItem.deleted(int fileID, int ownerID) {
|
||||
return ApiFileItem(
|
||||
fileID: fileID,
|
||||
ownerID: ownerID,
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> filesMetadataRowValues() {
|
||||
return [
|
||||
fileID,
|
||||
metadata?.toEncodedJson(),
|
||||
privMagicMetadata?.toEncodedJson(),
|
||||
pubMagicMetadata?.toEncodedJson(),
|
||||
info?.toEncodedJson(),
|
||||
];
|
||||
}
|
||||
|
||||
RemoteAsset toRemoteAsset() {
|
||||
return RemoteAsset.fromMetadata(
|
||||
id: fileID,
|
||||
ownerID: ownerID,
|
||||
thumbHeader: thumnailDecryptionHeader!,
|
||||
fileHeader: fileDecryptionHeader!,
|
||||
metadata: metadata!,
|
||||
privateMetadata: privMagicMetadata,
|
||||
publicMetadata: pubMagicMetadata,
|
||||
info: info,
|
||||
);
|
||||
}
|
||||
|
||||
String get title =>
|
||||
pubMagicMetadata?.data['editedName'] ?? metadata?.data['title'] ?? "";
|
||||
|
||||
String get nonEditedTitle {
|
||||
return metadata?.data['title'] ?? "";
|
||||
}
|
||||
|
||||
String? get localID => metadata?.data['localID'];
|
||||
|
||||
String? get deviceFolder => metadata?.data['deviceFolder'];
|
||||
|
||||
int get creationTime =>
|
||||
pubMagicMetadata?.data['editedTime'] ??
|
||||
metadata?.data['creationTime'] ??
|
||||
0;
|
||||
|
||||
int get modificationTime =>
|
||||
metadata?.data['modificationTime'] ?? creationTime;
|
||||
|
||||
// note: during remote to local sync, older live photo hash format from desktop
|
||||
// is already converted to the new format
|
||||
String? get hash => metadata?.data['hash'];
|
||||
|
||||
int get fileSize => info?.fileSize ?? -1;
|
||||
}
|
||||
|
||||
class DiffItem {
|
||||
final int collectionID;
|
||||
final bool isDeleted;
|
||||
final Uint8List? encFileKey;
|
||||
final Uint8List? encFileKeyNonce;
|
||||
final int updatedAt;
|
||||
final int? createdAt;
|
||||
final ApiFileItem fileItem;
|
||||
final TrashTime? trashTime;
|
||||
|
||||
DiffItem({
|
||||
required this.collectionID,
|
||||
required this.isDeleted,
|
||||
required this.updatedAt,
|
||||
required this.fileItem,
|
||||
this.createdAt,
|
||||
this.encFileKey,
|
||||
this.encFileKeyNonce,
|
||||
this.trashTime,
|
||||
});
|
||||
int get fileID => fileItem.fileID;
|
||||
|
||||
List<Object?> collectionFileRowValues() {
|
||||
return [
|
||||
collectionID,
|
||||
fileID,
|
||||
encFileKey,
|
||||
encFileKeyNonce,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
}
|
||||
|
||||
List<Object?> trashRowValues() {
|
||||
return [
|
||||
fileID,
|
||||
fileItem.ownerID,
|
||||
collectionID,
|
||||
encFileKey,
|
||||
encFileKeyNonce,
|
||||
fileItem.fileDecryptionHeader,
|
||||
fileItem.thumnailDecryptionHeader,
|
||||
fileItem.metadata?.toEncodedJson(),
|
||||
fileItem.privMagicMetadata?.toEncodedJson(),
|
||||
fileItem.pubMagicMetadata?.toEncodedJson(),
|
||||
fileItem.info?.toEncodedJson(),
|
||||
trashTime!.createdAt,
|
||||
trashTime!.updatedAt,
|
||||
trashTime!.deleteBy,
|
||||
];
|
||||
}
|
||||
}
|
||||
21
mobile/apps/photos/lib/models/api/diff/trash_time.dart
Normal file
21
mobile/apps/photos/lib/models/api/diff/trash_time.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
class TrashTime {
|
||||
int createdAt;
|
||||
int updatedAt;
|
||||
int deleteBy;
|
||||
TrashTime({
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.deleteBy,
|
||||
});
|
||||
TrashTime.fromMap(Map<String, dynamic> map)
|
||||
: createdAt = map["createdAt"] as int,
|
||||
updatedAt = map["updatedAt"] as int,
|
||||
deleteBy = map["deleteBy"] as int;
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
"createdAt": createdAt,
|
||||
"updatedAt": updatedAt,
|
||||
"deleteBy": deleteBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "dart:convert";
|
||||
import 'dart:core';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -5,6 +6,7 @@ import "package:photos/core/configuration.dart";
|
||||
import "package:photos/extensions/user_extension.dart";
|
||||
import "package:photos/models/api/collection/public_url.dart";
|
||||
import "package:photos/models/api/collection/user.dart";
|
||||
import "package:photos/models/collection/collection_old.dart";
|
||||
import "package:photos/models/metadata/collection_magic.dart";
|
||||
import "package:photos/models/metadata/common_keys.dart";
|
||||
|
||||
@@ -12,33 +14,18 @@ class Collection {
|
||||
final int id;
|
||||
final User owner;
|
||||
final String encryptedKey;
|
||||
final String? keyDecryptionNonce;
|
||||
@Deprecated("Use collectionName instead")
|
||||
// keyDecryptionNonce will be empty string for collections shared with the user
|
||||
final String keyDecryptionNonce;
|
||||
String? name;
|
||||
|
||||
// encryptedName & nameDecryptionNonce will be null for collections
|
||||
// created before we started encrypting collection name
|
||||
final String? encryptedName;
|
||||
final String? nameDecryptionNonce;
|
||||
final CollectionType type;
|
||||
final CollectionAttributes attributes;
|
||||
final List<User> sharees;
|
||||
final List<PublicURL> publicURLs;
|
||||
final int updationTime;
|
||||
final bool isDeleted;
|
||||
|
||||
// In early days before public launch, we used to store collection name
|
||||
// un-encrypted. decryptName will be value either decrypted value for
|
||||
// encryptedName or name itself.
|
||||
String? decryptedName;
|
||||
|
||||
// decryptedPath will be null for collections now owned by user, deleted
|
||||
// collections, && collections which don't have a path. The path is used
|
||||
// to map local on-device album on mobile to remote collection on ente.
|
||||
String? decryptedPath;
|
||||
String? mMdEncodedJson;
|
||||
String? mMdPubEncodedJson;
|
||||
String? sharedMmdJson;
|
||||
final String? localPath;
|
||||
String mMdEncodedJson;
|
||||
String mMdPubEncodedJson;
|
||||
String sharedMmdJson;
|
||||
int mMdVersion = 0;
|
||||
int mMbPubVersion = 0;
|
||||
int sharedMmdVersion = 0;
|
||||
@@ -47,14 +34,13 @@ class Collection {
|
||||
ShareeMagicMetadata? _sharedMmd;
|
||||
|
||||
CollectionMagicMetadata get magicMetadata =>
|
||||
_mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
|
||||
_mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson);
|
||||
|
||||
CollectionPubMagicMetadata get pubMagicMetadata =>
|
||||
_pubMmd ??
|
||||
CollectionPubMagicMetadata.fromEncodedJson(mMdPubEncodedJson ?? '{}');
|
||||
_pubMmd ?? CollectionPubMagicMetadata.fromEncodedJson(mMdPubEncodedJson);
|
||||
|
||||
ShareeMagicMetadata get sharedMagicMetadata =>
|
||||
_sharedMmd ?? ShareeMagicMetadata.fromEncodedJson(sharedMmdJson ?? '{}');
|
||||
_sharedMmd ?? ShareeMagicMetadata.fromEncodedJson(sharedMmdJson);
|
||||
|
||||
set magicMetadata(CollectionMagicMetadata? val) => _mmd = val;
|
||||
|
||||
@@ -69,32 +55,58 @@ class Collection {
|
||||
!isOwner(Configuration.instance.getUserID() ?? -1)) {
|
||||
return '${owner.nameOrEmail}\'s favorites';
|
||||
}
|
||||
return decryptedName ?? name ?? "Unnamed Album";
|
||||
return name ?? "Unnamed Album";
|
||||
}
|
||||
|
||||
// set the value for both name and decryptedName till we finish migration
|
||||
void setName(String newName) {
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
name = newName;
|
||||
decryptedName = newName;
|
||||
}
|
||||
|
||||
Collection(
|
||||
this.id,
|
||||
this.owner,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
this.name,
|
||||
this.encryptedName,
|
||||
this.nameDecryptionNonce,
|
||||
this.type,
|
||||
this.attributes,
|
||||
this.sharees,
|
||||
this.publicURLs,
|
||||
this.updationTime, {
|
||||
Collection({
|
||||
required this.id,
|
||||
required this.owner,
|
||||
required this.encryptedKey,
|
||||
required this.keyDecryptionNonce,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.sharees,
|
||||
required this.publicURLs,
|
||||
required this.updationTime,
|
||||
required this.localPath,
|
||||
this.isDeleted = false,
|
||||
this.mMdEncodedJson = '{}',
|
||||
this.mMdPubEncodedJson = '{}',
|
||||
this.sharedMmdJson = '{}',
|
||||
this.mMdVersion = 0,
|
||||
this.mMbPubVersion = 0,
|
||||
this.sharedMmdVersion = 0,
|
||||
});
|
||||
|
||||
factory Collection.fromOldCollection(CollectionV2 collection) {
|
||||
return Collection(
|
||||
id: collection.id,
|
||||
owner: collection.owner,
|
||||
encryptedKey: collection.encryptedKey,
|
||||
// note: keyDecryptionNonce will be null in case of collections
|
||||
// shared with the user
|
||||
keyDecryptionNonce: collection.keyDecryptionNonce ?? '',
|
||||
name: collection.displayName,
|
||||
type: collection.type,
|
||||
sharees: collection.sharees,
|
||||
publicURLs: collection.publicURLs,
|
||||
updationTime: collection.updationTime,
|
||||
localPath: collection.decryptedPath,
|
||||
isDeleted: collection.isDeleted,
|
||||
mMbPubVersion: collection.mMbPubVersion,
|
||||
mMdPubEncodedJson: collection.mMdPubEncodedJson ?? '{}',
|
||||
mMdVersion: collection.mMdVersion,
|
||||
mMdEncodedJson: collection.mMdEncodedJson ?? '{}',
|
||||
sharedMmdJson: collection.sharedMmdJson ?? '{}',
|
||||
sharedMmdVersion: collection.sharedMmdVersion,
|
||||
);
|
||||
}
|
||||
|
||||
bool isArchived() {
|
||||
return mMdVersion > 0 && magicMetadata.visibility == archiveVisibility;
|
||||
}
|
||||
@@ -122,6 +134,15 @@ class Collection {
|
||||
return mMdVersion > 0 && (magicMetadata.visibility == hiddenVisibility);
|
||||
}
|
||||
|
||||
int get visibility {
|
||||
if (isHidden()) {
|
||||
return hiddenVisibility;
|
||||
} else if (isArchived() || hasShareeArchived()) {
|
||||
return archiveVisibility;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool isDefaultHidden() {
|
||||
return (magicMetadata.subType ?? 0) == subTypeDefaultHidden;
|
||||
}
|
||||
@@ -191,7 +212,10 @@ class Collection {
|
||||
// device album based on path. The path is nothing but the name of the device
|
||||
// album.
|
||||
bool canLinkToDevicePath(int userID) {
|
||||
return isOwner(userID) && !isDeleted && attributes.encryptedPath != null;
|
||||
return isOwner(userID) &&
|
||||
!isDeleted &&
|
||||
localPath != null &&
|
||||
localPath != '';
|
||||
}
|
||||
|
||||
void updateSharees(List<User> newSharees) {
|
||||
@@ -205,72 +229,90 @@ class Collection {
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
String? name,
|
||||
String? encryptedName,
|
||||
String? nameDecryptionNonce,
|
||||
CollectionType? type,
|
||||
CollectionAttributes? attributes,
|
||||
List<User>? sharees,
|
||||
List<PublicURL>? publicURLs,
|
||||
int? updationTime,
|
||||
bool? isDeleted,
|
||||
String? localPath,
|
||||
String? mMdEncodedJson,
|
||||
int? mMdVersion,
|
||||
String? decryptedName,
|
||||
String? decryptedPath,
|
||||
String? mMdPubEncodedJson,
|
||||
int? mMbPubVersion,
|
||||
String? sharedMmdJson,
|
||||
int? sharedMmdVersion,
|
||||
}) {
|
||||
final Collection result = Collection(
|
||||
id ?? this.id,
|
||||
owner ?? this.owner,
|
||||
encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
// ignore: deprecated_member_use_from_same_package
|
||||
name ?? this.name,
|
||||
encryptedName ?? this.encryptedName,
|
||||
nameDecryptionNonce ?? this.nameDecryptionNonce,
|
||||
type ?? this.type,
|
||||
attributes ?? this.attributes,
|
||||
sharees ?? this.sharees,
|
||||
publicURLs ?? this.publicURLs,
|
||||
updationTime ?? this.updationTime,
|
||||
id: id ?? this.id,
|
||||
owner: owner ?? this.owner,
|
||||
encryptedKey: encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce: keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
sharees: sharees ?? this.sharees,
|
||||
publicURLs: publicURLs ?? this.publicURLs,
|
||||
updationTime: updationTime ?? this.updationTime,
|
||||
localPath: localPath ?? this.localPath,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
mMdEncodedJson: mMdEncodedJson ?? this.mMdEncodedJson,
|
||||
mMdVersion: mMdVersion ?? this.mMdVersion,
|
||||
mMdPubEncodedJson: mMdPubEncodedJson ?? this.mMdPubEncodedJson,
|
||||
mMbPubVersion: mMbPubVersion ?? this.mMbPubVersion,
|
||||
sharedMmdJson: sharedMmdJson ?? this.sharedMmdJson,
|
||||
sharedMmdVersion: sharedMmdVersion ?? this.sharedMmdVersion,
|
||||
);
|
||||
result.mMdVersion = mMdVersion ?? this.mMdVersion;
|
||||
result.mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson;
|
||||
result.decryptedName = decryptedName ?? this.decryptedName;
|
||||
result.decryptedPath = decryptedPath ?? this.decryptedPath;
|
||||
result.mMbPubVersion = mMbPubVersion;
|
||||
result.mMdPubEncodedJson = mMdPubEncodedJson;
|
||||
result.sharedMmdVersion = sharedMmdVersion;
|
||||
result.sharedMmdJson = sharedMmdJson;
|
||||
return result;
|
||||
}
|
||||
|
||||
static fromMap(Map<String, dynamic>? map) {
|
||||
if (map == null) return null;
|
||||
final sharees = (map['sharees'] == null || map['sharees'].length == 0)
|
||||
? <User>[]
|
||||
: List<User>.from(map['sharees'].map((x) => User.fromMap(x)));
|
||||
final publicURLs =
|
||||
(map['publicURLs'] == null || map['publicURLs'].length == 0)
|
||||
? <PublicURL>[]
|
||||
: List<PublicURL>.from(
|
||||
map['publicURLs'].map((x) => PublicURL.fromMap(x)),
|
||||
);
|
||||
return Collection(
|
||||
map['id'],
|
||||
User.fromMap(map['owner']),
|
||||
map['encryptedKey'],
|
||||
map['keyDecryptionNonce'],
|
||||
map['name'],
|
||||
map['encryptedName'],
|
||||
map['nameDecryptionNonce'],
|
||||
typeFromString(map['type']),
|
||||
CollectionAttributes.fromMap(map['attributes']),
|
||||
sharees,
|
||||
publicURLs,
|
||||
map['updationTime'],
|
||||
isDeleted: map['isDeleted'] ?? false,
|
||||
static Collection fromRow(Map<String, dynamic> map) {
|
||||
final sharees = List<User>.from(
|
||||
(json.decode(map['sharees']) as List).map((x) => User.fromMap(x)),
|
||||
);
|
||||
final List<PublicURL> publicURLs = List<PublicURL>.from(
|
||||
(json.decode(map['public_urls']) as List)
|
||||
.map((x) => PublicURL.fromMap(x)),
|
||||
);
|
||||
return Collection(
|
||||
id: map['id'],
|
||||
owner: User.fromJson(map['owner']),
|
||||
encryptedKey: map['enc_key'],
|
||||
keyDecryptionNonce: map['enc_key_nonce'],
|
||||
name: map['name'],
|
||||
type: typeFromString(map['type']),
|
||||
sharees: sharees,
|
||||
publicURLs: publicURLs,
|
||||
updationTime: map['updation_time'],
|
||||
localPath: map['local_path'],
|
||||
isDeleted: (map['is_deleted'] as int) == 1,
|
||||
mMdEncodedJson: map['mmd_encoded_json'],
|
||||
mMdVersion: map['mmd_ver'],
|
||||
mMdPubEncodedJson: map['pub_mmd_encoded_json'],
|
||||
mMbPubVersion: map['pub_mmd_ver'],
|
||||
sharedMmdJson: map['shared_mmd_json'],
|
||||
sharedMmdVersion: map['shared_mmd_ver'],
|
||||
);
|
||||
}
|
||||
|
||||
List<Object?> rowValiues() {
|
||||
return [
|
||||
id,
|
||||
owner.toJson(),
|
||||
encryptedKey,
|
||||
keyDecryptionNonce,
|
||||
name,
|
||||
typeToString(type),
|
||||
localPath,
|
||||
isDeleted ? 1 : 0,
|
||||
updationTime,
|
||||
json.encode(sharees.map((x) => x.toMap()).toList()),
|
||||
json.encode(publicURLs.map((x) => x.toMap()).toList()),
|
||||
mMdEncodedJson,
|
||||
mMdVersion,
|
||||
mMdPubEncodedJson,
|
||||
mMbPubVersion,
|
||||
sharedMmdJson,
|
||||
sharedMmdVersion,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
252
mobile/apps/photos/lib/models/collection/collection_old.dart
Normal file
252
mobile/apps/photos/lib/models/collection/collection_old.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
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:photos/models/metadata/collection_magic.dart";
|
||||
import "package:photos/models/metadata/common_keys.dart";
|
||||
|
||||
class CollectionV2 {
|
||||
final int id;
|
||||
final User owner;
|
||||
final String encryptedKey;
|
||||
final String? keyDecryptionNonce;
|
||||
@Deprecated("Use collectionName instead")
|
||||
String? name;
|
||||
|
||||
// encryptedName & nameDecryptionNonce will be null for collections
|
||||
// created before we started encrypting collection name
|
||||
final String? encryptedName;
|
||||
final String? nameDecryptionNonce;
|
||||
final CollectionType type;
|
||||
final CollectionAttributes attributes;
|
||||
final List<User> sharees;
|
||||
final List<PublicURL> publicURLs;
|
||||
final int updationTime;
|
||||
final bool isDeleted;
|
||||
|
||||
// In early days before public launch, we used to store collection name
|
||||
// un-encrypted. decryptName will be value either decrypted value for
|
||||
// encryptedName or name itself.
|
||||
String? decryptedName;
|
||||
|
||||
// decryptedPath will be null for collections now owned by user, deleted
|
||||
// collections, && collections which don't have a path. The path is used
|
||||
// to map local on-device album on mobile to remote collection on ente.
|
||||
String? decryptedPath;
|
||||
String? mMdEncodedJson;
|
||||
String? mMdPubEncodedJson;
|
||||
String? sharedMmdJson;
|
||||
int mMdVersion = 0;
|
||||
int mMbPubVersion = 0;
|
||||
int sharedMmdVersion = 0;
|
||||
CollectionMagicMetadata? _mmd;
|
||||
CollectionPubMagicMetadata? _pubMmd;
|
||||
ShareeMagicMetadata? _sharedMmd;
|
||||
|
||||
CollectionMagicMetadata get magicMetadata =>
|
||||
_mmd ?? CollectionMagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
|
||||
|
||||
CollectionPubMagicMetadata get pubMagicMetadata =>
|
||||
_pubMmd ??
|
||||
CollectionPubMagicMetadata.fromEncodedJson(mMdPubEncodedJson ?? '{}');
|
||||
|
||||
ShareeMagicMetadata get sharedMagicMetadata =>
|
||||
_sharedMmd ?? ShareeMagicMetadata.fromEncodedJson(sharedMmdJson ?? '{}');
|
||||
|
||||
set magicMetadata(CollectionMagicMetadata? val) => _mmd = val;
|
||||
|
||||
set pubMagicMetadata(CollectionPubMagicMetadata? val) => _pubMmd = val;
|
||||
|
||||
set sharedMagicMetadata(ShareeMagicMetadata? val) => _sharedMmd = val;
|
||||
|
||||
String get displayName => decryptedName ?? name ?? "Unnamed Album";
|
||||
|
||||
// set the value for both name and decryptedName till we finish migration
|
||||
void setName(String newName) {
|
||||
name = newName;
|
||||
decryptedName = newName;
|
||||
}
|
||||
|
||||
CollectionV2(
|
||||
this.id,
|
||||
this.owner,
|
||||
this.encryptedKey,
|
||||
this.keyDecryptionNonce,
|
||||
this.name,
|
||||
this.encryptedName,
|
||||
this.nameDecryptionNonce,
|
||||
this.type,
|
||||
this.attributes,
|
||||
this.sharees,
|
||||
this.publicURLs,
|
||||
this.updationTime, {
|
||||
this.isDeleted = false,
|
||||
});
|
||||
|
||||
bool isArchived() {
|
||||
return mMdVersion > 0 && magicMetadata.visibility == archiveVisibility;
|
||||
}
|
||||
|
||||
bool hasShareeArchived() {
|
||||
return sharedMmdVersion > 0 &&
|
||||
sharedMagicMetadata.visibility == archiveVisibility;
|
||||
}
|
||||
|
||||
// hasLink returns true if there's any link attached to the collection
|
||||
// including expired links
|
||||
bool get hasLink => publicURLs.isNotEmpty;
|
||||
|
||||
bool get hasCover => (pubMagicMetadata.coverID ?? 0) > 0;
|
||||
|
||||
// hasSharees returns true if the collection is shared with other ente users
|
||||
bool get hasSharees => sharees.isNotEmpty;
|
||||
|
||||
bool get isPinned => (magicMetadata.order ?? 0) != 0;
|
||||
|
||||
bool isHidden() {
|
||||
if (isDefaultHidden()) {
|
||||
return true;
|
||||
}
|
||||
return mMdVersion > 0 && (magicMetadata.visibility == hiddenVisibility);
|
||||
}
|
||||
|
||||
bool isDefaultHidden() {
|
||||
return (magicMetadata.subType ?? 0) == subTypeDefaultHidden;
|
||||
}
|
||||
|
||||
bool isQuickLinkCollection() {
|
||||
return (magicMetadata.subType ?? 0) == subTypeSharedFilesCollection &&
|
||||
!hasSharees;
|
||||
}
|
||||
|
||||
List<User> getSharees() {
|
||||
return sharees;
|
||||
}
|
||||
|
||||
bool isOwner(int userID) {
|
||||
return (owner.id ?? -100) == userID;
|
||||
}
|
||||
|
||||
bool isDownloadEnabledForPublicLink() {
|
||||
if (publicURLs.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return publicURLs.first.enableDownload;
|
||||
}
|
||||
|
||||
bool isCollectEnabledForPublicLink() {
|
||||
if (publicURLs.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return publicURLs.first.enableCollect;
|
||||
}
|
||||
|
||||
bool get isJoinEnabled {
|
||||
if (publicURLs.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return publicURLs.first.enableJoin;
|
||||
}
|
||||
|
||||
CollectionParticipantRole getRole(int userID) {
|
||||
if (isOwner(userID)) {
|
||||
return CollectionParticipantRole.owner;
|
||||
}
|
||||
if (sharees.isEmpty) {
|
||||
return CollectionParticipantRole.unknown;
|
||||
}
|
||||
for (final User u in sharees) {
|
||||
if (u.id == userID) {
|
||||
if (u.isViewer) {
|
||||
return CollectionParticipantRole.viewer;
|
||||
} else if (u.isCollaborator) {
|
||||
return CollectionParticipantRole.collaborator;
|
||||
}
|
||||
}
|
||||
}
|
||||
return CollectionParticipantRole.unknown;
|
||||
}
|
||||
|
||||
// canLinkToDevicePath returns true if the collection can be linked to local
|
||||
// device album based on path. The path is nothing but the name of the device
|
||||
// album.
|
||||
bool canLinkToDevicePath(int userID) {
|
||||
return isOwner(userID) && !isDeleted && attributes.encryptedPath != null;
|
||||
}
|
||||
|
||||
void updateSharees(List<User> newSharees) {
|
||||
sharees.clear();
|
||||
sharees.addAll(newSharees);
|
||||
}
|
||||
|
||||
CollectionV2 copyWith({
|
||||
int? id,
|
||||
User? owner,
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
String? name,
|
||||
String? encryptedName,
|
||||
String? nameDecryptionNonce,
|
||||
CollectionType? type,
|
||||
CollectionAttributes? attributes,
|
||||
List<User>? sharees,
|
||||
List<PublicURL>? publicURLs,
|
||||
int? updationTime,
|
||||
bool? isDeleted,
|
||||
String? mMdEncodedJson,
|
||||
int? mMdVersion,
|
||||
String? decryptedName,
|
||||
String? decryptedPath,
|
||||
}) {
|
||||
final CollectionV2 result = CollectionV2(
|
||||
id ?? this.id,
|
||||
owner ?? this.owner,
|
||||
encryptedKey ?? this.encryptedKey,
|
||||
keyDecryptionNonce ?? this.keyDecryptionNonce,
|
||||
name ?? this.name,
|
||||
encryptedName ?? this.encryptedName,
|
||||
nameDecryptionNonce ?? this.nameDecryptionNonce,
|
||||
type ?? this.type,
|
||||
attributes ?? this.attributes,
|
||||
sharees ?? this.sharees,
|
||||
publicURLs ?? this.publicURLs,
|
||||
updationTime ?? this.updationTime,
|
||||
isDeleted: isDeleted ?? this.isDeleted,
|
||||
);
|
||||
result.mMdVersion = mMdVersion ?? this.mMdVersion;
|
||||
result.mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson;
|
||||
result.decryptedName = decryptedName ?? this.decryptedName;
|
||||
result.decryptedPath = decryptedPath ?? this.decryptedPath;
|
||||
result.mMbPubVersion = mMbPubVersion;
|
||||
result.mMdPubEncodedJson = mMdPubEncodedJson;
|
||||
result.sharedMmdVersion = sharedMmdVersion;
|
||||
result.sharedMmdJson = sharedMmdJson;
|
||||
return result;
|
||||
}
|
||||
|
||||
static CollectionV2 fromMap(Map<String, dynamic> map) {
|
||||
final sharees = (map['sharees'] == null || map['sharees'].length == 0)
|
||||
? <User>[]
|
||||
: List<User>.from(map['sharees'].map((x) => User.fromMap(x)));
|
||||
final publicURLs =
|
||||
(map['publicURLs'] == null || map['publicURLs'].length == 0)
|
||||
? <PublicURL>[]
|
||||
: List<PublicURL>.from(
|
||||
map['publicURLs'].map((x) => PublicURL.fromMap(x)),
|
||||
);
|
||||
return CollectionV2(
|
||||
map['id'],
|
||||
User.fromMap(map['owner']),
|
||||
map['encryptedKey'],
|
||||
map['keyDecryptionNonce'],
|
||||
map['name'],
|
||||
map['encryptedName'],
|
||||
map['nameDecryptionNonce'],
|
||||
typeFromString(map['type']),
|
||||
CollectionAttributes.fromMap(map['attributes']),
|
||||
sharees,
|
||||
publicURLs,
|
||||
map['updationTime'],
|
||||
isDeleted: map['isDeleted'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import "package:photo_manager/photo_manager.dart";
|
||||
import 'package:photos/models/file/file.dart';
|
||||
import 'package:photos/models/upload_strategy.dart';
|
||||
|
||||
class DeviceCollection {
|
||||
final String id;
|
||||
final String name;
|
||||
AssetPathEntity assetPathEntity;
|
||||
final int count;
|
||||
final bool shouldBackup;
|
||||
UploadStrategy uploadStrategy;
|
||||
final String? coverId;
|
||||
int? collectionID;
|
||||
EnteFile? thumbnail;
|
||||
|
||||
@@ -15,10 +14,16 @@ class DeviceCollection {
|
||||
return collectionID != null && collectionID! != -1;
|
||||
}
|
||||
|
||||
String get name {
|
||||
return assetPathEntity.name;
|
||||
}
|
||||
|
||||
String get id {
|
||||
return assetPathEntity.id;
|
||||
}
|
||||
|
||||
DeviceCollection(
|
||||
this.id,
|
||||
this.name, {
|
||||
this.coverId,
|
||||
this.assetPathEntity, {
|
||||
this.count = 0,
|
||||
this.collectionID,
|
||||
this.thumbnail,
|
||||
|
||||
@@ -85,10 +85,10 @@ class DuplicateFiles {
|
||||
sortByCollectionName() {
|
||||
files.sort((first, second) {
|
||||
final firstName = collectionsService
|
||||
.getCollectionByID(first.collectionID!)!
|
||||
.getCollectionByID(first.cf!.collectionID)!
|
||||
.displayName;
|
||||
final secondName = collectionsService
|
||||
.getCollectionByID(second.collectionID!)!
|
||||
.getCollectionByID(second.cf!.collectionID!)!
|
||||
.displayName;
|
||||
return firstName.compareTo(secondName);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import "package:photos/core/configuration.dart";
|
||||
import 'package:photos/models/file/extensions/r_asset_props.dart';
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import "package:photos/models/file/trash_file.dart";
|
||||
import "package:photos/services/collections_service.dart";
|
||||
|
||||
extension FilePropsExtn on EnteFile {
|
||||
bool get isLivePhoto => fileType == FileType.livePhoto;
|
||||
|
||||
bool get isMotionPhoto => (pubMagicMetadata?.mvi ?? 0) > 0;
|
||||
bool get isMotionPhoto => rAsset?.isMotionPhoto ?? false;
|
||||
|
||||
bool get isLiveOrMotionPhoto => isLivePhoto || isMotionPhoto;
|
||||
|
||||
@@ -23,8 +23,8 @@ extension FilePropsExtn on EnteFile {
|
||||
if (fileType != FileType.image) {
|
||||
return false;
|
||||
}
|
||||
if (pubMagicMetadata?.mediaType != null) {
|
||||
return (pubMagicMetadata!.mediaType! & 1) == 1;
|
||||
if (rAsset?.mediaType != null) {
|
||||
return (rAsset!.mediaType! & 1) == 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -42,22 +42,21 @@ extension FilePropsExtn on EnteFile {
|
||||
|
||||
bool get canEditMetaInfo => isUploaded && isOwner;
|
||||
|
||||
bool get isTrash => this is TrashFile;
|
||||
bool get isTrash => trashTime != null;
|
||||
|
||||
// Return true if the file was uploaded via collect photos workflow
|
||||
bool get isCollect => uploaderName != null;
|
||||
|
||||
String? get uploaderName => pubMagicMetadata?.uploaderName;
|
||||
String? get uploaderName => rAsset?.uploaderName;
|
||||
|
||||
bool get skipIndex => !isUploaded || fileType == FileType.other;
|
||||
|
||||
bool canReUpload(int userID) =>
|
||||
localID != null &&
|
||||
localID!.isNotEmpty &&
|
||||
lAsset != null &&
|
||||
cf != null &&
|
||||
isOwner &&
|
||||
collectionID != null &&
|
||||
(CollectionsService.instance
|
||||
.getCollectionByID(collectionID!)
|
||||
.getCollectionByID(cf!.collectionID)
|
||||
?.isOwner(userID) ??
|
||||
false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
|
||||
extension RemoteAssetExtension on RemoteAsset {
|
||||
bool get isMotionPhoto {
|
||||
return (motionVideoIndex ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@@ -1,125 +1,102 @@
|
||||
import 'dart:io';
|
||||
import "dart:core";
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import "package:photos/models/api/diff/trash_time.dart";
|
||||
import 'package:photos/models/file/file_type.dart';
|
||||
import "package:photos/models/file/remote/asset.dart";
|
||||
import "package:photos/models/file/remote/collection_file.dart";
|
||||
import "package:photos/models/local/shared_asset.dart";
|
||||
import 'package:photos/models/location/location.dart';
|
||||
import "package:photos/models/metadata/file_magic.dart";
|
||||
import "package:photos/module/download/file_url.dart";
|
||||
import 'package:photos/utils/exif_util.dart';
|
||||
import 'package:photos/utils/file_uploader_util.dart';
|
||||
import "package:photos/utils/panorama_util.dart";
|
||||
import 'package:photos/utils/standalone/date_time.dart';
|
||||
import "package:photos/services/local/asset_entity.service.dart";
|
||||
|
||||
//Todo: files with no location data have lat and long set to 0.0. This should ideally be null.
|
||||
class EnteFile {
|
||||
static final _logger = Logger('EnteFile');
|
||||
AssetEntity? lAsset;
|
||||
RemoteAsset? rAsset;
|
||||
CollectionFile? cf;
|
||||
TrashTime? trashTime;
|
||||
SharedAsset? sharedAsset;
|
||||
|
||||
int? generatedID;
|
||||
int? uploadedFileID;
|
||||
int? ownerID;
|
||||
int? collectionID;
|
||||
String? localID;
|
||||
String? title;
|
||||
|
||||
String? deviceFolder;
|
||||
int? creationTime;
|
||||
int? modificationTime;
|
||||
int? updationTime;
|
||||
int? addedTime;
|
||||
Location? location;
|
||||
|
||||
late Location? location;
|
||||
late FileType fileType;
|
||||
int? fileSubType;
|
||||
int? duration;
|
||||
String? exif;
|
||||
String? hash;
|
||||
int? metadataVersion;
|
||||
String? encryptedKey;
|
||||
String? keyDecryptionNonce;
|
||||
String? fileDecryptionHeader;
|
||||
String? thumbnailDecryptionHeader;
|
||||
String? metadataDecryptionHeader;
|
||||
int? fileSize;
|
||||
|
||||
String? mMdEncodedJson;
|
||||
int mMdVersion = 0;
|
||||
MagicMetadata? _mmd;
|
||||
|
||||
MagicMetadata get magicMetadata =>
|
||||
_mmd ?? MagicMetadata.fromEncodedJson(mMdEncodedJson ?? '{}');
|
||||
|
||||
set magicMetadata(val) => _mmd = val;
|
||||
|
||||
// public magic metadata is shared if during file/album sharing
|
||||
String? pubMmdEncodedJson;
|
||||
int pubMmdVersion = 0;
|
||||
PubMagicMetadata? _pubMmd;
|
||||
|
||||
PubMagicMetadata? get pubMagicMetadata =>
|
||||
_pubMmd ?? PubMagicMetadata.fromEncodedJson(pubMmdEncodedJson ?? '{}');
|
||||
|
||||
set pubMagicMetadata(val) => _pubMmd = val;
|
||||
|
||||
// in Version 1, live photo hash is stored as zip's hash.
|
||||
// in V2: LivePhoto hash is stored as imgHash:vidHash
|
||||
static const kCurrentMetadataVersion = 2;
|
||||
|
||||
static final _logger = Logger('File');
|
||||
|
||||
EnteFile();
|
||||
|
||||
static Future<EnteFile> fromAsset(String pathName, AssetEntity asset) async {
|
||||
static Future<EnteFile> fromAsset(String pathName, AssetEntity lAsset) async {
|
||||
final EnteFile file = EnteFile();
|
||||
file.localID = asset.id;
|
||||
file.title = asset.title;
|
||||
file.lAsset = lAsset;
|
||||
file.deviceFolder = pathName;
|
||||
file.location =
|
||||
Location(latitude: asset.latitude, longitude: asset.longitude);
|
||||
file.fileType = fileTypeFromAsset(asset);
|
||||
file.creationTime = parseFileCreationTime(file.title, asset);
|
||||
file.modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch;
|
||||
file.fileSubType = asset.subtype;
|
||||
file.metadataVersion = kCurrentMetadataVersion;
|
||||
Location(latitude: lAsset.latitude, longitude: lAsset.longitude);
|
||||
file.fileType = enteTypeFromAsset(lAsset);
|
||||
file.creationTime = AssetEntityService.estimateCreationTime(lAsset);
|
||||
file.modificationTime = lAsset.modifiedDateTime.microsecondsSinceEpoch;
|
||||
return file;
|
||||
}
|
||||
|
||||
static int parseFileCreationTime(String? fileTitle, AssetEntity asset) {
|
||||
int creationTime = asset.createDateTime.microsecondsSinceEpoch;
|
||||
final int modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch;
|
||||
if (creationTime >= jan011981Time) {
|
||||
// assuming that fileSystem is returning correct creationTime.
|
||||
// During upload, this might get overridden with exif Creation time
|
||||
// When the assetModifiedTime is less than creationTime, than just use
|
||||
// that as creationTime. This is to handle cases where file might be
|
||||
// copied to the fileSystem from somewhere else See #https://superuser.com/a/1091147
|
||||
if (modificationTime >= jan011981Time &&
|
||||
modificationTime < creationTime) {
|
||||
_logger.info(
|
||||
'LocalID: ${asset.id} modification time is less than creation time. Using modification time as creation time',
|
||||
);
|
||||
creationTime = modificationTime;
|
||||
}
|
||||
return creationTime;
|
||||
} else {
|
||||
if (modificationTime >= jan011981Time) {
|
||||
creationTime = modificationTime;
|
||||
} else {
|
||||
creationTime = DateTime.now().toUtc().microsecondsSinceEpoch;
|
||||
}
|
||||
try {
|
||||
final parsedDateTime = parseDateTimeFromFileNameV2(
|
||||
basenameWithoutExtension(fileTitle ?? ""),
|
||||
);
|
||||
if (parsedDateTime != null) {
|
||||
creationTime = parsedDateTime.microsecondsSinceEpoch;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return creationTime;
|
||||
static EnteFile fromAssetSync(AssetEntity asset) {
|
||||
final EnteFile file = EnteFile();
|
||||
file.lAsset = asset;
|
||||
file.deviceFolder = asset.relativePath;
|
||||
file.location =
|
||||
Location(latitude: asset.latitude, longitude: asset.longitude);
|
||||
file.fileType = enteTypeFromAsset(asset);
|
||||
file.creationTime = asset.createDateTime.microsecondsSinceEpoch;
|
||||
file.modificationTime = asset.modifiedDateTime.microsecondsSinceEpoch;
|
||||
return file;
|
||||
}
|
||||
|
||||
static EnteFile fromRemoteAsset(
|
||||
RemoteAsset rAsset,
|
||||
CollectionFile collection, {
|
||||
AssetEntity? lAsset,
|
||||
}) {
|
||||
final EnteFile file = EnteFile();
|
||||
file.rAsset = rAsset;
|
||||
file.cf = collection;
|
||||
file.lAsset = lAsset;
|
||||
file.ownerID = rAsset.ownerID;
|
||||
// file.deviceFolder = rAsset.deviceFolder;
|
||||
file.location = rAsset.location;
|
||||
file.fileType = rAsset.fileType;
|
||||
file.creationTime = rAsset.creationTime;
|
||||
file.modificationTime = rAsset.modificationTime;
|
||||
return file;
|
||||
}
|
||||
|
||||
String? get localID => lAsset?.id ?? sharedAsset?.id;
|
||||
|
||||
int get remoteID {
|
||||
if (rAsset != null) {
|
||||
return rAsset!.id;
|
||||
} else {
|
||||
throw Exception("Remote ID is not set for the file");
|
||||
}
|
||||
}
|
||||
|
||||
String? get hash => rAsset?.hash;
|
||||
|
||||
int? get fileSubType => rAsset?.subType ?? lAsset?.subtype;
|
||||
|
||||
int? get uploadedFileID => rAsset?.id;
|
||||
|
||||
int? get durationInSec => rAsset?.durationInSec ?? lAsset?.duration;
|
||||
|
||||
String? get title => rAsset?.title ?? lAsset?.title;
|
||||
|
||||
int? get collectionID => cf?.collectionID;
|
||||
|
||||
Future<AssetEntity?> get getAsset {
|
||||
if (localID == null) {
|
||||
return Future.value(null);
|
||||
@@ -127,132 +104,23 @@ class EnteFile {
|
||||
return AssetEntity.fromId(localID!);
|
||||
}
|
||||
|
||||
void applyMetadata(Map<String, dynamic> metadata) {
|
||||
localID = metadata["localID"];
|
||||
title = metadata["title"];
|
||||
deviceFolder = metadata["deviceFolder"];
|
||||
creationTime = metadata["creationTime"] ?? 0;
|
||||
modificationTime = metadata["modificationTime"] ?? creationTime;
|
||||
final latitude = double.tryParse(metadata["latitude"].toString());
|
||||
final longitude = double.tryParse(metadata["longitude"].toString());
|
||||
if (latitude == null || longitude == null) {
|
||||
location = null;
|
||||
} else {
|
||||
location = Location(latitude: latitude, longitude: longitude);
|
||||
}
|
||||
fileType = getFileType(metadata["fileType"] ?? -1);
|
||||
fileSubType = metadata["subType"] ?? -1;
|
||||
duration = metadata["duration"] ?? 0;
|
||||
exif = metadata["exif"];
|
||||
hash = metadata["hash"];
|
||||
// handle past live photos upload from web client
|
||||
if (hash == null &&
|
||||
fileType == FileType.livePhoto &&
|
||||
metadata.containsKey('imageHash') &&
|
||||
metadata.containsKey('videoHash')) {
|
||||
// convert to imgHash:vidHash
|
||||
hash =
|
||||
'${metadata['imageHash']}$kLivePhotoHashSeparator${metadata['videoHash']}';
|
||||
}
|
||||
metadataVersion = metadata["version"] ?? 0;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getMetadataForUpload(
|
||||
MediaUploadData mediaUploadData,
|
||||
ParsedExifDateTime? exifTime,
|
||||
) async {
|
||||
final asset = await getAsset;
|
||||
// asset can be null for files shared to app
|
||||
if (asset != null) {
|
||||
fileSubType = asset.subtype;
|
||||
if (fileType == FileType.video) {
|
||||
duration = asset.duration;
|
||||
}
|
||||
}
|
||||
bool hasExifTime = false;
|
||||
if (exifTime != null && exifTime.time != null) {
|
||||
hasExifTime = true;
|
||||
creationTime = exifTime.time!.microsecondsSinceEpoch;
|
||||
}
|
||||
if (mediaUploadData.exifData != null) {
|
||||
mediaUploadData.isPanorama =
|
||||
checkPanoramaFromEXIF(null, mediaUploadData.exifData);
|
||||
}
|
||||
if (mediaUploadData.isPanorama != true &&
|
||||
fileType == FileType.image &&
|
||||
mediaUploadData.sourceFile != null) {
|
||||
try {
|
||||
final xmpData = await getXmp(mediaUploadData.sourceFile!);
|
||||
mediaUploadData.isPanorama = checkPanoramaFromXMP(xmpData);
|
||||
} catch (_) {}
|
||||
mediaUploadData.isPanorama ??= false;
|
||||
}
|
||||
|
||||
// Try to get the timestamp from fileName. In case of iOS, file names are
|
||||
// generic IMG_XXXX, so only parse it on Android devices
|
||||
if (!hasExifTime && Platform.isAndroid && title != null) {
|
||||
final timeFromFileName = parseDateTimeFromFileNameV2(title!);
|
||||
if (timeFromFileName != null) {
|
||||
// only use timeFromFileName if the existing creationTime and
|
||||
// timeFromFilename belongs to different date.
|
||||
// This is done because many times the fileTimeStamp will only give us
|
||||
// the date, not time value but the photo_manager's creation time will
|
||||
// contain the time.
|
||||
final bool useFileTimeStamp = creationTime == null ||
|
||||
!areFromSameDay(
|
||||
creationTime!,
|
||||
timeFromFileName.microsecondsSinceEpoch,
|
||||
);
|
||||
if (useFileTimeStamp) {
|
||||
creationTime = timeFromFileName.microsecondsSinceEpoch;
|
||||
}
|
||||
}
|
||||
}
|
||||
hash = mediaUploadData.hashData?.fileHash;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
Map<String, dynamic> get metadata {
|
||||
final metadata = <String, dynamic>{};
|
||||
metadata["localID"] = isSharedMediaToAppSandbox ? null : localID;
|
||||
metadata["title"] = title;
|
||||
metadata["deviceFolder"] = deviceFolder;
|
||||
metadata["creationTime"] = creationTime;
|
||||
metadata["modificationTime"] = modificationTime;
|
||||
metadata["fileType"] = fileType.index;
|
||||
if (location != null &&
|
||||
location!.latitude != null &&
|
||||
location!.longitude != null) {
|
||||
metadata["latitude"] = location!.latitude;
|
||||
metadata["longitude"] = location!.longitude;
|
||||
}
|
||||
if (fileSubType != null) {
|
||||
metadata["subType"] = fileSubType;
|
||||
}
|
||||
if (duration != null) {
|
||||
metadata["duration"] = duration;
|
||||
}
|
||||
if (hash != null) {
|
||||
metadata["hash"] = hash;
|
||||
}
|
||||
if (metadataVersion != null) {
|
||||
metadata["version"] = metadataVersion;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
String get downloadUrl =>
|
||||
FileUrl.getUrl(uploadedFileID!, FileUrlType.download);
|
||||
|
||||
String? get caption {
|
||||
return pubMagicMetadata?.caption;
|
||||
return rAsset?.caption;
|
||||
}
|
||||
|
||||
String? debugCaption;
|
||||
int? get fileSize {
|
||||
if (rAsset != null) {
|
||||
return rAsset!.fileSize;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String get displayName {
|
||||
if (pubMagicMetadata != null && pubMagicMetadata!.editedName != null) {
|
||||
return pubMagicMetadata!.editedName!;
|
||||
if (rAsset != null) {
|
||||
return rAsset!.title;
|
||||
}
|
||||
if (title == null && kDebugMode) _logger.severe('File title is null');
|
||||
return title ?? '';
|
||||
@@ -260,11 +128,17 @@ class EnteFile {
|
||||
|
||||
// return 0 if the height is not available
|
||||
int get height {
|
||||
return pubMagicMetadata?.h ?? 0;
|
||||
if (rAsset != null) {
|
||||
return rAsset!.height ?? 0;
|
||||
}
|
||||
return lAsset?.height ?? 0;
|
||||
}
|
||||
|
||||
int get width {
|
||||
return pubMagicMetadata?.w ?? 0;
|
||||
if (rAsset != null) {
|
||||
return rAsset!.width ?? 0;
|
||||
}
|
||||
return lAsset?.width ?? 0;
|
||||
}
|
||||
|
||||
bool get hasDimensions {
|
||||
@@ -273,15 +147,16 @@ class EnteFile {
|
||||
|
||||
// returns true if the file isn't available in the user's gallery
|
||||
bool get isRemoteFile {
|
||||
return localID == null && uploadedFileID != null;
|
||||
return localID == null && isUploaded;
|
||||
}
|
||||
|
||||
bool get isUploaded {
|
||||
return uploadedFileID != null;
|
||||
return rAsset != null;
|
||||
}
|
||||
|
||||
bool get isSharedMediaToAppSandbox {
|
||||
return localID != null && localID!.startsWith(sharedMediaIdentifier);
|
||||
// returns true if the file is only available in the app's sandbox
|
||||
bool get isInAppMedia {
|
||||
return sharedAsset != null;
|
||||
}
|
||||
|
||||
bool get hasLocation {
|
||||
@@ -293,7 +168,7 @@ class EnteFile {
|
||||
String toString() {
|
||||
return '''File(generatedID: $generatedID, localID: $localID, title: $title,
|
||||
type: $fileType, uploadedFileId: $uploadedFileID, modificationTime: $modificationTime,
|
||||
ownerID: $ownerID, collectionID: $collectionID, updationTime: $updationTime)''';
|
||||
ownerID: $ownerID, collectionID: $collectionID, updationTime: ${cf?.updatedAt})''';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -312,12 +187,7 @@ class EnteFile {
|
||||
}
|
||||
|
||||
String get tag {
|
||||
return "local_" +
|
||||
localID.toString() +
|
||||
":remote_" +
|
||||
uploadedFileID.toString() +
|
||||
":generated_" +
|
||||
generatedID.toString();
|
||||
return "local_$localID:remote_$uploadedFileID:generated_$generatedID";
|
||||
}
|
||||
|
||||
String cacheKey() {
|
||||
@@ -327,68 +197,27 @@ class EnteFile {
|
||||
|
||||
EnteFile copyWith({
|
||||
int? generatedID,
|
||||
int? uploadedFileID,
|
||||
int? ownerID,
|
||||
int? collectionID,
|
||||
String? localID,
|
||||
String? title,
|
||||
String? deviceFolder,
|
||||
int? creationTime,
|
||||
int? modificationTime,
|
||||
int? updationTime,
|
||||
int? addedTime,
|
||||
Location? location,
|
||||
FileType? fileType,
|
||||
int? fileSubType,
|
||||
int? duration,
|
||||
String? exif,
|
||||
String? hash,
|
||||
int? metadataVersion,
|
||||
String? encryptedKey,
|
||||
String? keyDecryptionNonce,
|
||||
String? fileDecryptionHeader,
|
||||
String? thumbnailDecryptionHeader,
|
||||
String? metadataDecryptionHeader,
|
||||
int? fileSize,
|
||||
String? mMdEncodedJson,
|
||||
int? mMdVersion,
|
||||
MagicMetadata? magicMetadata,
|
||||
String? pubMmdEncodedJson,
|
||||
int? pubMmdVersion,
|
||||
PubMagicMetadata? pubMagicMetadata,
|
||||
}) {
|
||||
return EnteFile()
|
||||
..lAsset = lAsset
|
||||
..rAsset = rAsset
|
||||
..cf = cf
|
||||
..generatedID = generatedID ?? this.generatedID
|
||||
..uploadedFileID = uploadedFileID ?? this.uploadedFileID
|
||||
..ownerID = ownerID ?? this.ownerID
|
||||
..collectionID = collectionID ?? this.collectionID
|
||||
..localID = localID ?? this.localID
|
||||
..title = title ?? this.title
|
||||
..deviceFolder = deviceFolder ?? this.deviceFolder
|
||||
..creationTime = creationTime ?? this.creationTime
|
||||
..modificationTime = modificationTime ?? this.modificationTime
|
||||
..updationTime = updationTime ?? this.updationTime
|
||||
..addedTime = addedTime ?? this.addedTime
|
||||
..location = location ?? this.location
|
||||
..fileType = fileType ?? this.fileType
|
||||
..fileSubType = fileSubType ?? this.fileSubType
|
||||
..duration = duration ?? this.duration
|
||||
..exif = exif ?? this.exif
|
||||
..hash = hash ?? this.hash
|
||||
..metadataVersion = metadataVersion ?? this.metadataVersion
|
||||
..encryptedKey = encryptedKey ?? this.encryptedKey
|
||||
..keyDecryptionNonce = keyDecryptionNonce ?? this.keyDecryptionNonce
|
||||
..fileDecryptionHeader = fileDecryptionHeader ?? this.fileDecryptionHeader
|
||||
..thumbnailDecryptionHeader =
|
||||
thumbnailDecryptionHeader ?? this.thumbnailDecryptionHeader
|
||||
..metadataDecryptionHeader =
|
||||
metadataDecryptionHeader ?? this.metadataDecryptionHeader
|
||||
..fileSize = fileSize ?? this.fileSize
|
||||
..mMdEncodedJson = mMdEncodedJson ?? this.mMdEncodedJson
|
||||
..mMdVersion = mMdVersion ?? this.mMdVersion
|
||||
..magicMetadata = magicMetadata ?? this.magicMetadata
|
||||
..pubMmdEncodedJson = pubMmdEncodedJson ?? this.pubMmdEncodedJson
|
||||
..pubMmdVersion = pubMmdVersion ?? this.pubMmdVersion
|
||||
..pubMagicMetadata = pubMagicMetadata ?? this.pubMagicMetadata;
|
||||
..fileType = fileType ?? this.fileType;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ enum FileType {
|
||||
image,
|
||||
video,
|
||||
livePhoto,
|
||||
other,
|
||||
other;
|
||||
|
||||
bool get isVideo => this == FileType.video;
|
||||
}
|
||||
|
||||
int getInt(FileType fileType) {
|
||||
@@ -35,7 +37,7 @@ FileType getFileType(int fileType) {
|
||||
}
|
||||
}
|
||||
|
||||
FileType fileTypeFromAsset(AssetEntity asset) {
|
||||
FileType enteTypeFromAsset(AssetEntity asset) {
|
||||
FileType type = FileType.image;
|
||||
switch (asset.type) {
|
||||
case AssetType.image:
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class LocalAssetInfo {
|
||||
final String id;
|
||||
final String? hash;
|
||||
final String? title;
|
||||
final String? relativePath;
|
||||
final int state;
|
||||
|
||||
LocalAssetInfo({
|
||||
required this.id,
|
||||
this.hash,
|
||||
this.title,
|
||||
this.relativePath,
|
||||
required this.state,
|
||||
});
|
||||
|
||||
factory LocalAssetInfo.fromRow(Map<String, Object?> row) {
|
||||
return LocalAssetInfo(
|
||||
id: row['id'] as String,
|
||||
hash: row['hash'] as String?,
|
||||
title: row['title'] as String?,
|
||||
relativePath: row['relative_path'] as String?,
|
||||
state: row['scan_state'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
190
mobile/apps/photos/lib/models/file/remote/asset.dart
Normal file
190
mobile/apps/photos/lib/models/file/remote/asset.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import "package:photos/models/location/location.dart";
|
||||
import "package:photos/models/metadata/common_keys.dart";
|
||||
import "package:photos/models/metadata/file_magic.dart";
|
||||
|
||||
// Represents the remote asset stored in the database
|
||||
// Note: Ensure that the fields in this class matches the database schema
|
||||
// (remote_db -> files table). Keep the field order consistent with the schema.
|
||||
class RemoteAsset {
|
||||
final int id;
|
||||
final int ownerID;
|
||||
final Uint8List fileHeader;
|
||||
final Uint8List thumbHeader;
|
||||
final int creationTime;
|
||||
final int modificationTime;
|
||||
final int type;
|
||||
final int subType;
|
||||
final String title;
|
||||
final int? fileSize;
|
||||
final String? hash;
|
||||
|
||||
final int? visibility;
|
||||
final int? durationInSec;
|
||||
final Location? location;
|
||||
|
||||
final int? height;
|
||||
final int? width;
|
||||
final int? noThumb;
|
||||
final int? sv;
|
||||
final int? mediaType;
|
||||
final int? motionVideoIndex;
|
||||
|
||||
String? caption;
|
||||
final String? uploaderName;
|
||||
|
||||
RemoteAsset({
|
||||
required this.id,
|
||||
required this.ownerID,
|
||||
required this.thumbHeader,
|
||||
required this.fileHeader,
|
||||
required this.subType,
|
||||
required this.type,
|
||||
required this.creationTime,
|
||||
required this.modificationTime,
|
||||
required this.title,
|
||||
this.hash,
|
||||
this.visibility,
|
||||
this.durationInSec,
|
||||
this.location,
|
||||
this.height,
|
||||
this.width,
|
||||
this.sv,
|
||||
this.motionVideoIndex,
|
||||
this.noThumb,
|
||||
this.mediaType,
|
||||
this.uploaderName,
|
||||
this.fileSize,
|
||||
this.caption,
|
||||
});
|
||||
|
||||
// Factory constructor for creating from metadata (if needed for migration)
|
||||
factory RemoteAsset.fromMetadata({
|
||||
required int id,
|
||||
required int ownerID,
|
||||
required Uint8List thumbHeader,
|
||||
required Uint8List fileHeader,
|
||||
required Metadata metadata,
|
||||
Metadata? privateMetadata,
|
||||
Metadata? publicMetadata,
|
||||
Info? info,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id,
|
||||
ownerID: ownerID,
|
||||
thumbHeader: thumbHeader,
|
||||
fileHeader: fileHeader,
|
||||
creationTime: publicMetadata?.data[editTimeKey] ??
|
||||
metadata.data['creationTime'] ??
|
||||
0,
|
||||
title: publicMetadata?.data[editNameKey] ?? metadata.data['title'] ?? "",
|
||||
modificationTime: metadata.data["modificationTime"] ??
|
||||
publicMetadata?.data[editTimeKey] ??
|
||||
metadata.data['creationTime'] ??
|
||||
0,
|
||||
hash: metadata.data['hash'],
|
||||
location: RemoteAsset.parseLocation(publicMetadata, metadata),
|
||||
durationInSec: metadata.data['duration'] ?? 0,
|
||||
fileSize: info?.fileSize,
|
||||
subType: metadata.data['subType'] ?? -1,
|
||||
type: metadata.data['fileType'] ?? -1,
|
||||
height: safeParseInt(publicMetadata?.data[heightKey], heightKey),
|
||||
width: safeParseInt(publicMetadata?.data[widthKey], widthKey),
|
||||
sv: publicMetadata?.data[streamVersionKey],
|
||||
motionVideoIndex: publicMetadata?.data[motionVideoIndexKey],
|
||||
noThumb: (publicMetadata?.data[noThumbKey] ??
|
||||
metadata.data["hasStaticThumbnail"] ??
|
||||
false)
|
||||
? 1
|
||||
: 0,
|
||||
caption: publicMetadata?.data[captionKey],
|
||||
mediaType: publicMetadata?.data[mediaTypeKey],
|
||||
uploaderName: publicMetadata?.data[uploaderNameKey],
|
||||
visibility: privateMetadata?.data[magicKeyVisibility],
|
||||
);
|
||||
}
|
||||
|
||||
RemoteAsset copyWith({
|
||||
int? id,
|
||||
int? ownerID,
|
||||
Uint8List? thumbHeader,
|
||||
Uint8List? fileHeader,
|
||||
int? subType,
|
||||
int? type,
|
||||
int? creationTime,
|
||||
int? modificationTime,
|
||||
String? title,
|
||||
String? hash,
|
||||
int? visibility,
|
||||
int? durationInSec,
|
||||
Location? location,
|
||||
int? height,
|
||||
int? width,
|
||||
int? sv,
|
||||
int? motionVideoIndex,
|
||||
int? noThumb,
|
||||
int? mediaType,
|
||||
String? deviceFolder,
|
||||
String? uploaderName,
|
||||
int? fileSize,
|
||||
String? caption,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
ownerID: ownerID ?? this.ownerID,
|
||||
thumbHeader: thumbHeader ?? this.thumbHeader,
|
||||
fileHeader: fileHeader ?? this.fileHeader,
|
||||
subType: subType ?? this.subType,
|
||||
type: type ?? this.type,
|
||||
creationTime: creationTime ?? this.creationTime,
|
||||
modificationTime: modificationTime ?? this.modificationTime,
|
||||
title: title ?? this.title,
|
||||
hash: hash ?? this.hash,
|
||||
visibility: visibility ?? this.visibility,
|
||||
durationInSec: durationInSec ?? this.durationInSec,
|
||||
location: location ?? this.location,
|
||||
height: height ?? this.height,
|
||||
width: width ?? this.width,
|
||||
sv: sv ?? this.sv,
|
||||
motionVideoIndex: motionVideoIndex ?? this.motionVideoIndex,
|
||||
noThumb: noThumb ?? this.noThumb,
|
||||
mediaType: mediaType ?? this.mediaType,
|
||||
uploaderName: uploaderName ?? this.uploaderName,
|
||||
fileSize: fileSize ?? this.fileSize,
|
||||
caption: caption ?? this.caption,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isArchived {
|
||||
return visibility == archiveVisibility;
|
||||
}
|
||||
|
||||
FileType get fileType {
|
||||
return getFileType(type);
|
||||
}
|
||||
|
||||
static Location? parseLocation(Metadata? publicMetadata, Metadata metadata) {
|
||||
if (publicMetadata?.data[latKey] != null) {
|
||||
return Location(
|
||||
latitude: publicMetadata!.data[latKey],
|
||||
longitude: publicMetadata!.data[longKey],
|
||||
);
|
||||
}
|
||||
if (metadata.data['latitude'] == null ||
|
||||
metadata.data['longitude'] == null) {
|
||||
return null;
|
||||
}
|
||||
final latitude = double.tryParse(metadata.data["latitude"].toString());
|
||||
final longitude = double.tryParse(metadata.data["longitude"].toString());
|
||||
if (latitude == null ||
|
||||
longitude == null ||
|
||||
(latitude == 0.0 && longitude == 0.0)) {
|
||||
return null;
|
||||
} else {
|
||||
return Location(latitude: latitude, longitude: longitude);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import "dart:typed_data";
|
||||
|
||||
class CollectionFile {
|
||||
final int collectionID;
|
||||
final int fileID;
|
||||
final Uint8List encFileKey;
|
||||
final Uint8List encFileKeyNonce;
|
||||
final int updatedAt;
|
||||
final int createdAt;
|
||||
|
||||
CollectionFile({
|
||||
required this.collectionID,
|
||||
required this.fileID,
|
||||
required this.encFileKey,
|
||||
required this.encFileKeyNonce,
|
||||
required this.updatedAt,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
CollectionFile.fromMap(Map<String, dynamic> map)
|
||||
: collectionID = map["collection_id"] as int,
|
||||
fileID = map["file_id"] as int,
|
||||
encFileKey = map["enc_key"] as Uint8List,
|
||||
encFileKeyNonce = map["enc_key_nonce"] as Uint8List,
|
||||
updatedAt = map["updated_at"] as int,
|
||||
createdAt = map["created_at"] as int;
|
||||
}
|
||||
57
mobile/apps/photos/lib/models/file/remote/rl_mapping.dart
Normal file
57
mobile/apps/photos/lib/models/file/remote/rl_mapping.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
class RLMapping {
|
||||
final int remoteUploadID;
|
||||
final String localID;
|
||||
final String? localCloudID;
|
||||
final MatchType mappingType;
|
||||
|
||||
RLMapping({
|
||||
required this.remoteUploadID,
|
||||
required this.localID,
|
||||
required this.localCloudID,
|
||||
required this.mappingType,
|
||||
});
|
||||
|
||||
List<Object?> get rowValues => [
|
||||
remoteUploadID,
|
||||
localID,
|
||||
localCloudID,
|
||||
mappingType.name,
|
||||
];
|
||||
}
|
||||
|
||||
enum MatchType {
|
||||
localID,
|
||||
cloudID,
|
||||
deviceUpload,
|
||||
deviceHashMatched,
|
||||
}
|
||||
|
||||
extension MappingTypeExtension on MatchType {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case MatchType.localID:
|
||||
return "localID";
|
||||
case MatchType.cloudID:
|
||||
return "cloudID";
|
||||
case MatchType.deviceUpload:
|
||||
return "deviceUpload";
|
||||
case MatchType.deviceHashMatched:
|
||||
return "deviceHashMatched";
|
||||
}
|
||||
}
|
||||
|
||||
static MatchType fromName(String name) {
|
||||
switch (name) {
|
||||
case "localID":
|
||||
return MatchType.localID;
|
||||
case "cloudID":
|
||||
return MatchType.cloudID;
|
||||
case "deviceUpload":
|
||||
return MatchType.deviceUpload;
|
||||
case "deviceHashMatched":
|
||||
return MatchType.deviceHashMatched;
|
||||
default:
|
||||
throw Exception("Unknown mapping type: $name");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import 'package:photos/models/file/file.dart';
|
||||
|
||||
class TrashFile extends EnteFile {
|
||||
// time when file was put in the trash for first time
|
||||
late int createdAt;
|
||||
|
||||
// for non-deleted trash items, updateAt is usually equal to the latest time
|
||||
// when the file was moved to trash
|
||||
late int updateAt;
|
||||
|
||||
// time after which will will be deleted from trash & user's storage usage
|
||||
// will go down
|
||||
late int deleteBy;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class FilesSplit {
|
||||
ownedByOtherUsers = [],
|
||||
pendingUploads = [];
|
||||
for (var f in files) {
|
||||
if (f.ownerID == null || f.uploadedFileID == null) {
|
||||
if (f.ownerID == null || !f.isUploaded) {
|
||||
pendingUploads.add(f);
|
||||
} else if (f.ownerID == currentUserID) {
|
||||
ownedByCurrentUser.add(f);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:photos/models/file/trash_file.dart';
|
||||
import "package:photos/models/api/diff/diff.dart";
|
||||
|
||||
const kIgnoreReasonTrash = "trash";
|
||||
|
||||
@@ -10,21 +10,21 @@ class IgnoredFile {
|
||||
|
||||
IgnoredFile(this.localID, this.title, this.deviceFolder, this.reason);
|
||||
|
||||
static fromTrashItem(TrashFile? trashFile) {
|
||||
if (trashFile == null) return null;
|
||||
if (trashFile.localID == null ||
|
||||
trashFile.localID!.isEmpty ||
|
||||
trashFile.title == null ||
|
||||
trashFile.title!.isEmpty ||
|
||||
trashFile.deviceFolder == null ||
|
||||
trashFile.deviceFolder!.isEmpty) {
|
||||
static fromTrashItem(DiffItem? item) {
|
||||
if (item == null) return null;
|
||||
final fileItem = item.fileItem;
|
||||
if (fileItem.localID == null ||
|
||||
fileItem.localID!.isEmpty ||
|
||||
fileItem.nonEditedTitle.isEmpty ||
|
||||
fileItem.deviceFolder == null ||
|
||||
fileItem.deviceFolder!.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return IgnoredFile(
|
||||
trashFile.localID,
|
||||
trashFile.title,
|
||||
trashFile.deviceFolder,
|
||||
fileItem.localID,
|
||||
fileItem.nonEditedTitle,
|
||||
fileItem.deviceFolder,
|
||||
kIgnoreReasonTrash,
|
||||
);
|
||||
}
|
||||
|
||||
57
mobile/apps/photos/lib/models/local/asset.dart
Normal file
57
mobile/apps/photos/lib/models/local/asset.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import "package:photos/models/file/file_type.dart";
|
||||
import "package:photos/models/location/location.dart";
|
||||
|
||||
class LocalAsset {
|
||||
/// The ID of the asset.
|
||||
/// AssetEntity.id
|
||||
final String id;
|
||||
|
||||
final FileType type;
|
||||
|
||||
final int subType;
|
||||
|
||||
final int width;
|
||||
final int height;
|
||||
final int durationInSec;
|
||||
final int orientation;
|
||||
|
||||
/// Whether the asset is favorite on the device.
|
||||
/// See also:
|
||||
/// * [AssetEntity.isFavorite]
|
||||
final bool isFavorite;
|
||||
|
||||
final String title;
|
||||
|
||||
/// See [AssetEntity.relativePath]
|
||||
final String? relativePath;
|
||||
|
||||
final int createdAt;
|
||||
final int modifiedAt;
|
||||
// /// See [AssetEntity.relativePath]
|
||||
final String? mimeType;
|
||||
|
||||
final Location? location;
|
||||
final int scanState;
|
||||
final String? hash;
|
||||
final int? size;
|
||||
|
||||
LocalAsset({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.subType,
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.durationInSec,
|
||||
required this.orientation,
|
||||
required this.isFavorite,
|
||||
required this.title,
|
||||
this.relativePath,
|
||||
required this.createdAt,
|
||||
required this.modifiedAt,
|
||||
this.mimeType,
|
||||
this.location,
|
||||
required this.scanState,
|
||||
this.hash,
|
||||
this.size,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user