Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
219f99e516 | ||
|
|
1890c0ab6b | ||
|
|
a78e08bac1 | ||
|
|
634169235a | ||
|
|
45ffa65173 | ||
|
|
62cb14e4b6 | ||
|
|
3d7e9b7184 | ||
|
|
d2807b8d6a | ||
|
|
ed386dd12a | ||
|
|
dadcf49eca | ||
|
|
4a9f58bf9b | ||
|
|
9d225d3d06 | ||
|
|
268a9c4803 | ||
|
|
bddeb03fd5 | ||
|
|
f0bb50b61a | ||
|
|
7e9fc4aa97 | ||
|
|
e57c926676 | ||
|
|
f3b17d8f73 | ||
|
|
41af76bbe2 | ||
|
|
9af5e7838f | ||
|
|
5dacea6f74 | ||
|
|
18fcca2884 | ||
|
|
8222327299 | ||
|
|
eebe9bcd5f | ||
|
|
41befc0948 | ||
|
|
09bf1c9175 | ||
|
|
332a8d80f2 | ||
|
|
56eb7bf0fc | ||
|
|
99e9c2ada6 | ||
|
|
d8ecefaea5 | ||
|
|
b8d6cc1e09 | ||
|
|
f36c40bc6b | ||
|
|
83b63ca12e | ||
|
|
f57acc0802 | ||
|
|
29981b1088 | ||
|
|
43f4dac3ad | ||
|
|
2370c9ef41 | ||
|
|
ebb50476ac | ||
|
|
2ea080cacd | ||
|
|
b56f22aac3 | ||
|
|
9033e7f179 | ||
|
|
9070a361bc | ||
|
|
d8e66acd02 | ||
|
|
687d896c63 | ||
|
|
0243570c0b | ||
|
|
66ccf298ba | ||
|
|
982dcd7b8d | ||
|
|
c68702c0a7 | ||
|
|
98a7412855 | ||
|
|
104880a729 | ||
|
|
c48d4f01dc | ||
|
|
8d5bf93360 | ||
|
|
2f9d0a2404 | ||
|
|
36b21948bf | ||
|
|
4dffae3f39 | ||
|
|
35fa6397ea | ||
|
|
4a8887f37b | ||
|
|
fc93762230 | ||
|
|
ebd3f7f125 | ||
|
|
81009c17bf | ||
|
|
beb92e8ffb | ||
|
|
7b4e36e990 | ||
|
|
192e950567 | ||
|
|
126dd45751 | ||
|
|
ff331ffad9 | ||
|
|
e571880c16 | ||
|
|
e5b4d09827 | ||
|
|
81d51fbd7e | ||
|
|
02f9b40d67 | ||
|
|
260a600bbc | ||
|
|
818005fcb5 | ||
|
|
e5f704cf3b | ||
|
|
e2f1e38472 | ||
|
|
b3c82d5ba2 | ||
|
|
6d1868a6e0 | ||
|
|
98db9331d8 | ||
|
|
66e860a08e | ||
|
|
3172c341e0 | ||
|
|
8234234c48 | ||
|
|
8d5e782fc4 | ||
|
|
10d10d9021 | ||
|
|
68d6d89a3b | ||
|
|
3e73cfb71a | ||
|
|
d7e970dcea | ||
|
|
fb7249d1f6 | ||
|
|
521436dd21 | ||
|
|
c145963b02 | ||
|
|
098ab9eae5 | ||
|
|
a937efe719 | ||
|
|
b7fcec7ce3 | ||
|
|
69c23aa3ec | ||
|
|
0a22e64799 | ||
|
|
c3d6d69262 | ||
|
|
7cb78ed972 | ||
|
|
cc70f5f6a0 | ||
|
|
85efbc6984 | ||
|
|
3a44e8f8d3 | ||
|
|
9bada51d56 | ||
|
|
7bc6e9ef64 | ||
|
|
ea797c1723 | ||
|
|
f63d6d5b67 | ||
|
|
8873c9a02f | ||
|
|
ee0e131efa | ||
|
|
af5a9d9108 | ||
|
|
56cf9464af | ||
|
|
9676412875 | ||
|
|
54bea23485 | ||
|
|
3053cbd4c8 | ||
|
|
07069c3b1e | ||
|
|
c0ce81ca0e | ||
|
|
3bef456923 | ||
|
|
91e2348381 | ||
|
|
1564ed3256 | ||
|
|
b8fec26115 | ||
|
|
dd86aa9259 | ||
|
|
84e4c15ed5 | ||
|
|
25d1b3e1b1 | ||
|
|
0e63efb490 | ||
|
|
014d164d99 | ||
|
|
fc64be6603 | ||
|
|
9a7e48eaa6 | ||
|
|
e050121dbf | ||
|
|
f0a5d39625 | ||
|
|
86f5ceb80e | ||
|
|
06959a9ea5 | ||
|
|
acdc66413c | ||
|
|
816db700e1 | ||
|
|
9030b1f89f | ||
|
|
2e0c7abd65 | ||
|
|
1a633f3fca | ||
|
|
dda735ec51 | ||
|
|
f1c98ac9e6 | ||
|
|
7d07aaeba3 | ||
|
|
a0163d8df0 | ||
|
|
49ef86173f | ||
|
|
b6c6a7e403 | ||
|
|
672560f55b | ||
|
|
94cbbf3c4b | ||
|
|
40b802a5a9 | ||
|
|
a63f027bf7 | ||
|
|
1c02e1dadf | ||
|
|
63b6a71ebd | ||
|
|
0a9b632e48 | ||
|
|
7fcc5a5417 | ||
|
|
9cec6aaf46 | ||
|
|
a3206bf950 | ||
|
|
9c627920dd | ||
|
|
8a421eb778 | ||
|
|
14e681c954 | ||
|
|
095a5e0ffb | ||
|
|
b1d31a4567 |
2
.github/workflows/build-mobile.yml
vendored
2
.github/workflows/build-mobile.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.13.3"
|
||||
flutter-version: "3.13.6"
|
||||
cache: true
|
||||
|
||||
- name: Create the Keystore
|
||||
|
||||
2
.github/workflows/static_analysis.yml
vendored
2
.github/workflows/static_analysis.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.13.3"
|
||||
flutter-version: "3.13.6"
|
||||
|
||||
- name: Install dependencies
|
||||
run: dart pub get
|
||||
|
||||
33
.github/workflows/test.yml
vendored
33
.github/workflows/test.yml
vendored
@@ -13,20 +13,15 @@ jobs:
|
||||
e2e-tests:
|
||||
name: Run end-to-end test suites
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run npm install
|
||||
run: npm ci
|
||||
with:
|
||||
submodules: "recursive"
|
||||
|
||||
- name: Run e2e tests
|
||||
run: npm run test:e2e
|
||||
if: ${{ !cancelled() }}
|
||||
run: docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
|
||||
|
||||
doc-tests:
|
||||
name: Run documentation checks
|
||||
@@ -149,7 +144,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.13.3"
|
||||
flutter-version: "3.13.6"
|
||||
- name: Run tests
|
||||
working-directory: ./mobile
|
||||
run: flutter test -j 1
|
||||
@@ -223,15 +218,27 @@ jobs:
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm --prefix server ci
|
||||
run: npm ci
|
||||
|
||||
- name: Build the
|
||||
run: npm run build
|
||||
|
||||
- name: Run existing migrations
|
||||
run: npm --prefix server run typeorm:migrations:run
|
||||
run: npm run typeorm:migrations:run
|
||||
|
||||
- name: Generate new migrations
|
||||
continue-on-error: true
|
||||
run: npm --prefix server run typeorm:migrations:generate ./src/infra/migrations/TestMigration
|
||||
run: npm run typeorm:migrations:generate ./src/infra/migrations/TestMigration
|
||||
|
||||
- name: Find file changes
|
||||
uses: tj-actions/verify-changed-files@v13.1
|
||||
id: verify-changed-files
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
.idea
|
||||
|
||||
docker/upload
|
||||
docker/library
|
||||
uploads
|
||||
coverage
|
||||
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +1,6 @@
|
||||
[submodule "mobile/.isar"]
|
||||
path = mobile/.isar
|
||||
url = https://github.com/isar/isar
|
||||
[submodule "server/test/assets"]
|
||||
path = server/test/assets
|
||||
url = https://github.com/immich-app/test-assets
|
||||
|
||||
2
Makefile
2
Makefile
@@ -20,7 +20,7 @@ pull-stage:
|
||||
docker-compose -f ./docker/docker-compose.staging.yml pull
|
||||
|
||||
test-e2e:
|
||||
docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
|
||||
docker-compose -f ./docker/docker-compose.test.yml -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
|
||||
|
||||
prod:
|
||||
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<a href="README_ca_ES.md">Català</a>
|
||||
<a href="README_es_ES.md">Español</a>
|
||||
<a href="README_fr_FR.md">Français</a>
|
||||
<a href="README_nl_NL.md">Nederlands</a>
|
||||
<a href="README_ja_JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
## Disclaimer
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<a href="README_tr_TR.md">Türkçe</a>
|
||||
<a href="README_ca_ES.md">Español</a>
|
||||
<a href="README_fr_FR.md">Français</a>
|
||||
<a href="README_nl_NL.md">Nederlands</a>
|
||||
<a href="README_ja_JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
## Avís legal
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<a href="README_tr_TR.md">Türkçe</a>
|
||||
<a href="README_ca_ES.md">Català</a>
|
||||
<a href="README_fr_FR.md">Français</a>
|
||||
<a href="README_ja_JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
## Descargo de responsabilidad
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<a href="README_ca_ES.md">Català</a>
|
||||
<a href="README_es_ES.md">Español</a>
|
||||
<a href="README_fr_FR.md">Français</a>
|
||||
<a href="README_nl_NL.md">Nederlands</a>
|
||||
<a href="README_ja_JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
## Clause de non-responsabilité
|
||||
|
||||
111
README_ja_JP.md
Normal file
111
README_ja_JP.md
Normal file
@@ -0,0 +1,111 @@
|
||||
<p align="center">
|
||||
<br/>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
|
||||
<a href="https://discord.gg/D8JsnBEuKb">
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
|
||||
</a>
|
||||
<br/>
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="design/immich-logo.svg" width="150" title="Login With Custom URL">
|
||||
</p>
|
||||
<h3 align="center">Immich - 高性能なセルフホスト 写真/ビデオバックアップソリューション</h3>
|
||||
<br/>
|
||||
<a href="https://immich.app">
|
||||
<img src="design/immich-screenshots.png" title="Main Screenshot">
|
||||
</a>
|
||||
<br/>
|
||||
<p align="center">
|
||||
<a href="README_zh_CN.md">中文</a>
|
||||
<a href="README_tr_TR.md">Türkçe</a>
|
||||
<a href="README_ca_ES.md">Català</a>
|
||||
<a href="README_es_ES.md">Español</a>
|
||||
<a href="README_fr_FR.md">Français</a>
|
||||
<a href="README_nl_NL.md">Nederlands</a>
|
||||
</p>
|
||||
|
||||
## 免責事項
|
||||
|
||||
- ⚠️ このプロジェクトは **非常に活発に** 開発中です。
|
||||
- ⚠️ バグの存在や変更が入ることも予想されます。
|
||||
- ⚠️ **写真やビデオを保存する唯一の方法としてこのアプリを使用しないでください。**
|
||||
- ⚠️ 大切な写真やビデオは、常に [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) のバックアッププランに従ってください!
|
||||
|
||||
## コンテンツ
|
||||
|
||||
- [公式ドキュメント](https://immich.app/docs)
|
||||
- [ロードマップ](https://github.com/orgs/immich-app/projects/1)
|
||||
- [デモ](#デモ)
|
||||
- [機能](#機能)
|
||||
- [紹介](https://immich.app/docs/overview/introduction)
|
||||
- [インストール](https://immich.app/docs/install/requirements)
|
||||
- [コントリビューションガイド](https://immich.app/docs/overview/support-the-project)
|
||||
- [プロジェクトのサポート](#プロジェクトのサポート)
|
||||
|
||||
## ドキュメント
|
||||
|
||||
インストールガイドを含む主なドキュメントは、https://immich.app/ です。
|
||||
|
||||
## デモ
|
||||
|
||||
web デモは https://demo.immich.app からアクセスできます
|
||||
|
||||
モバイルアプリの場合、`Server Endpoint URL` には `https://demo.immich.app/api` を使用することができます
|
||||
|
||||
```bash title="Demo Credential"
|
||||
The credential
|
||||
email: demo@immich.app
|
||||
password: demo
|
||||
```
|
||||
|
||||
```
|
||||
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
```
|
||||
|
||||
# 機能
|
||||
|
||||
| 機能 | モバイル | Web |
|
||||
| ------------------------------------------- | ------ | --- |
|
||||
| ビデオや写真のアップロードと表示 | はい | はい |
|
||||
| アプリを開いたとき自動バックアップ | はい | N/A |
|
||||
| バックアップ用アルバム選択 | はい | N/A |
|
||||
| 写真やビデオをローカルデバイスにダウンロード | はい | はい |
|
||||
| マルチユーザー対応 | はい | はい |
|
||||
| アルバムと共有アルバム | はい | はい |
|
||||
| スクラブ可能/ドラッグ可能スクロールバ | はい | はい |
|
||||
| 生のフォーマットに対応 | はい | はい |
|
||||
| メタデータ表示(EXIF、地図) | はい | はい |
|
||||
| メタデータ、オブジェクト、フェース、CLIPによる検索 | はい | はい |
|
||||
| 管理機能(ユーザー管理) | いいえ | はい |
|
||||
| バックグラウンドバックアップ | はい | N/A |
|
||||
| 仮想スクロール | はい | はい |
|
||||
| OAuth サポート | はい | はい |
|
||||
| API キー | N/A | はい |
|
||||
| LivePhoto のバックアップと再生 | iOS | はい |
|
||||
| ユーザー定義のストレージ構造 | はい | はい |
|
||||
| 公開シェアリング | いいえ | はい |
|
||||
| アーカイブとお気に入り | はい | はい |
|
||||
| グローバルマップ | はい | はい |
|
||||
| パートナー共有 | はい | はい |
|
||||
| 思い出(x 年前)顔認識とクラスタリング | はい | はい |
|
||||
| 思い出(x 年前) | はい | はい |
|
||||
| オフラインサポート | はい | いいえ |
|
||||
| 読み取り専用ギャラリー | はい | はい |
|
||||
|
||||
# プロジェクトのサポート
|
||||
|
||||
私はこのプロジェクトにコミットしてきました。ドキュメントを更新し、新しい機能を追加し、バグを修正し続けるつもりですが、私ひとりではできません。だから、続けるためのモチベーションをさらに高めてくれる皆さんの助けが必要なのです。
|
||||
|
||||
[selfhosted.show - In the episode 'The-organization-must-いいえt-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) のホストが言ったように、これはチームと私がやっていることの大規模な事業だ。そしていつの日か、フルタイムでこの仕事ができるようになりたいと思っています。
|
||||
|
||||
もし、あなたがこのプロジェクトに賛同し、このアプリを長く使い続けたいと思われるのであれば、以下のオプションから支援をご検討ください。
|
||||
|
||||
## 寄付
|
||||
|
||||
- GitHub スポンサー経由の[毎月の寄付](https://github.com/sponsors/alextran1502)
|
||||
- GitHub スポンサー経由の[一回寄付](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
|
||||
- [Librepay](https://liberapay.com/alex.tran1502/)
|
||||
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
|
||||
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
|
||||
112
README_nl_NL.md
Normal file
112
README_nl_NL.md
Normal file
@@ -0,0 +1,112 @@
|
||||
<p align="center">
|
||||
<br/>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: MIT"></a>
|
||||
<a href="https://discord.gg/D8JsnBEuKb">
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
|
||||
</a>
|
||||
<br/>
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="design/immich-logo.svg" width="150" title="Login met aangepaste URL">
|
||||
</p>
|
||||
<h3 align="center">Immich - Hoogwaardige, self-hosted back-up oplossing voor foto's en video's</h3>
|
||||
<br/>
|
||||
<a href="https://immich.app">
|
||||
<img src="design/immich-screenshots.png" title="Main Screenshot">
|
||||
</a>
|
||||
<br/>
|
||||
<p align="center">
|
||||
<a href="README_zh_CN.md">中文</a>
|
||||
<a href="README_tr_TR.md">Türkçe</a>
|
||||
<a href="README_ca_ES.md">Català</a>
|
||||
<a href="README_es_ES.md">Español</a>
|
||||
<a href="README_fr_FR.md">Français</a>
|
||||
<a href="README_nl_NL.md">Nederlands</a>
|
||||
<a href="README_ja_JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
## Disclaimer
|
||||
|
||||
- ⚠️ Het project wordt momenteel **zeer actief** ontwikkeld.
|
||||
- ⚠️ Verwacht bugs en ingrijpende wijzigingen.
|
||||
- ⚠️ **Gebruik de app niet als de enige manier om uw foto's en video's op te slaan.**
|
||||
- ⚠️ Volg altijd het [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan voor je kostbare foto's en video's!
|
||||
|
||||
## Inhoud
|
||||
|
||||
- [Officiële documentatie](https://immich.app/docs)
|
||||
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Functies](#functies)
|
||||
- [Introductie](https://immich.app/docs/overview/introduction)
|
||||
- [Installatie](https://immich.app/docs/install/requirements)
|
||||
- [Richtlijnen voor bijdragen](https://immich.app/docs/overview/support-the-project)
|
||||
- [Steun het project](#steun-het-project)
|
||||
|
||||
## Documentatie
|
||||
|
||||
De belangrijkste documentatie, inclusief installatie handleidingen, zijn te vinden op https://immich.app/.
|
||||
|
||||
## Demo
|
||||
|
||||
De demo is te bekijken op https://demo.immich.app.
|
||||
|
||||
Voor de mobiele app kunt u gebruik maken van `https://demo.immich.app/api` voor de `Server Endpoint URL`
|
||||
|
||||
```bash title="Demo Credential"
|
||||
De inloggegevens
|
||||
email: demo@immich.app
|
||||
wachtwoord: demo
|
||||
```
|
||||
|
||||
```
|
||||
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
```
|
||||
|
||||
# Functies
|
||||
|
||||
| Functies | Mobiel | Web |
|
||||
|-----------------------------------------------------|--------|-----|
|
||||
| Upload en bekijk video's en foto's | Ja | Ja |
|
||||
| Automatische back-up wanneer de app wordt geopend | Ja | NVT |
|
||||
| Selectieve album(s) voor back-up | Ja | NVT |
|
||||
| Download foto's en video's naar een lokaal apparaat | Ja | Ja |
|
||||
| Ondersteuning voor meerdere gebruikers | Ja | Ja |
|
||||
| Album en gedeelde albums | Ja | Ja |
|
||||
| Versleepbare scroll balk | Ja | Ja |
|
||||
| Ondersteuning voor het RAW formaat | Ja | Ja |
|
||||
| Metagegevensweergave (EXIF, kaart) | Ja | Ja |
|
||||
| Zoek op metagegevens, objecten, gezichten en CLIP | Ja | Ja |
|
||||
| Administratieve functies (gebruikersbeheer) | Nee | Ja |
|
||||
| Back-up op de achtergrond | Ja | NVT |
|
||||
| Virtueel scrollen | Ja | Ja |
|
||||
| OAuth-ondersteuning | Ja | Ja |
|
||||
| API-sleutels | NVT | Ja |
|
||||
| LivePhoto-back-up en weergave | iOS | Ja |
|
||||
| Door de gebruiker gedefinieerde opslagstructuur | Ja | Ja |
|
||||
| Openbaar delen | Nee | Ja |
|
||||
| Archief en Favorieten | Ja | Ja |
|
||||
| Wereldkaart | Ja | Ja |
|
||||
| Delen met partner | Ja | Ja |
|
||||
| Gezichtsherkenning en groepering | Ja | Ja |
|
||||
| Herinneringen (x jaar geleden) | Ja | Ja |
|
||||
| Offline-ondersteuning | Ja | Nee |
|
||||
| Alleen-lezen galerij | Ja | Ja |
|
||||
|
||||
# Steun het project
|
||||
|
||||
Ik ben trouw aan dit project en ik zal niet stoppen. Ik zal de documenten blijven bijwerken, nieuwe functies toevoegen en bugs oplossen. Maar ik kan het niet alleen. Ik heb dus jouw hulp nodig om mij extra motivatie te geven om door te gaan.
|
||||
|
||||
Als onze gastheren in de [selfhosted.show - In de aflevering 'The-organization-must-Neet-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) zeiden, dit is een eNeerme onderneming van wat het team en ik doen. En ik zou dit graag fulltime willen doen, ik vraag jouw hulp om dat mogelijk te maken.
|
||||
|
||||
Als je denkt dat dit het juiste doel is en de app iets is dat je jezelf al heel lang ziet gebruiken, overweeg dan om het project te steunen met de onderstaande optie.
|
||||
|
||||
## Doneren
|
||||
|
||||
- [Maandelijkse donatie](https://github.com/sponsors/alextran1502) via GitHub Sponsors
|
||||
- [Eenmalige donatie](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
|
||||
- [Librepay](https://liberapay.com/alex.tran1502/)
|
||||
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
|
||||
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
|
||||
@@ -23,6 +23,8 @@
|
||||
<a href="README_ca_ES.md">Català</a>
|
||||
<a href="README_es_ES.md">Español</a>
|
||||
<a href="README_fr_FR.md">Français</a>
|
||||
<a href="README_nl_NL.md">Nederlands</a>
|
||||
<a href="README_ja_JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
## Feragatname
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<a href="README_ca_ES.md">Català</a>
|
||||
<a href="README_es_ES.md">Español</a>
|
||||
<a href="README_fr_FR.md">Français</a>
|
||||
<a href="README_nl_NL.md">Nederlands</a>
|
||||
<a href="README_ja_JP.md">日本語</a>
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ module.exports = {
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'prettier/prettier': 0,
|
||||
},
|
||||
};
|
||||
|
||||
6808
cli/package-lock.json
generated
6808
cli/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
2216
cli/src/api/open-api/api.ts
generated
2216
cli/src/api/open-api/api.ts
generated
File diff suppressed because it is too large
Load Diff
2
cli/src/api/open-api/base.ts
generated
2
cli/src/api/open-api/base.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.78.0
|
||||
* The version of the OpenAPI document: 1.82.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/common.ts
generated
2
cli/src/api/open-api/common.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.78.0
|
||||
* The version of the OpenAPI document: 1.82.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/configuration.ts
generated
2
cli/src/api/open-api/configuration.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.78.0
|
||||
* The version of the OpenAPI document: 1.82.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
2
cli/src/api/open-api/index.ts
generated
2
cli/src/api/open-api/index.ts
generated
@@ -4,7 +4,7 @@
|
||||
* Immich
|
||||
* Immich API
|
||||
*
|
||||
* The version of the OpenAPI document: 1.78.0
|
||||
* The version of the OpenAPI document: 1.82.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
||||
@@ -34,6 +34,7 @@ const other = [
|
||||
'orf',
|
||||
'ori',
|
||||
'pef',
|
||||
'psd',
|
||||
'raf',
|
||||
'raw',
|
||||
'rwl',
|
||||
|
||||
@@ -25,7 +25,7 @@ export class CrawledAsset {
|
||||
async process() {
|
||||
const stats = await fs.promises.stat(this.path);
|
||||
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replace(/\s+/g, '');
|
||||
this.fileCreatedAt = stats.ctime.toISOString();
|
||||
this.fileCreatedAt = stats.mtime.toISOString();
|
||||
this.fileModifiedAt = stats.mtime.toISOString();
|
||||
this.fileSize = stats.size;
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ program
|
||||
)
|
||||
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
|
||||
.argument('[paths...]', 'One or more paths to assets to be uploaded')
|
||||
.action((paths, options) => {
|
||||
.action(async (paths, options) => {
|
||||
options.excludePatterns = options.ignore;
|
||||
new Upload().run(paths, options);
|
||||
await new Upload().run(paths, options);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -37,18 +37,18 @@ program
|
||||
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
|
||||
.addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
|
||||
.argument('[paths...]', 'One or more paths to assets to be imported')
|
||||
.action((paths, options) => {
|
||||
.action(async (paths, options) => {
|
||||
options.import = true;
|
||||
options.excludePatterns = options.ignore;
|
||||
new Upload().run(paths, options);
|
||||
await new Upload().run(paths, options);
|
||||
});
|
||||
|
||||
program
|
||||
.command('server-info')
|
||||
.description('Display server information')
|
||||
|
||||
.action(() => {
|
||||
new ServerInfo().run();
|
||||
.action(async () => {
|
||||
await new ServerInfo().run();
|
||||
});
|
||||
|
||||
program
|
||||
@@ -56,8 +56,8 @@ program
|
||||
.description('Login using an API key')
|
||||
.argument('[instanceUrl]')
|
||||
.argument('[apiKey]')
|
||||
.action((paths, options) => {
|
||||
new LoginKey().run(paths, options);
|
||||
.action(async (paths, options) => {
|
||||
await new LoginKey().run(paths, options);
|
||||
});
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('SessionService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should create auth file when logged in', async () => {
|
||||
it.skip('should create auth file when logged in', async () => {
|
||||
mockfs();
|
||||
|
||||
await sessionService.keyLogin('https://test/api', 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg');
|
||||
|
||||
@@ -53,7 +53,14 @@ export class SessionService {
|
||||
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
// Create config folder if it doesn't exist
|
||||
fs.mkdirSync(this.configDir, { recursive: true });
|
||||
const created = await fs.promises.mkdir(this.configDir, { recursive: true });
|
||||
if (!created) {
|
||||
throw new Error(`Failed to create config folder ${this.configDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(this.configDir)) {
|
||||
console.error('waah');
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.authPath, yaml.stringify({ instanceUrl, apiKey }));
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import { UploadService } from './upload.service';
|
||||
import mockfs from 'mock-fs';
|
||||
import axios from 'axios';
|
||||
import mockAxios from 'jest-mock-axios';
|
||||
import FormData from 'form-data';
|
||||
import { ApiConfiguration } from '../cores/api-configuration';
|
||||
|
||||
jest.mock('axios', () => jest.fn());
|
||||
|
||||
describe('UploadService', () => {
|
||||
let uploadService: UploadService;
|
||||
|
||||
beforeAll(() => {
|
||||
// Write a dummy output before mock-fs to prevent some annoying errors
|
||||
console.log();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const apiConfiguration = new ApiConfiguration('https://example.com/api', 'key');
|
||||
|
||||
uploadService = new UploadService(apiConfiguration);
|
||||
});
|
||||
|
||||
it('should upload a single file', async () => {
|
||||
it('should call axios', async () => {
|
||||
const data = new FormData();
|
||||
|
||||
uploadService.upload(data);
|
||||
await uploadService.upload(data);
|
||||
|
||||
mockAxios.mockResponse();
|
||||
expect(axios).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockfs.restore();
|
||||
mockAxios.reset();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,21 +42,21 @@ export class UploadService {
|
||||
};
|
||||
}
|
||||
|
||||
public checkIfAssetAlreadyExists(path: string, checksum: string): Promise<any> {
|
||||
public checkIfAssetAlreadyExists(path: string, checksum: string) {
|
||||
this.checkAssetExistenceConfig.data = JSON.stringify({ assets: [{ id: path, checksum: checksum }] });
|
||||
|
||||
// TODO: retry on 500 errors?
|
||||
return axios(this.checkAssetExistenceConfig);
|
||||
}
|
||||
|
||||
public upload(data: FormData): Promise<any> {
|
||||
public upload(data: FormData) {
|
||||
this.uploadConfig.data = data;
|
||||
|
||||
// TODO: retry on 500 errors?
|
||||
return axios(this.uploadConfig);
|
||||
}
|
||||
|
||||
public import(data: any): Promise<any> {
|
||||
public import(data: any) {
|
||||
this.importConfig.data = data;
|
||||
|
||||
// TODO: retry on 500 errors?
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Database
|
||||
DB_HOSTNAME=immich-database-test
|
||||
DB_USERNAME=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_DATABASE_NAME=e2e_test
|
||||
|
||||
# Redis
|
||||
REDIS_HOSTNAME=immich-redis-test
|
||||
|
||||
# Upload File Config
|
||||
UPLOAD_LOCATION=./upload
|
||||
|
||||
# WEB
|
||||
VITE_SERVER_ENDPOINT=http://localhost:2283/api
|
||||
|
||||
TYPESENSE_ENABLED=false
|
||||
@@ -11,8 +11,9 @@ services:
|
||||
command: npm run start:debug immich
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- 3001:3001
|
||||
- 9230:9230
|
||||
@@ -25,25 +26,6 @@ services:
|
||||
- database
|
||||
- typesense
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
image: immich-machine-learning-dev:latest
|
||||
build:
|
||||
context: ../machine-learning
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- 3003:3003
|
||||
volumes:
|
||||
- ../machine-learning:/usr/src/app
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- database
|
||||
restart: unless-stopped
|
||||
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
image: immich-microservices:latest
|
||||
@@ -57,8 +39,9 @@ services:
|
||||
command: npm run start:debug microservices
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
||||
- /usr/src/app/node_modules
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
@@ -94,6 +77,25 @@ services:
|
||||
depends_on:
|
||||
- immich-server
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
image: immich-machine-learning-dev:latest
|
||||
build:
|
||||
context: ../machine-learning
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- 3003:3003
|
||||
volumes:
|
||||
- ../machine-learning:/usr/src/app
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- database
|
||||
restart: unless-stopped
|
||||
|
||||
typesense:
|
||||
container_name: immich_typesense
|
||||
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
|
||||
@@ -103,7 +105,7 @@ services:
|
||||
# remove this to get debug messages
|
||||
- GLOG_minloglevel=1
|
||||
volumes:
|
||||
- tsdata:/data
|
||||
- ${UPLOAD_LOCATION}/typesense:/data
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
@@ -119,7 +121,7 @@ services:
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
@@ -141,6 +143,4 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
model-cache:
|
||||
tsdata:
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
command: ["./start-server.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
@@ -29,7 +30,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
restart: always
|
||||
|
||||
|
||||
immich-microservices:
|
||||
container_name: immich_microservices
|
||||
image: immich-microservices:latest
|
||||
@@ -42,6 +43,7 @@ services:
|
||||
command: ["./start-microservices.sh"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
version: "3.8"
|
||||
|
||||
# Compose file for dockerized end-to-end testing of the backend
|
||||
|
||||
services:
|
||||
immich-server-test:
|
||||
image: immich-server-test
|
||||
@@ -8,39 +10,31 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
target: builder
|
||||
command: npm run test:e2e
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- /usr/src/app/node_modules
|
||||
env_file:
|
||||
- .env.test
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- TYPESENSE_ENABLED=false
|
||||
- DB_HOSTNAME=immich-database-test
|
||||
- DB_USERNAME=postgres
|
||||
- DB_PASSWORD=postgres
|
||||
- DB_DATABASE_NAME=e2e_test
|
||||
- IMMICH_RUN_ALL_TESTS=true
|
||||
depends_on:
|
||||
- immich-redis-test
|
||||
- immich-database-test
|
||||
networks:
|
||||
- immich-test-network
|
||||
immich-redis-test:
|
||||
container_name: immich-redis-test
|
||||
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
|
||||
networks:
|
||||
- immich-test-network
|
||||
|
||||
immich-database-test:
|
||||
container_name: immich-database-test
|
||||
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
|
||||
env_file:
|
||||
- .env.test
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
volumes:
|
||||
- /var/lib/postgresql/data
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: e2e_test
|
||||
networks:
|
||||
- immich-test-network
|
||||
logging:
|
||||
driver: none
|
||||
|
||||
networks:
|
||||
immich-test-network:
|
||||
|
||||
@@ -4,9 +4,10 @@ services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
command: [ "start.sh", "immich" ]
|
||||
command: ["start.sh", "immich"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
@@ -21,9 +22,10 @@ services:
|
||||
# extends:
|
||||
# file: hwaccel.yml
|
||||
# service: hwaccel
|
||||
command: [ "start.sh", "microservices" ]
|
||||
command: ["start.sh", "microservices"]
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
|
||||
@@ -20,22 +20,9 @@ Immich doesn't have two-way synchronization ([yet](https://github.com/immich-app
|
||||
|
||||
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.
|
||||
|
||||
### Why does my uploaded photo show up with the wrong date or time in Immich?
|
||||
|
||||
When a photo is initially uploaded Immich uses the create date of the file to determine where it belongs in the timeline. After that, background jobs will run that extract [exif metadata](https://en.wikipedia.org/wiki/Exif), including the CreateDate, to provide a more accurate date for the photo. If that is not available it will fallback to the modified date. If you want to ensure your photo has the right date, check the exif metadata before uploading.
|
||||
|
||||
If the timezone is incorrect in an uploaded photo, check the `DateTimeOriginal` exif field of the uploaded file. Immich uses the very competent library [exiftool-vendored.js](https://github.com/photostructure/exiftool-vendored.js#dates) to handle timezone parsing, but in some cases (like photos taken with DSLR cameras) it has to fallback on the local timezone. If you are using docker, this fallback will be UTC. (Note that even the photo backup app that can't be named [has the same bug!](https://photo.stackexchange.com/a/126978)) In Immich, it is possible to change this assumed fallback timezone system-wide by setting the timezone in the microservices docker container. You might need to run the "Extract Metadata" job after to effect the change.
|
||||
|
||||
As an example, the following modification of `docker-compose.yml` will set the timezone of the microservices container to be `Europe/Stockholm`
|
||||
|
||||
```
|
||||
environment:
|
||||
- TZ=Europe/Stockholm # <---- Add this line in the microservices config
|
||||
```
|
||||
|
||||
### Why are only photos and not videos being uploaded to Immich?
|
||||
|
||||
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes.
|
||||
This often happens when using a reverse proxy or cloudflare tunnel in front of Immich. Make sure to set your reverse proxy to allow large POST requests. In `nginx`, set `client_max_body_size 50000M;` or similar. Cloudflare tunnels are limited to 100 mb file sizes. Also check the disk space of your reverse proxy, in some cases proxies caches requests to disk before passing them on, and if disk space runs out the request fails.
|
||||
|
||||
### Why is Immich slow on low-memory systems like the Raspberry Pi?
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Users can deploy a custom reverse proxy that forwards requests to Immich's rever
|
||||
|
||||
### Nginx example config
|
||||
|
||||
Below is an example config for nginx:
|
||||
Below is an example config for nginx. Make sure to include `client_max_body_size 50000M;` also in a `http` block in `/etc/nginx/nginx.conf`.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
|
||||
@@ -16,20 +16,53 @@ To run a command, [connect](/docs/guides/docker-help.md#attach-to-a-container) t
|
||||
|
||||
## Examples
|
||||
|
||||
Note that the commands below should begin with `immich-admin`.
|
||||
|
||||
Reset Admin Password
|
||||
|
||||

|
||||
```
|
||||
immich-admin reset-admin-password
|
||||
Found Admin:
|
||||
- ID=e65e6f88-2a30-4dbe-8dd9-1885f4889b53
|
||||
- OAuth ID=
|
||||
- Email=admin@example.com
|
||||
- Name=Immich Admin
|
||||
? Please choose a new password (optional) immich-is-cool
|
||||
The admin password has been updated.
|
||||
```
|
||||
|
||||
Disable Password Login
|
||||
|
||||

|
||||
```
|
||||
immich-admin disable-password-login
|
||||
Password login has been disabled.
|
||||
```
|
||||
|
||||
Enabled Password Login
|
||||
|
||||

|
||||
```
|
||||
immich-admin enable-password-login
|
||||
Password login has been enabled.
|
||||
```
|
||||
|
||||
List Users
|
||||
|
||||

|
||||
```
|
||||
immich-admin list-users
|
||||
[
|
||||
{
|
||||
id: 'e65e6f88-2a30-4dbe-8dd9-1885f4889b53',
|
||||
email: 'immich@example.com.com',
|
||||
firstName: 'Immich',
|
||||
lastName: 'Admin',
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
profileImagePath: 'upload/profile/e65e6f88-2a30-4dbe-8dd9-1885f4889b53/e65e6f88-2a30-4dbe-8dd9-1885f4889b53.jpg',
|
||||
shouldChangePassword: true,
|
||||
isAdmin: true,
|
||||
createdAt: 2023-07-11T20:12:20.602Z,
|
||||
deletedAt: null,
|
||||
updatedAt: 2023-09-21T15:42:28.129Z,
|
||||
oauthId: '',
|
||||
memoriesEnabled: true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
17
docs/docs/developer/testing.md
Normal file
17
docs/docs/developer/testing.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Testing
|
||||
|
||||
## Server
|
||||
|
||||
### Unit tests
|
||||
|
||||
Unit are run by calling `npm run test` from the `server` directory.
|
||||
|
||||
### End to end tests
|
||||
|
||||
The backend has an end-to-end test suite that can be called with `npm run test:e2e` from the `server` directory. This will set up a dummy database inside a temporary container and run the tests against it. Setup and teardown is automatically taken care of. That test, however, can not set up all prerequisites to parse file formats, as that is very complex and error-prone. As such, this test excludes some test cases like HEIC file imports. The test suite will also print a friendly warning to remind you that not all tests are being run.
|
||||
|
||||
Note that there is a bug in nodejs <20.8 that causes segmentation faults when running these tests. If you run into segfaults, ensure you are using at least version 20.8.
|
||||
|
||||
To perform a full e2e test, you need to run e2e tests inside docker. The easiest way to do that is to run `make test-e2e` in the root directory. This will build and start a docker-compose consisting of the server, microservices, and a postgres database. It will then perfom the tests and exit.
|
||||
|
||||
If you manually install the dependencies (see the DOCKERFILE) on your development machine, you can also run the full e2e tests manually by setting the `IMMICH_RUN_ALL_TESTS` environment value to true, i.e. `IMMICH_RUN_ALL_TESTS=true npm run test:e2e`.
|
||||
@@ -56,8 +56,6 @@ The API key can be obtained in the user setting panel on the web interface.
|
||||
|
||||
---
|
||||
|
||||
## Uploading existing libraries
|
||||
|
||||
### Run via Docker
|
||||
|
||||
You can run the CLI inside of a docker container to avoid needing to install anything.
|
||||
|
||||
170
docs/docs/features/libraries.md
Normal file
170
docs/docs/features/libraries.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Libraries
|
||||
|
||||
## Overview
|
||||
|
||||
Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries.
|
||||
|
||||
## The Upload Library
|
||||
|
||||
Immich comes preconfigured with an upload library for each user. All assets uploaded to Immich are added to this library. This library can be renamed, but not deleted. The upload library is the only library that can be synced with a mobile device. No items in an upload library is allowed to have the same sha1 hash as another item in the same library in order to prevent duplicates.
|
||||
|
||||
## External Libraries
|
||||
|
||||
External libraries tracks assets stored outside of immich, i.e. in the file system. Immich will only read data from the files, and will not modify them in any way. Therefore, the delete button is disabled for external assets. When the external library is scanned, immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc.
|
||||
|
||||
If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case:
|
||||
|
||||
- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets
|
||||
- Scan All Library Files: Same as above, but will check each existing asset to see if the modification time has changed. If it has, the asset will be updated. Since it has to check each asset, this is slower than Scan Library Files.
|
||||
- Force Scan All Library Files: Same as above, but will read each asset from disk no matter the modification time. This is useful in some cases where an asset has been modified externally but the modification time has not changed. This is the slowest way to scan because it reads each asset from disk.
|
||||
|
||||
:::caution
|
||||
|
||||
Due to aggressive caching it can take some time for a refreshed asset to appear correctly in the web view. You need to clear the cache in your browser to see the changes. This is a known issue and will be fixed in a future release. In Chrome, you need to open the developer console with F12, then reload the page with F5, and finally right click on the reload button and select "Empty Cache and Hard Reload".
|
||||
|
||||
:::
|
||||
|
||||
In external libraries, the file path is used for duplicate detection. This means that if a file is moved to a different location, it will be added as a new asset. If the file is moved back to its original location, it will be added as a new asset. In contrast to upload libraries, two identical files can be uploaded if they are in different locations. This is a deliberate design choice to make Immich reflect the file system as closely as possible. Remember that duplication detection is only done within the same library, so if you have multiple external libraries, the same file can be added to multiple libraries.
|
||||
|
||||
:::caution
|
||||
|
||||
If you add assets from an external library to an album and then move the asset to another location within the library, the asset will be removed from the album upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release.
|
||||
|
||||
:::
|
||||
|
||||
### Deleted External Assets
|
||||
|
||||
In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored.
|
||||
|
||||
Finally, files can be deleted from Immich via the `Remove Offline Files` job. Any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. Note that a library scan must be performed first to mark the assets as offline.
|
||||
|
||||
### Import Paths
|
||||
|
||||
External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. If the import paths are edited in a way that an external file is no longer in any import path, it will be removed from the library in the same way a deleted file would. If the file is moved back to an import path, it will be added again as if it was a new file.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Sometimes, an external library will not scan correctly. This can happen if the immich_server or immich_microservices can't access the files. Here are some things to check:
|
||||
|
||||
- Is the external path set correctly?
|
||||
- In the docker-compose file, are the volumes mounted correctly?
|
||||
- Are the volumes identical between the `server` and `microservices` container?
|
||||
- Are the import paths set correctly, and do they match the path set in docker-compose file?
|
||||
- Are the permissions set correctly?
|
||||
|
||||
If all else fails, you can always start a shell inside the container and check if the path is accessible. For example, `docker exec -it immich_microservices /bin/bash` will start a bash shell. If your import path, for instance, is `/data/import/photos`, you can check if the files are accessible by running `ls /data/import/photos`. Also check the `immich_server` container in the same way.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
:::caution
|
||||
|
||||
Please read and understand this section before setting external paths, as there are important security considerations.
|
||||
|
||||
:::
|
||||
|
||||
For security purposes, each Immich user is disallowed to add external files by default. This is to prevent devastating [path traversal attacks](https://owasp.org/www-community/attacks/Path_Traversal). An admin can allow individual users to use external path feature via the `external path` setting found in the admin panel. Without the external path restriction, a user can add any image or video file on the Immich host filesystem to be imported into Immich, potentially allowing sensitive data to be accessed. If you are running Immich as root in your Docker setup (which is the default), all external file reads are done with root privileges. This is particularly dangerous if the Immich host is a shared server.
|
||||
|
||||
With the `external path` set, a user is restricted to accessing external files to files or directories within that path. The Immich admin should still be careful not set the external path too generously. For example, `user1` wants to read their photos in to `/home/user1`. A lazy admin sets that user's external path to `/home/` since it "gets the job done". However, that user will then be able to read all photos in `/home/user2/private-photos`, too! Please set the external path as specific as possible. If multiple folders must be added, do this using the docker volume mount feature described below.
|
||||
|
||||
### Exclusion Patterns and Scan Settings
|
||||
|
||||
By default, all files in the import paths will be added to the library. If there are files that should not be added, exclusion patterns can be used to exclude them. Exclusion patterns are glob patterns are matched against the full file path. If a file matches an exclusion pattern, it will not be added to the library. Exclusion patterns can be added in the Scan Settings page for each library. Under the hood, Immich uses the [glob](https://www.npmjs.com/package/glob) package to match patterns, so please refer to [their documentation](https://github.com/isaacs/node-glob#glob-primer) to see what patterns are supported.
|
||||
|
||||
Some basic examples:
|
||||
|
||||
- `*.tif` will exclude all files with the extension `.tif`
|
||||
- `hidden.jpg` will exclude all files named `hidden.jpg`
|
||||
- `**/Raw/**` will exclude all files in any directory named `Raw`
|
||||
- `*.(tif,jpg)` will exclude all files with the extension `.tif` or `.jpg`
|
||||
|
||||
### Nightly job
|
||||
|
||||
There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as cleans up any libraries stuck in deletion.
|
||||
|
||||
## Usage
|
||||
|
||||
Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add:
|
||||
|
||||
- `/home/user/old-pics`: a folder contining childhood photos.
|
||||
- `/mnt/nas/christmas-trip`: photos from a christmas trip. The subfolder `/mnt/nas/christmas-trip/Raw` contains the raw files directly from the DSLR. We don't want to import the raw files to Immich
|
||||
- `/mnt/media/videos`: Videos from the same christmas trip.
|
||||
|
||||
First, we need to plan how we want to organize the libraries. The christmas trip photos should belong to its own library since we want to exclude the raw files. The videos and old photos can be in the same library since we want to import all files. We could also add all three folders to the same library if there are no files matching the Raw exclusion pattern in the other folders.
|
||||
|
||||
### Mount Docker Volumes
|
||||
|
||||
`immich-server` and `immich-microservices` containers will need access to the gallery. Modify your docker compose file as follows
|
||||
|
||||
```diff title="docker-compose.yml"
|
||||
immich-server:
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
+ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
|
||||
+ - /home/user/old-pics:/mnt/media/old-pics:ro
|
||||
+ - /mnt/media/videos:/mnt/media/videos:ro
|
||||
|
||||
|
||||
immich-microservices:
|
||||
volumes:
|
||||
- ${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
+ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
|
||||
+ - /home/user/old-pics:/mnt/media/old-pics:ro
|
||||
+ - /mnt/media/videos:/mnt/media/videos:ro
|
||||
```
|
||||
|
||||
:::tip
|
||||
The `ro` flag at the end only gives read-only access to the volumes. While Immich does not modify files, it's a good practice to mount read-only.
|
||||
:::
|
||||
|
||||
_Remember to bring the container down/up to register the changes. Make sure you can see the mounted path in the container._
|
||||
|
||||
### Set External Path
|
||||
|
||||
Only an admin can do this.
|
||||
|
||||
- Navigate to `Administration > Users` page on the web.
|
||||
- Click on the user edit button.
|
||||
- Set `/mnt/media` to be the external path. This folder will only contain the three folders that we want to import, so nothing else can be accessed.
|
||||
|
||||
### Create External Libraries
|
||||
|
||||
- Click on your user name in the top right corner -> Account Settings
|
||||
- Click on Libraries
|
||||
- Click on Create External Library
|
||||
- Click the drop-down menu on the newly created library
|
||||
- Click on Rename Library and rename it to "Christmas Trip"
|
||||
- Click Edit Import Paths
|
||||
- Click on Add Path
|
||||
- Enter `/mnt/media/christmas-trip` then click Add
|
||||
|
||||
NOTE: We have to use the `/mnt/media/christmas-trip` path and not the `/mnt/nas/christmas-trip` path since all paths have to be what the Docker containers see.
|
||||
|
||||
Next, we'll add an exclusion pattern to filter out raw files.
|
||||
|
||||
- Click the drop-down menu on the newly christmas library
|
||||
- Click on Manage
|
||||
- Click on Scan Settings
|
||||
- Click on Add Exclusion Pattern
|
||||
- Enter `**/Raw/**` and click save.
|
||||
- Click save
|
||||
- Click the drop-down menu on the newly created library
|
||||
- Click on Scan Library Files
|
||||
|
||||
The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library.
|
||||
|
||||
- Click on Create External Library.
|
||||
|
||||
:::info Note
|
||||
If you get an error here, please rename the other external library to something else. This is a bug that will be fixed in a future release.
|
||||
:::
|
||||
|
||||
- Click the drop-down menu on the newly created library
|
||||
- Click Edit Import Paths
|
||||
- Click on Add Path
|
||||
- Enter `/mnt/media/old-pics` then click Add
|
||||
- Click on Add Path
|
||||
- Enter `/mnt/media/videos` then click Add
|
||||
- Click Save
|
||||
- Click on Scan Library Files
|
||||
|
||||
Within seconds, the assets from the old-pics and videos folders should show up in the main timeline.
|
||||
@@ -1,4 +1,10 @@
|
||||
# Read-only Gallery [Experimental]
|
||||
# Read-only Gallery [Deprecated]
|
||||
|
||||
:::caution
|
||||
|
||||
This feature is being deprecated in favor of [Libraries](/docs/features/libraries.md).
|
||||
|
||||
:::
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -6,18 +12,6 @@ This feature enables users to use an existing gallery without uploading the asse
|
||||
|
||||
Upon syncing the file information, it will be read by Immich to generate supported files.
|
||||
|
||||
:::caution
|
||||
|
||||
This feature is still in an experimental stage. And this is an initial implementation and will receive improvements in the future.
|
||||
|
||||
The current limitations of this feature are:
|
||||
|
||||
- Assets are not automatically synced and must instead be manually synced with the CLI tool.
|
||||
- Only new files that are added to the gallery will be detected.
|
||||
- Deleted and moved files will not be detected.
|
||||
|
||||
:::
|
||||
|
||||
## Usage
|
||||
|
||||
:::tip Example scenario
|
||||
|
||||
85
docs/docs/guides/database-queries.md
Normal file
85
docs/docs/guides/database-queries.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Database Queries
|
||||
|
||||
:::danger
|
||||
Keep in mind that mucking around in the database might set the moon on fire. Avoid modifying the database directly when possible, and always have current backups.
|
||||
:::
|
||||
|
||||
:::tip
|
||||
Run `docker exec -it immich_postgres psql immich <DB_USERNAME>` to connect to the database via the container directly.
|
||||
|
||||
(Replace `<DB_USERNAME>` wit the value from your [`.env` file](/docs/install/environment-variables#database)).
|
||||
:::
|
||||
|
||||
## Assets
|
||||
|
||||
:::note
|
||||
The `"originalFileName"` column is the name of the uploaded file _without_ the extension.
|
||||
:::
|
||||
|
||||
```sql title="Find by original filename"
|
||||
SELECT * FROM "assets" WHERE "originalFileName" = 'PXL_20230903_232542848';
|
||||
SELECT * FROM "assets" WHERE "originalFileName" LIKE 'PXL_%'; -- all files starting with PXL_
|
||||
SELECT * FROM "assets" WHERE "originalFileName" LIKE '%_2023_%'; -- all files with _2023_ in the middle
|
||||
```
|
||||
|
||||
```sql title="Find by path"
|
||||
SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09-03/PXL_20230903_232542848.jpg';
|
||||
SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%';
|
||||
```
|
||||
|
||||
```sql title="Find by checksum" (sha1)
|
||||
SELECT encode("checksum", 'hex') FROM "assets";
|
||||
SELECT * FROM "assets" WHERE "checksum" = decode('69de19c87658c4c15d9cacb9967b8e033bf74dd1', 'hex');
|
||||
```
|
||||
|
||||
```sql title="Live photos"
|
||||
SELECT * FROM "assets" where "livePhotoVideoId" IS NOT NULL;
|
||||
```
|
||||
|
||||
```sql title="Without metadata"
|
||||
SELECT "assets".* FROM "exif" LEFT JOIN "assets" ON "assets"."id" = "exif"."assetId" WHERE "exif"."assetId" IS NULL;
|
||||
```
|
||||
|
||||
```sql title="Without thumbnails"
|
||||
SELECT * FROM "assets" WHERE "assets"."resizePath" IS NULL OR "assets"."webpPath" IS NULL;
|
||||
```
|
||||
|
||||
```sql title="By type"
|
||||
SELECT * FROM "assets" WHERE "assets"."type" = 'VIDEO';
|
||||
SELECT * FROM "assets" WHERE "assets"."type" = 'IMAGE';
|
||||
```
|
||||
|
||||
```sql title="Count by type"
|
||||
SELECT "assets"."type", count(*) FROM "assets" GROUP BY "assets"."type";
|
||||
```
|
||||
|
||||
```sql title="Count by type (per user)"
|
||||
SELECT
|
||||
"users"."email", "assets"."type", COUNT(*)
|
||||
FROM
|
||||
"assets"
|
||||
JOIN
|
||||
"users" ON "assets"."ownerId" = "users"."id"
|
||||
GROUP BY
|
||||
"assets"."type", "users"."email"
|
||||
ORDER BY
|
||||
"users"."email";
|
||||
```
|
||||
|
||||
```sql title="Failed file movements"
|
||||
SELECT * FROM "move_history";
|
||||
```
|
||||
|
||||
## Users
|
||||
|
||||
```sql title="List"
|
||||
SELECT * FROM "users";
|
||||
```
|
||||
|
||||
## System Config
|
||||
|
||||
```sql title="Custom settings"
|
||||
SELECT "key", "value" FROM "system_config";
|
||||
```
|
||||
|
||||
(Only used when not using the [config file](/docs/install/config-file))
|
||||
@@ -1,7 +1,3 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Docker Help
|
||||
|
||||
## Containers
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Hosting the machine-learning service on another system
|
||||
# Remote Machine Learning
|
||||
|
||||
To alleviate [performance issues on low-memory systems](/docs/FAQ.md#why-is-immich-slow-on-low-memory-systems-like-the-raspberry-pi) like the Raspberry Pi, you may also host Immich's machine-learning container on a more powerful system (e.g. your laptop or desktop computer):
|
||||
|
||||
|
||||
@@ -70,7 +70,8 @@ The default configuration looks like this:
|
||||
"enabled": true,
|
||||
"modelName": "buffalo_l",
|
||||
"minScore": 0.7,
|
||||
"maxDistance": 0.6
|
||||
"maxDistance": 0.6,
|
||||
"minFaces": 1
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
|
||||
@@ -49,11 +49,9 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
||||
|
||||
## Geocoding
|
||||
|
||||
| Variable | Description | Default | Services |
|
||||
| :--------------------------------- | :---------------------------------- | :--------------------------: | :------------ |
|
||||
| `DISABLE_REVERSE_GEOCODING` | Disable Reverse Geocoding Precision | `false` | microservices |
|
||||
| `REVERSE_GEOCODING_PRECISION` | Reverse Geocoding Precision | `3` | microservices |
|
||||
| `REVERSE_GEOCODING_DUMP_DIRECTORY` | Reverse Geocoding Dump Directory | `./.reverse-geocoding-dump/` | microservices |
|
||||
| Variable | Description | Default | Services |
|
||||
| :--------------------------------- | :------------------------------- | :--------------------------: | :------------ |
|
||||
| `REVERSE_GEOCODING_DUMP_DIRECTORY` | Reverse Geocoding Dump Directory | `./.reverse-geocoding-dump/` | microservices |
|
||||
|
||||
## Ports
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ const config = {
|
||||
},
|
||||
},
|
||||
navbar: {
|
||||
title: 'IMMICH',
|
||||
logo: {
|
||||
alt: 'Immich University Logo',
|
||||
src: 'img/color-logo.png',
|
||||
@@ -161,6 +162,7 @@ const config = {
|
||||
prism: {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
additionalLanguages: ['sql'],
|
||||
},
|
||||
image: 'overview/img/feature-panel.png',
|
||||
}),
|
||||
|
||||
9112
docs/package-lock.json
generated
9112
docs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
"docusaurus": "docusaurus",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"start": "docusaurus start",
|
||||
"start": "docusaurus start --port 3005",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
@@ -17,10 +17,11 @@
|
||||
"check": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "^2.4.1",
|
||||
"@docusaurus/preset-classic": "^2.4.1",
|
||||
"@docusaurus/core": "^2.4.3",
|
||||
"@docusaurus/preset-classic": "^2.4.3",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"classnames": "^2.3.2",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-lunr-search": "^2.3.2",
|
||||
"docusaurus-preset-openapi": "^0.6.3",
|
||||
|
||||
@@ -40,3 +40,7 @@ button {
|
||||
--ifm-background-color: #000000;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.navbar__brand .navbar__title {
|
||||
@apply font-immich-title text-2xl font-normal text-immich-primary dark:text-immich-dark-primary;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
"extends": "@tsconfig/docusaurus/tsconfig.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
"baseUrl": ".",
|
||||
"module": "Node16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,6 @@ from ..config import log
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
_ST_TO_JINA_MODEL_NAME = {
|
||||
"clip-ViT-B-16": "ViT-B-16::openai",
|
||||
"clip-ViT-B-32": "ViT-B-32::openai",
|
||||
"clip-ViT-B-32-multilingual-v1": "M-CLIP/XLM-Roberta-Large-Vit-B-32",
|
||||
"clip-ViT-L-14": "ViT-L-14::openai",
|
||||
}
|
||||
|
||||
|
||||
class CLIPEncoder(InferenceModel):
|
||||
_model_type = ModelType.CLIP
|
||||
@@ -36,11 +29,10 @@ class CLIPEncoder(InferenceModel):
|
||||
) -> None:
|
||||
if mode is not None and mode not in ("text", "vision"):
|
||||
raise ValueError(f"Mode must be 'text', 'vision', or omitted; got '{mode}'")
|
||||
if "vit-b" not in model_name.lower():
|
||||
raise ValueError(f"Only ViT-B models are currently supported; got '{model_name}'")
|
||||
if model_name not in _MODELS:
|
||||
raise ValueError(f"Unknown model name {model_name}.")
|
||||
self.mode = mode
|
||||
jina_model_name = self._get_jina_model_name(model_name)
|
||||
super().__init__(jina_model_name, cache_dir, **model_kwargs)
|
||||
super().__init__(model_name, cache_dir, **model_kwargs)
|
||||
|
||||
def _download(self) -> None:
|
||||
models: tuple[tuple[str, str], tuple[str, str]] = _MODELS[self.model_name]
|
||||
@@ -104,20 +96,6 @@ class CLIPEncoder(InferenceModel):
|
||||
|
||||
return outputs[0][0].tolist()
|
||||
|
||||
def _get_jina_model_name(self, model_name: str) -> str:
|
||||
if model_name in _MODELS:
|
||||
return model_name
|
||||
elif model_name in _ST_TO_JINA_MODEL_NAME:
|
||||
log.warn(
|
||||
(
|
||||
f"Sentence-Transformer models like '{model_name}' are not supported."
|
||||
f"Using '{_ST_TO_JINA_MODEL_NAME[model_name]}' instead as it is the best match for '{model_name}'."
|
||||
),
|
||||
)
|
||||
return _ST_TO_JINA_MODEL_NAME[model_name]
|
||||
else:
|
||||
raise ValueError(f"Unknown model name {model_name}.")
|
||||
|
||||
def _download_model(self, model_name: str, model_md5: str) -> bool:
|
||||
# downloading logic is adapted from clip-server's CLIPOnnxModel class
|
||||
download_model(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "machine-learning"
|
||||
version = "1.78.0"
|
||||
version = "1.82.0"
|
||||
description = ""
|
||||
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 101,
|
||||
"android.injected.version.name" => "1.78.0",
|
||||
"android.injected.version.code" => 106,
|
||||
"android.injected.version.name" => "1.82.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00023">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="63.585931">
|
||||
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.160108">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="24.755096">
|
||||
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="39.176668">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -163,7 +163,6 @@
|
||||
"home_page_building_timeline": "Building the timeline",
|
||||
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
|
||||
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
|
||||
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
|
||||
"image_viewer_page_state_provider_download_error": "Download Error",
|
||||
"image_viewer_page_state_provider_download_success": "Download Success",
|
||||
"library_page_albums": "Albums",
|
||||
@@ -174,6 +173,8 @@
|
||||
"library_page_sharing": "Sharing",
|
||||
"library_page_sort_created": "Most recently created",
|
||||
"library_page_sort_title": "Album title",
|
||||
"library_page_sort_most_recent_photo": "Most recent photo",
|
||||
"library_page_sort_last_modified": "Last modified",
|
||||
"login_disabled": "Login has been disabled",
|
||||
"login_form_api_exception": "API exception. Please check the server URL and try again.",
|
||||
"login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.",
|
||||
@@ -225,6 +226,7 @@
|
||||
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
|
||||
"permission_onboarding_request": "Immich requires permission to view your photos and videos.",
|
||||
"profile_drawer_app_logs": "Logs",
|
||||
"profile_drawer_trash": "Trash",
|
||||
"profile_drawer_client_server_up_to_date": "Client and Server are up-to-date",
|
||||
"profile_drawer_settings": "Settings",
|
||||
"profile_drawer_sign_out": "Sign Out",
|
||||
@@ -312,6 +314,7 @@
|
||||
"map_settings_dialog_title": "Map Settings",
|
||||
"map_settings_dark_mode": "Dark mode",
|
||||
"map_settings_only_show_favorites": "Show Favorite Only",
|
||||
"map_settings_include_show_archived": "Include Archived",
|
||||
"map_settings_only_relative_range": "Date range",
|
||||
"map_settings_dialog_cancel": "Cancel",
|
||||
"map_settings_dialog_save": "Save",
|
||||
@@ -321,5 +324,17 @@
|
||||
"map_no_location_permission_title": "Location Permission denied",
|
||||
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
|
||||
"map_location_dialog_cancel": "Cancel",
|
||||
"map_location_dialog_yes": "Yes"
|
||||
"map_location_dialog_yes": "Yes",
|
||||
"trash_page_title": "Trash ({})",
|
||||
"trash_page_info": "Trashed items will be permanently deleted after {} days",
|
||||
"trash_page_no_assets": "No trashed assets",
|
||||
"trash_page_delete": "Delete",
|
||||
"trash_page_delete_all": "Delete All",
|
||||
"trash_page_restore": "Restore",
|
||||
"trash_page_restore_all": "Restore All",
|
||||
"trash_page_select_btn": "Select",
|
||||
"trash_page_select_assets_btn": "Select assets",
|
||||
"trash_page_empty_trash_btn": "Empty trash",
|
||||
"trash_page_empty_trash_dialog_ok": "Ok",
|
||||
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich"
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 116;
|
||||
CURRENT_PROJECT_VERSION = 118;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -515,7 +515,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 116;
|
||||
CURRENT_PROJECT_VERSION = 118;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -543,7 +543,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 116;
|
||||
CURRENT_PROJECT_VERSION = 118;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -59,11 +59,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.76.0</string>
|
||||
<string>1.78.1</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>116</string>
|
||||
<string>118</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true />
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.78.0"
|
||||
version_number: "1.82.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -5,32 +5,32 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.611762">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="7.645306">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="6.937008">
|
||||
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.669798">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.740416">
|
||||
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.218788">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="93.625943">
|
||||
<testcase classname="fastlane.lanes" name="4: build_app" time="97.596654">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="62.107671">
|
||||
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="89.490906">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
|
||||
import 'package:immich_mobile/shared/cache/widgets_binding.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
import 'package:immich_mobile/shared/models/android_device_asset.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
@@ -37,7 +38,7 @@ import 'package:logging/logging.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
ImmichWidgetsBinding();
|
||||
|
||||
final db = await loadDb();
|
||||
await initApp();
|
||||
|
||||
@@ -47,6 +47,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
|
||||
|
||||
List<Album> sortedAlbums() {
|
||||
// Created.
|
||||
if (selectedAlbumSortOrder.value == 0) {
|
||||
return albums
|
||||
.where((a) => a.isRemote)
|
||||
@@ -54,6 +55,34 @@ class LibraryPage extends HookConsumerWidget {
|
||||
.reversed
|
||||
.toList();
|
||||
}
|
||||
// Album title.
|
||||
if (selectedAlbumSortOrder.value == 1) {
|
||||
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
|
||||
}
|
||||
// Most recent photo, if unset (e.g. empty album, use modifiedAt / updatedAt).
|
||||
if (selectedAlbumSortOrder.value == 2) {
|
||||
return albums
|
||||
.where((a) => a.isRemote)
|
||||
.sorted(
|
||||
(a, b) => a.lastModifiedAssetTimestamp != null &&
|
||||
b.lastModifiedAssetTimestamp != null
|
||||
? a.lastModifiedAssetTimestamp!
|
||||
.compareTo(b.lastModifiedAssetTimestamp!)
|
||||
: a.modifiedAt.compareTo(b.modifiedAt),
|
||||
)
|
||||
.reversed
|
||||
.toList();
|
||||
}
|
||||
// Last modified.
|
||||
if (selectedAlbumSortOrder.value == 3) {
|
||||
return albums
|
||||
.where((a) => a.isRemote)
|
||||
.sortedBy((album) => album.modifiedAt)
|
||||
.reversed
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Fallback: Album title.
|
||||
return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
|
||||
}
|
||||
|
||||
@@ -61,6 +90,8 @@ class LibraryPage extends HookConsumerWidget {
|
||||
final options = [
|
||||
"library_page_sort_created".tr(),
|
||||
"library_page_sort_title".tr(),
|
||||
"library_page_sort_most_recent_photo".tr(),
|
||||
"library_page_sort_last_modified".tr(),
|
||||
];
|
||||
|
||||
return PopupMenuButton(
|
||||
|
||||
@@ -117,6 +117,7 @@ class SharingPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.only(
|
||||
left: 12.0,
|
||||
right: 12.0,
|
||||
top: 24.0,
|
||||
bottom: 12.0,
|
||||
),
|
||||
child: Row(
|
||||
|
||||
@@ -13,9 +13,11 @@ final archiveProvider = StreamProvider<RenderList>((ref) async* {
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(user.isarId)
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isArchivedEqualTo(true)
|
||||
.isTrashedEqualTo(false)
|
||||
.sortByFileCreatedAt();
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final groupBy =
|
||||
|
||||
@@ -364,7 +364,18 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
children: [
|
||||
buildDragHeader(),
|
||||
buildDate(),
|
||||
if (asset.isRemote) DescriptionInput(asset: asset),
|
||||
assetWithExif.when(
|
||||
data: (data) => DescriptionInput(asset: data),
|
||||
error: (error, stackTrace) => Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
loading: () => const SizedBox(
|
||||
width: 75,
|
||||
height: 75,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
buildLocation(),
|
||||
SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
|
||||
class TopControlAppBar extends HookConsumerWidget {
|
||||
const TopControlAppBar({
|
||||
@@ -14,7 +15,6 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
required this.isPlayingMotionVideo,
|
||||
required this.onFavorite,
|
||||
required this.onUploadPressed,
|
||||
required this.isFavorite,
|
||||
}) : super(key: key);
|
||||
|
||||
final Asset asset;
|
||||
@@ -23,19 +23,19 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
final VoidCallback? onDownloadPressed;
|
||||
final VoidCallback onToggleMotionVideo;
|
||||
final VoidCallback onAddToAlbumPressed;
|
||||
final VoidCallback? onFavorite;
|
||||
final Function(Asset) onFavorite;
|
||||
final bool isPlayingMotionVideo;
|
||||
final bool isFavorite;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const double iconSize = 22.0;
|
||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||
|
||||
Widget buildFavoriteButton() {
|
||||
Widget buildFavoriteButton(a) {
|
||||
return IconButton(
|
||||
onPressed: onFavorite,
|
||||
onPressed: () => onFavorite(a),
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
a.isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
@@ -123,7 +123,7 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
size: iconSize,
|
||||
),
|
||||
actions: [
|
||||
if (asset.isRemote) buildFavoriteButton(),
|
||||
if (asset.isRemote) buildFavoriteButton(a),
|
||||
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
|
||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal) buildDownloadButton(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
|
||||
@@ -18,11 +19,15 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/shared/cache/original_image_provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_image.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
@@ -31,8 +36,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:openapi/api.dart' as api;
|
||||
import 'package:openapi/api.dart' show ThumbnailFormat;
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class GalleryViewerPage extends HookConsumerWidget {
|
||||
@@ -51,6 +55,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
|
||||
final PageController controller;
|
||||
|
||||
static const jpeg = ThumbnailFormat.JPEG;
|
||||
static const webp = ThumbnailFormat.WEBP;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
@@ -59,11 +66,17 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
final isZoomed = useState<bool>(false);
|
||||
final isPlayingMotionVideo = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
final progressValue = useState(0.0);
|
||||
Offset? localPosition;
|
||||
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
|
||||
final header = {"Authorization": authToken};
|
||||
final currentIndex = useState(initialIndex);
|
||||
final currentAsset = loadAsset(currentIndex.value);
|
||||
final isTrashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
final navStack = AutoRouter.of(context).stackData;
|
||||
final isFromTrash = isTrashEnabled &&
|
||||
navStack.length > 2 &&
|
||||
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
|
||||
|
||||
Asset asset() => currentAsset;
|
||||
|
||||
@@ -83,93 +96,52 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleFavorite([asset], !asset.isFavorite);
|
||||
|
||||
/// Thumbnail image of a remote asset. Required asset.isRemote
|
||||
ImageProvider remoteThumbnailImageProvider(
|
||||
Asset asset,
|
||||
api.ThumbnailFormat type,
|
||||
) {
|
||||
return CachedNetworkImageProvider(
|
||||
getThumbnailUrl(
|
||||
asset,
|
||||
type: type,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset,
|
||||
type: type,
|
||||
),
|
||||
headers: {"Authorization": authToken},
|
||||
);
|
||||
}
|
||||
|
||||
/// Original (large) image of a remote asset. Required asset.isRemote
|
||||
ImageProvider originalImageProvider(Asset asset) {
|
||||
return CachedNetworkImageProvider(
|
||||
getImageUrl(asset),
|
||||
cacheKey: getImageCacheKey(asset),
|
||||
headers: {"Authorization": authToken},
|
||||
);
|
||||
}
|
||||
|
||||
/// Thumbnail image of a local asset. Required asset.isLocal
|
||||
ImageProvider localThumbnailImageProvider(Asset asset) {
|
||||
return AssetEntityImageProvider(
|
||||
asset.local!,
|
||||
isOriginal: false,
|
||||
thumbnailSize: ThumbnailSize(
|
||||
MediaQuery.of(context).size.width.floor(),
|
||||
MediaQuery.of(context).size.height.floor(),
|
||||
),
|
||||
);
|
||||
}
|
||||
ImageProvider remoteOriginalProvider(Asset asset) =>
|
||||
CachedNetworkImageProvider(
|
||||
getImageUrl(asset),
|
||||
cacheKey: getImageCacheKey(asset),
|
||||
headers: header,
|
||||
);
|
||||
|
||||
/// Original (large) image of a local asset. Required asset.isLocal
|
||||
ImageProvider localImageProvider(Asset asset) {
|
||||
return AssetEntityImageProvider(
|
||||
isOriginal: true,
|
||||
asset.local!,
|
||||
);
|
||||
ImageProvider localOriginalProvider(Asset asset) =>
|
||||
OriginalImageProvider(asset);
|
||||
|
||||
ImageProvider finalImageProvider(Asset asset) {
|
||||
if (ImmichImage.useLocal(asset)) {
|
||||
return localOriginalProvider(asset);
|
||||
} else if (isLoadOriginal.value) {
|
||||
return remoteOriginalProvider(asset);
|
||||
} else if (isLoadPreview.value) {
|
||||
return ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
|
||||
}
|
||||
return ImmichImage.remoteThumbnailProvider(asset, webp, header);
|
||||
}
|
||||
|
||||
Iterable<ImageProvider> allImageProviders(Asset asset) sync* {
|
||||
if (ImmichImage.useLocal(asset)) {
|
||||
yield ImmichImage.localThumbnailProvider(asset);
|
||||
yield localOriginalProvider(asset);
|
||||
} else {
|
||||
yield ImmichImage.remoteThumbnailProvider(asset, webp, header);
|
||||
if (isLoadPreview.value) {
|
||||
yield ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
|
||||
}
|
||||
if (isLoadOriginal.value) {
|
||||
yield remoteOriginalProvider(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void precacheNextImage(int index) {
|
||||
void onError(Object exception, StackTrace? stackTrace) {
|
||||
// swallow error silently
|
||||
}
|
||||
if (index < totalAssets && index >= 0) {
|
||||
final asset = loadAsset(index);
|
||||
|
||||
if (!asset.isRemote ||
|
||||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) {
|
||||
// Preload the local asset
|
||||
precacheImage(localImageProvider(asset), context);
|
||||
} else {
|
||||
onError(Object exception, StackTrace? stackTrace) {
|
||||
// swallow error silently
|
||||
}
|
||||
// Probably load WEBP either way
|
||||
precacheImage(
|
||||
remoteThumbnailImageProvider(
|
||||
asset,
|
||||
api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
if (isLoadPreview.value) {
|
||||
// Precache the JPEG thumbnail
|
||||
precacheImage(
|
||||
remoteThumbnailImageProvider(
|
||||
asset,
|
||||
api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
if (isLoadOriginal.value) {
|
||||
// Preload the original asset
|
||||
precacheImage(
|
||||
originalImageProvider(asset),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
for (final imageProvider in allImageProviders(asset)) {
|
||||
precacheImage(imageProvider, context, onError: onError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,25 +171,47 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void handleDelete(Asset deleteAsset) {
|
||||
void handleDelete(Asset deleteAsset) async {
|
||||
Future<bool> onDelete(bool force) async {
|
||||
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
||||
{deleteAsset},
|
||||
force: force,
|
||||
);
|
||||
if (isDeleted) {
|
||||
if (totalAssets == 1) {
|
||||
// Handle only one asset
|
||||
AutoRouter.of(context).pop();
|
||||
} else {
|
||||
// Go to next page otherwise
|
||||
controller.nextPage(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
}
|
||||
return isDeleted;
|
||||
}
|
||||
|
||||
// Asset is trashed
|
||||
if (isTrashEnabled && !isFromTrash) {
|
||||
final isDeleted = await onDelete(false);
|
||||
// Can only trash assets stored in server. Local assets are always permanently removed for now
|
||||
if (context.mounted && isDeleted && deleteAsset.isRemote) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
msg: 'Asset trashed',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Asset is permanently removed
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return DeleteDialog(
|
||||
onDelete: () {
|
||||
if (totalAssets == 1) {
|
||||
// Handle only one asset
|
||||
AutoRouter.of(context).pop();
|
||||
} else {
|
||||
// Go to next page otherwise
|
||||
controller.nextPage(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
ref.watch(assetProvider.notifier).deleteAssets({deleteAsset});
|
||||
},
|
||||
);
|
||||
return DeleteDialog(onDelete: () => onDelete(true));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -303,10 +297,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
child: TopControlAppBar(
|
||||
isPlayingMotionVideo: isPlayingMotionVideo.value,
|
||||
asset: asset(),
|
||||
isFavorite: asset().isFavorite,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onFavorite:
|
||||
asset().isRemote ? () => toggleFavorite(asset()) : null,
|
||||
onFavorite: toggleFavorite,
|
||||
onUploadPressed:
|
||||
asset().isLocal ? () => handleUpload(asset()) : null,
|
||||
onDownloadPressed: asset().isLocal
|
||||
@@ -346,7 +338,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withOpacity(0.75),
|
||||
onChanged: (position) {
|
||||
progressValue.value = position;
|
||||
ref.read(videoPlayerControlsProvider.notifier).position = position;
|
||||
},
|
||||
),
|
||||
@@ -485,27 +476,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
ImageProvider imageProvider(Asset asset) {
|
||||
if (!asset.isRemote ||
|
||||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) {
|
||||
return localImageProvider(asset);
|
||||
} else {
|
||||
if (isLoadOriginal.value) {
|
||||
return originalImageProvider(asset);
|
||||
} else if (isLoadPreview.value) {
|
||||
return remoteThumbnailImageProvider(
|
||||
asset,
|
||||
api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
} else {
|
||||
return remoteThumbnailImageProvider(
|
||||
asset,
|
||||
api.ThumbnailFormat.WEBP,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: WillPopScope(
|
||||
@@ -531,79 +501,51 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
itemCount: totalAssets,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) {
|
||||
// Precache image
|
||||
if (currentIndex.value < value) {
|
||||
// Moving forwards, so precache the next asset
|
||||
precacheNextImage(value + 1);
|
||||
} else {
|
||||
// Moving backwards, so precache previous asset
|
||||
precacheNextImage(value - 1);
|
||||
}
|
||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||
precacheNextImage(next);
|
||||
currentIndex.value = value;
|
||||
progressValue.value = 0.0;
|
||||
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
loadingBuilder: isLoadPreview.value
|
||||
? (context, event) {
|
||||
final a = asset();
|
||||
if (!a.isLocal ||
|
||||
(a.isRemote &&
|
||||
Store.get(StoreKey.preferRemoteImage, false))) {
|
||||
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
|
||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||
final webPThumbnail = CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
a,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
a,
|
||||
type: api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
progressIndicatorBuilder: (_, __, ___) =>
|
||||
const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fit: BoxFit.contain,
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
);
|
||||
loadingBuilder: (context, event, index) {
|
||||
final a = loadAsset(index);
|
||||
if (ImmichImage.useLocal(a)) {
|
||||
return Image(
|
||||
image: ImmichImage.localThumbnailProvider(a),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
|
||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||
final webPThumbnail = CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(a, type: webp),
|
||||
cacheKey: getThumbnailCacheKey(a, type: webp),
|
||||
httpHeaders: header,
|
||||
progressIndicatorBuilder: (_, __, ___) => const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fit: BoxFit.contain,
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
);
|
||||
|
||||
if (isLoadOriginal.value) {
|
||||
// loading the preview in the loadingBuilder only
|
||||
// makes sense if the original is loaded in the builder
|
||||
return CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(
|
||||
a,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
a,
|
||||
type: api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
httpHeaders: {'Authorization': authToken},
|
||||
fit: BoxFit.contain,
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, __) => webPThumbnail,
|
||||
errorWidget: (_, __, ___) => webPThumbnail,
|
||||
);
|
||||
} else {
|
||||
return webPThumbnail;
|
||||
}
|
||||
} else {
|
||||
return Image(
|
||||
image: localThumbnailImageProvider(a),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
// loading the preview in the loadingBuilder only
|
||||
// makes sense if the original is loaded in the builder
|
||||
return isLoadPreview.value && isLoadOriginal.value
|
||||
? CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(a, type: jpeg),
|
||||
cacheKey: getThumbnailCacheKey(a, type: jpeg),
|
||||
httpHeaders: header,
|
||||
fit: BoxFit.contain,
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
placeholder: (_, __) => webPThumbnail,
|
||||
errorWidget: (_, __, ___) => webPThumbnail,
|
||||
)
|
||||
: webPThumbnail;
|
||||
},
|
||||
builder: (context, index) {
|
||||
final asset = loadAsset(index);
|
||||
final ImageProvider provider = imageProvider(asset);
|
||||
final ImageProvider provider = finalImageProvider(asset);
|
||||
|
||||
if (asset.isImage && !isPlayingMotionVideo.value) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
|
||||
@@ -217,24 +217,25 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
||||
|
||||
final assetCountInAlbum = await album.assetCountAsync;
|
||||
if (assetCountInAlbum > 0) {
|
||||
final assetList =
|
||||
await album.getAssetListRange(start: 0, end: assetCountInAlbum);
|
||||
final assetList = await album.getAssetListPaged(page: 0, size: 1);
|
||||
|
||||
if (assetList.isNotEmpty) {
|
||||
final thumbnailAsset = assetList.first;
|
||||
|
||||
try {
|
||||
final thumbnailData = await thumbnailAsset
|
||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
availableAlbum =
|
||||
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||
} catch (e, stack) {
|
||||
log.severe(
|
||||
"Failed to get thumbnail for album ${album.name}",
|
||||
e.toString(),
|
||||
stack,
|
||||
);
|
||||
}
|
||||
// Even though we check assetCountInAlbum to make sure that there are assets in album
|
||||
// The `getAssetListPaged` method still return empty list and cause not assets get rendered
|
||||
if (assetList.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final thumbnailAsset = assetList.first;
|
||||
try {
|
||||
final thumbnailData = await thumbnailAsset
|
||||
.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||
availableAlbum =
|
||||
availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||
} catch (e, stack) {
|
||||
log.severe(
|
||||
"Failed to get thumbnail for album ${album.name}",
|
||||
e.toString(),
|
||||
stack,
|
||||
);
|
||||
}
|
||||
|
||||
availableAlbums.add(availableAlbum);
|
||||
|
||||
@@ -220,7 +220,9 @@ class BackupService {
|
||||
final List<String> duplicatedAssetIds = [];
|
||||
|
||||
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
|
||||
await PhotoManager.requestPermissionExtend();
|
||||
if (Platform.isIOS) {
|
||||
await PhotoManager.requestPermissionExtend();
|
||||
}
|
||||
|
||||
List<AssetEntity> assetsToUpload = sortAssets
|
||||
// Upload images before video assets
|
||||
|
||||
@@ -81,7 +81,9 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||
context: context,
|
||||
msg: "Deleting ${assets.length} assets on the server...",
|
||||
);
|
||||
await ref.read(assetProvider.notifier).deleteAssets(assets);
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteAssets(assets, force: true);
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "Deleted ${assets.length} assets on the server. "
|
||||
|
||||
@@ -13,9 +13,11 @@ final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(user.isarId)
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isFavoriteEqualTo(true)
|
||||
.isTrashedEqualTo(false)
|
||||
.sortByFileCreatedAt();
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final groupBy =
|
||||
|
||||
@@ -142,7 +142,7 @@ class RenderList {
|
||||
) async {
|
||||
final List<RenderAssetGridElement> elements = [];
|
||||
|
||||
const pageSize = 500;
|
||||
const pageSize = 50000;
|
||||
const sectionSize = 60; // divides evenly by 2,3,4,5,6
|
||||
|
||||
if (groupBy == GroupAssetsBy.none) {
|
||||
|
||||
@@ -60,6 +60,39 @@ class ThumbnailImage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildVideoIcon() {
|
||||
final minutes = asset.duration.inMinutes;
|
||||
final durationString = asset.duration.toString();
|
||||
return Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
minutes > 59
|
||||
? durationString.substring(0, 7) // h:mm:ss
|
||||
: minutes > 0
|
||||
? durationString.substring(2, 7) // mm:ss
|
||||
: durationString.substring(3, 7), // m:ss
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 3,
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_fill_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildImage() {
|
||||
final image = SizedBox(
|
||||
width: 300,
|
||||
@@ -162,26 +195,7 @@ class ThumbnailImage extends StatelessWidget {
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
if (!asset.isImage)
|
||||
Positioned(
|
||||
top: 5,
|
||||
right: 5,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
asset.duration.toString().substring(0, 7),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!asset.isImage) buildVideoIcon(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/shared/models/album.dart';
|
||||
|
||||
@@ -43,6 +44,8 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
var hasRemote = selectionAssetState == AssetState.remote;
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
|
||||
Widget renderActionButtons() {
|
||||
return Row(
|
||||
@@ -70,14 +73,20 @@ class ControlBottomAppBar extends ConsumerWidget {
|
||||
iconData: Icons.delete_outline_rounded,
|
||||
label: "control_bottom_app_bar_delete".tr(),
|
||||
onPressed: enabled
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return DeleteDialog(
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
)
|
||||
? () {
|
||||
if (!trashEnabled) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return DeleteDialog(
|
||||
onDelete: onDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (!hasRemote)
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dar
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
|
||||
class ProfileDrawer extends HookConsumerWidget {
|
||||
@@ -16,6 +17,9 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
|
||||
buildSignOutButton() {
|
||||
return ListTile(
|
||||
leading: SizedBox(
|
||||
@@ -91,6 +95,29 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
buildTrashButton() {
|
||||
return ListTile(
|
||||
leading: SizedBox(
|
||||
height: double.infinity,
|
||||
child: Icon(
|
||||
Icons.delete_rounded,
|
||||
color: Theme.of(context).textTheme.labelMedium?.color,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
"profile_drawer_trash",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
onTap: () {
|
||||
AutoRouter.of(context).push(const TrashRoute());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Drawer(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
@@ -105,6 +132,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
const ProfileDrawerHeader(),
|
||||
buildSettingButton(),
|
||||
buildAppLogButton(),
|
||||
if (trashEnabled) buildTrashButton(),
|
||||
buildSignOutButton(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -43,6 +43,8 @@ class HomePage extends HookConsumerWidget {
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
final albumService = ref.watch(albumServiceProvider);
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
final trashEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
|
||||
final tipOneOpacity = useState(0.0);
|
||||
final refreshCount = useState(0);
|
||||
@@ -54,7 +56,7 @@ class HomePage extends HookConsumerWidget {
|
||||
Future(() => ref.read(assetProvider.notifier).getAllAsset());
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
ref.read(serverInfoProvider.notifier).getServerVersion();
|
||||
ref.read(serverInfoProvider.notifier).getServerInfo();
|
||||
|
||||
selectionEnabledHook.addListener(() {
|
||||
multiselectEnabled.state = selectionEnabledHook.value;
|
||||
@@ -139,30 +141,34 @@ class HomePage extends HookConsumerWidget {
|
||||
void onDelete() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
await ref.read(assetProvider.notifier).deleteAssets(selection.value);
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteAssets(selection.value, force: !trashEnabled);
|
||||
|
||||
final hasRemote = selection.value.any((a) => a.isRemote);
|
||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
||||
final trashOrRemoved =
|
||||
!trashEnabled ? 'deleted permanently' : 'trashed';
|
||||
if (hasRemote) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: '${selection.value.length} $assetOrAssets $trashOrRemoved',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
selectionEnabledHook.value = false;
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onUpload() async {
|
||||
void onUpload() {
|
||||
processing.value = true;
|
||||
selectionEnabledHook.value = false;
|
||||
try {
|
||||
final Set<Asset> assets = selection.value;
|
||||
if (assets.length > 30) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'home_page_upload_err_limit'.tr(),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
} else {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
await ref
|
||||
.read(manualUploadProvider.notifier)
|
||||
.uploadAssets(context, assets);
|
||||
}
|
||||
ref
|
||||
.read(manualUploadProvider.notifier)
|
||||
.uploadAssets(context, selection.value);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
class MapState {
|
||||
final bool isDarkTheme;
|
||||
final bool showFavoriteOnly;
|
||||
final bool includeArchived;
|
||||
final int relativeTime;
|
||||
|
||||
MapState({
|
||||
this.isDarkTheme = false,
|
||||
this.showFavoriteOnly = false,
|
||||
this.includeArchived = false,
|
||||
this.relativeTime = 0,
|
||||
});
|
||||
|
||||
MapState copyWith({
|
||||
bool? isDarkTheme,
|
||||
bool? showFavoriteOnly,
|
||||
bool? includeArchived,
|
||||
int? relativeTime,
|
||||
}) {
|
||||
return MapState(
|
||||
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
|
||||
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
relativeTime: relativeTime ?? this.relativeTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime)';
|
||||
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -33,13 +37,15 @@ class MapState {
|
||||
return other is MapState &&
|
||||
other.isDarkTheme == isDarkTheme &&
|
||||
other.showFavoriteOnly == showFavoriteOnly &&
|
||||
other.relativeTime == relativeTime;
|
||||
other.relativeTime == relativeTime &&
|
||||
other.includeArchived == includeArchived;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return isDarkTheme.hashCode ^
|
||||
showFavoriteOnly.hashCode ^
|
||||
relativeTime.hashCode;
|
||||
relativeTime.hashCode ^
|
||||
includeArchived.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ final mapMarkersProvider =
|
||||
final mapState = ref.read(mapStateNotifier);
|
||||
DateTime? fileCreatedAfter;
|
||||
bool? isFavorite;
|
||||
bool? isIncludeArchived;
|
||||
|
||||
if (mapState.relativeTime != 0) {
|
||||
fileCreatedAfter =
|
||||
@@ -20,8 +21,13 @@ final mapMarkersProvider =
|
||||
isFavorite = true;
|
||||
}
|
||||
|
||||
if (!mapState.includeArchived) {
|
||||
isIncludeArchived = false;
|
||||
}
|
||||
|
||||
final markers = await service.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
withArchived: isIncludeArchived,
|
||||
fileCreatedAfter: fileCreatedAfter,
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ class MapStateNotifier extends StateNotifier<MapState> {
|
||||
.getSetting<bool>(AppSettingsEnum.mapThemeMode),
|
||||
showFavoriteOnly: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
|
||||
includeArchived: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
|
||||
relativeTime: appSettingsProvider
|
||||
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
|
||||
),
|
||||
@@ -31,11 +33,19 @@ class MapStateNotifier extends StateNotifier<MapState> {
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapShowFavoriteOnly,
|
||||
appSettingsProvider,
|
||||
isFavoriteOnly,
|
||||
);
|
||||
state = state.copyWith(showFavoriteOnly: isFavoriteOnly);
|
||||
}
|
||||
|
||||
void switchIncludeArchived(bool isIncludeArchived) {
|
||||
appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapIncludeArchived,
|
||||
isIncludeArchived,
|
||||
);
|
||||
state = state.copyWith(includeArchived: isIncludeArchived);
|
||||
}
|
||||
|
||||
void setRelativeTime(int relativeTime) {
|
||||
appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapRelativeDate,
|
||||
|
||||
@@ -23,12 +23,14 @@ class MapSerivce {
|
||||
|
||||
Future<List<MapMarkerResponseDto>> getMapMarkers({
|
||||
bool? isFavorite,
|
||||
bool? withArchived,
|
||||
DateTime? fileCreatedAfter,
|
||||
DateTime? fileCreatedBefore,
|
||||
}) async {
|
||||
try {
|
||||
final markers = await _apiService.assetApi.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
isArchived: withArchived,
|
||||
fileCreatedAfter: fileCreatedAfter,
|
||||
fileCreatedBefore: fileCreatedBefore,
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ class MapSettingsDialog extends HookConsumerWidget {
|
||||
final mapSettings = ref.read(mapStateNotifier);
|
||||
final isDarkMode = useState(mapSettings.isDarkTheme);
|
||||
final showFavoriteOnly = useState(mapSettings.showFavoriteOnly);
|
||||
final showIncludeArchived = useState(mapSettings.includeArchived);
|
||||
final showRelativeDate = useState(mapSettings.relativeTime);
|
||||
final ThemeData theme = Theme.of(context);
|
||||
|
||||
@@ -48,6 +49,22 @@ class MapSettingsDialog extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildIncludeArchivedSetting() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showIncludeArchived.value,
|
||||
onChanged: (value) {
|
||||
showIncludeArchived.value = value;
|
||||
},
|
||||
activeColor: theme.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"map_settings_include_show_archived".tr(),
|
||||
style:
|
||||
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDateRangeSetting() {
|
||||
final now = DateTime.now();
|
||||
return DropdownMenu(
|
||||
@@ -127,6 +144,8 @@ class MapSettingsDialog extends HookConsumerWidget {
|
||||
mapSettingsNotifier.switchTheme(isDarkMode.value);
|
||||
mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
|
||||
mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
|
||||
mapSettingsNotifier
|
||||
.switchIncludeArchived(showIncludeArchived.value);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
@@ -166,6 +185,7 @@ class MapSettingsDialog extends HookConsumerWidget {
|
||||
children: [
|
||||
buildMapThemeSetting(),
|
||||
buildFavoriteOnlySetting(),
|
||||
buildIncludeArchivedSetting(),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -29,8 +30,9 @@ class MapThumbnail extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
urlTemplate: ref.watch(
|
||||
serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
|
||||
),
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
@@ -154,7 +155,8 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
ref.listen(mapStateNotifier, (previous, next) {
|
||||
bool shouldRefetch =
|
||||
previous?.showFavoriteOnly != next.showFavoriteOnly ||
|
||||
previous?.relativeTime != next.relativeTime;
|
||||
previous?.relativeTime != next.relativeTime ||
|
||||
previous?.includeArchived != next.includeArchived;
|
||||
if (shouldRefetch) {
|
||||
refetchMarkers.value = shouldRefetch;
|
||||
ref.invalidate(mapMarkersProvider);
|
||||
@@ -358,8 +360,9 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
}
|
||||
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
urlTemplate: ref.watch(
|
||||
serverInfoProvider.select((v) => v.serverConfig.mapTileUrl),
|
||||
),
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
@@ -22,9 +22,9 @@ class MemoryService {
|
||||
Future<List<Memory>?> getMemoryLane() async {
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final beginningOfDate = DateTime(now.year, now.month, now.day);
|
||||
final data = await _apiService.assetApi.getMemoryLane(
|
||||
beginningOfDate,
|
||||
now.day,
|
||||
now.month,
|
||||
);
|
||||
|
||||
if (data == null) {
|
||||
|
||||
@@ -8,15 +8,21 @@ import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class CuratedPlacesRow extends CuratedRow {
|
||||
final bool isMapEnabled;
|
||||
|
||||
const CuratedPlacesRow({
|
||||
super.key,
|
||||
required super.content,
|
||||
this.isMapEnabled = true,
|
||||
super.imageSize,
|
||||
super.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Calculating the actual index of the content based on the whether map is enabled or not.
|
||||
// If enabled, inject map as the first item in the list (index 0) and so the actual content will start from index 1
|
||||
final int actualContentIndex = isMapEnabled ? 1 : 0;
|
||||
Widget buildMapThumbnail() {
|
||||
return GestureDetector(
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
@@ -75,6 +81,24 @@ class CuratedPlacesRow extends CuratedRow {
|
||||
);
|
||||
}
|
||||
|
||||
// Return empty thumbnail
|
||||
if (!isMapEnabled && content.isEmpty) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: SizedBox(
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
child: ThumbnailWithInfo(
|
||||
textInfo: '',
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -82,11 +106,10 @@ class CuratedPlacesRow extends CuratedRow {
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
// Injecting Map thumbnail as the first element
|
||||
if (index == 0) {
|
||||
if (isMapEnabled && index == 0) {
|
||||
return buildMapThumbnail();
|
||||
}
|
||||
// The actual index is 1 less than the virutal index since we inject map into the first position
|
||||
final actualIndex = index - 1;
|
||||
final actualIndex = index - actualContentIndex;
|
||||
final object = content[actualIndex];
|
||||
final thumbnailRequestUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
|
||||
@@ -103,8 +126,7 @@ class CuratedPlacesRow extends CuratedRow {
|
||||
),
|
||||
);
|
||||
},
|
||||
// Adding 1 to inject map thumbnail as first element
|
||||
itemCount: content.length + 1,
|
||||
itemCount: content.length + actualContentIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
@@ -27,6 +28,8 @@ class SearchPage extends HookConsumerWidget {
|
||||
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
|
||||
final curatedLocation = ref.watch(getCuratedLocationProvider);
|
||||
final curatedPeople = ref.watch(getCuratedPeopleProvider);
|
||||
final isMapEnabled =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map));
|
||||
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
double imageSize = math.min(MediaQuery.of(context).size.width / 3, 150);
|
||||
|
||||
@@ -107,6 +110,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
data: (locations) => CuratedPlacesRow(
|
||||
isMapEnabled: isMapEnabled,
|
||||
content: locations
|
||||
.map(
|
||||
(o) => CuratedContent(
|
||||
|
||||
@@ -48,6 +48,7 @@ enum AppSettingsEnum<T> {
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||
mapThemeMode<bool>(StoreKey.mapThemeMode, null, false),
|
||||
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
|
||||
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
|
||||
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
||||
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
|
||||
;
|
||||
|
||||
125
mobile/lib/modules/trash/providers/trashed_asset.provider.dart
Normal file
125
mobile/lib/modules/trash/providers/trashed_asset.provider.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/trash/services/trash.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class TrashNotifier extends StateNotifier<bool> {
|
||||
final Isar _db;
|
||||
final Ref _ref;
|
||||
final TrashService _trashService;
|
||||
final _log = Logger('TrashNotifier');
|
||||
|
||||
TrashNotifier(
|
||||
this._trashService,
|
||||
this._db,
|
||||
this._ref,
|
||||
) : super(false);
|
||||
|
||||
Future<void> emptyTrash() async {
|
||||
try {
|
||||
final user = _ref.read(currentUserProvider);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
await _trashService.emptyTrash();
|
||||
|
||||
final dbIds = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isTrashedEqualTo(true)
|
||||
.idProperty()
|
||||
.findAll();
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.exifInfos.deleteAll(dbIds);
|
||||
await _db.assets.deleteAll(dbIds);
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
|
||||
try {
|
||||
final result = await _trashService.restoreAssets(assetList);
|
||||
|
||||
if (result) {
|
||||
final remoteAssets = assetList.where((a) => a.isRemote).toList();
|
||||
|
||||
final updatedAssets = remoteAssets.map((e) {
|
||||
e.isTrashed = false;
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(updatedAssets);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> restoreTrash() async {
|
||||
try {
|
||||
final user = _ref.read(currentUserProvider);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
await _trashService.restoreTrash();
|
||||
|
||||
final assets = await _db.assets
|
||||
.where()
|
||||
.remoteIdIsNotNull()
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isTrashedEqualTo(true)
|
||||
.findAll();
|
||||
|
||||
final updatedAssets = assets.map((e) {
|
||||
e.isTrashed = false;
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
await _db.writeTxn(() async {
|
||||
await _db.assets.putAll(updatedAssets);
|
||||
});
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final trashProvider = StateNotifierProvider<TrashNotifier, bool>((ref) {
|
||||
return TrashNotifier(
|
||||
ref.watch(trashServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
||||
final trashedAssetsProvider = StreamProvider<RenderList>((ref) async* {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null) return;
|
||||
final query = ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.ownerIdEqualTo(user.isarId)
|
||||
.isTrashedEqualTo(true)
|
||||
.sortByFileCreatedAt();
|
||||
const groupBy = GroupAssetsBy.none;
|
||||
yield await RenderList.fromQuery(query, groupBy);
|
||||
await for (final _ in query.watchLazy()) {
|
||||
yield await RenderList.fromQuery(query, groupBy);
|
||||
}
|
||||
});
|
||||
48
mobile/lib/modules/trash/services/trash.service.dart
Normal file
48
mobile/lib/modules/trash/services/trash.service.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final trashServiceProvider = Provider<TrashService>((ref) {
|
||||
return TrashService(
|
||||
ref.watch(apiServiceProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class TrashService {
|
||||
final _log = Logger("TrashService");
|
||||
|
||||
final ApiService _apiService;
|
||||
|
||||
TrashService(this._apiService);
|
||||
|
||||
Future<bool> restoreAssets(Iterable<Asset> assetList) async {
|
||||
try {
|
||||
List<String> remoteIds =
|
||||
assetList.where((a) => a.isRemote).map((e) => e.remoteId!).toList();
|
||||
await _apiService.assetApi.restoreAssets(BulkIdsDto(ids: remoteIds));
|
||||
return true;
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore assets ${error.toString()}", error, stack);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> emptyTrash() async {
|
||||
try {
|
||||
await _apiService.assetApi.emptyTrash();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot empty trash ${error.toString()}", error, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restoreTrash() async {
|
||||
try {
|
||||
await _apiService.assetApi.restoreTrash();
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot restore trash ${error.toString()}", error, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
276
mobile/lib/modules/trash/views/trash_page.dart
Normal file
276
mobile/lib/modules/trash/views/trash_page.dart
Normal file
@@ -0,0 +1,276 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
|
||||
import 'package:immich_mobile/modules/trash/providers/trashed_asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
|
||||
class TrashPage extends HookConsumerWidget {
|
||||
const TrashPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final trashedAssets = ref.watch(trashedAssetsProvider);
|
||||
final trashDays =
|
||||
ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays));
|
||||
final selectionEnabledHook = useState(false);
|
||||
final selection = useState(<Asset>{});
|
||||
final processing = useState(false);
|
||||
|
||||
void selectionListener(
|
||||
bool multiselect,
|
||||
Set<Asset> selectedAssets,
|
||||
) {
|
||||
selectionEnabledHook.value = multiselect;
|
||||
selection.value = selectedAssets;
|
||||
}
|
||||
|
||||
onEmptyTrash() async {
|
||||
processing.value = true;
|
||||
await ref.read(trashProvider.notifier).emptyTrash();
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Emptied trash',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleEmptyTrash() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
onOk: () => onEmptyTrash(),
|
||||
title: "trash_page_empty_trash_btn".tr(),
|
||||
ok: "trash_page_empty_trash_dialog_ok".tr(),
|
||||
content: "trash_page_empty_trash_dialog_content".tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onPermanentlyDelete() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteAssets(selection.value, force: true);
|
||||
|
||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'${selection.value.length} $assetOrAssets deleted permanently',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
handlePermanentDelete() async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => DeleteDialog(
|
||||
onDelete: () => onPermanentlyDelete(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleRestoreAll() async {
|
||||
processing.value = true;
|
||||
await ref.read(trashProvider.notifier).restoreTrash();
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
|
||||
Future<void> handleRestore() async {
|
||||
processing.value = true;
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
final result = await ref
|
||||
.read(trashProvider.notifier)
|
||||
.restoreAssets(selection.value);
|
||||
|
||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
||||
if (result && context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'${selection.value.length} $assetOrAssets restored successfully',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
String getAppBarTitle(String count) {
|
||||
if (selectionEnabledHook.value) {
|
||||
return selection.value.isNotEmpty
|
||||
? "${selection.value.length}"
|
||||
: "trash_page_select_assets_btn".tr();
|
||||
}
|
||||
return 'trash_page_title'.tr(args: [count]);
|
||||
}
|
||||
|
||||
AppBar buildAppBar(String count) {
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: !selectionEnabledHook.value
|
||||
? () => AutoRouter.of(context).pop()
|
||||
: () {
|
||||
selectionEnabledHook.value = false;
|
||||
selection.value = {};
|
||||
},
|
||||
icon: !selectionEnabledHook.value
|
||||
? const Icon(Icons.arrow_back_ios_rounded)
|
||||
: const Icon(Icons.close_rounded),
|
||||
),
|
||||
centerTitle: !selectionEnabledHook.value,
|
||||
automaticallyImplyLeading: false,
|
||||
title: Text(getAppBarTitle(count)),
|
||||
actions: <Widget>[
|
||||
if (!selectionEnabledHook.value)
|
||||
PopupMenuButton<void Function()>(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
PopupMenuItem(
|
||||
value: () => selectionEnabledHook.value = true,
|
||||
child: const Text('trash_page_select_btn').tr(),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: handleEmptyTrash,
|
||||
child: const Text('trash_page_empty_trash_btn').tr(),
|
||||
),
|
||||
];
|
||||
},
|
||||
onSelected: (fn) => fn(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBottomBar() {
|
||||
return SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Container(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: Icon(
|
||||
Icons.delete_forever,
|
||||
color: Colors.red[400],
|
||||
),
|
||||
label: Text(
|
||||
selection.value.isEmpty
|
||||
? 'trash_page_delete_all'.tr()
|
||||
: 'trash_page_delete'.tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.red[400],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: processing.value
|
||||
? null
|
||||
: selection.value.isEmpty
|
||||
? handleEmptyTrash
|
||||
: handlePermanentDelete,
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(
|
||||
Icons.history_rounded,
|
||||
),
|
||||
label: Text(
|
||||
selection.value.isEmpty
|
||||
? 'trash_page_restore_all'.tr()
|
||||
: 'trash_page_restore'.tr(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onPressed: processing.value
|
||||
? null
|
||||
: selection.value.isEmpty
|
||||
? handleRestoreAll
|
||||
: handleRestore,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return trashedAssets.when(
|
||||
loading: () => Scaffold(
|
||||
appBar: buildAppBar("?"),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stackTrace) => Scaffold(
|
||||
appBar: buildAppBar("!"),
|
||||
body: Center(child: Text(error.toString())),
|
||||
),
|
||||
data: (data) => Scaffold(
|
||||
appBar: buildAppBar(data.totalAssets.toString()),
|
||||
body: data.isEmpty
|
||||
? Center(
|
||||
child: Text('trash_page_no_assets'.tr()),
|
||||
)
|
||||
: Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: ImmichAssetGrid(
|
||||
renderList: data,
|
||||
listener: selectionListener,
|
||||
selectionActive: selectionEnabledHook.value,
|
||||
showMultiSelectIndicator: false,
|
||||
topWidget: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 24,
|
||||
bottom: 24,
|
||||
left: 12,
|
||||
right: 12,
|
||||
),
|
||||
child: const Text(
|
||||
"trash_page_info",
|
||||
).tr(args: ["$trashDays"]),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selectionEnabledHook.value) buildBottomBar(),
|
||||
if (processing.value)
|
||||
const Center(child: ImmichLoadingIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart';
|
||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
||||
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
|
||||
import 'package:immich_mobile/modules/trash/views/trash_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/all_people_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
|
||||
@@ -155,6 +156,7 @@ part 'router.gr.dart';
|
||||
AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
],
|
||||
)
|
||||
class AppRouter extends _$AppRouter {
|
||||
|
||||
@@ -312,6 +312,12 @@ class _$AppRouter extends RootStackRouter {
|
||||
),
|
||||
);
|
||||
},
|
||||
TrashRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const TrashPage(),
|
||||
);
|
||||
},
|
||||
HomeRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
@@ -624,6 +630,14 @@ class _$AppRouter extends RootStackRouter {
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
TrashRoute.name,
|
||||
path: '/trash-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1394,6 +1408,18 @@ class AlbumOptionsRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [TrashPage]
|
||||
class TrashRoute extends PageRouteInfo<void> {
|
||||
const TrashRoute()
|
||||
: super(
|
||||
TrashRoute.name,
|
||||
path: '/trash-page',
|
||||
);
|
||||
|
||||
static const String name = 'TrashRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [HomePage]
|
||||
class HomeRoute extends PageRouteInfo<void> {
|
||||
|
||||
@@ -64,10 +64,10 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
}
|
||||
|
||||
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
|
||||
ref.read(serverInfoProvider.notifier).getServerVersion();
|
||||
} catch (e) {
|
||||
debugPrint("Error refreshing user info $e");
|
||||
}
|
||||
}
|
||||
ref.watch(serverInfoProvider.notifier).getServerVersion();
|
||||
}
|
||||
}
|
||||
|
||||
69
mobile/lib/shared/cache/custom_image_cache.dart
vendored
Normal file
69
mobile/lib/shared/cache/custom_image_cache.dart
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
import 'original_image_provider.dart';
|
||||
|
||||
/// [ImageCache] that uses two caches for small and large images
|
||||
/// so that a single large image does not evict all small iamges
|
||||
final class CustomImageCache implements ImageCache {
|
||||
final _small = ImageCache();
|
||||
final _large = ImageCache();
|
||||
|
||||
@override
|
||||
int get maximumSize => _small.maximumSize + _large.maximumSize;
|
||||
|
||||
@override
|
||||
int get maximumSizeBytes => _small.maximumSizeBytes + _large.maximumSizeBytes;
|
||||
|
||||
@override
|
||||
set maximumSize(int value) => _small.maximumSize = value;
|
||||
|
||||
@override
|
||||
set maximumSizeBytes(int value) => _small.maximumSize = value;
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
_small.clear();
|
||||
_large.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
void clearLiveImages() {
|
||||
_small.clearLiveImages();
|
||||
_large.clearLiveImages();
|
||||
}
|
||||
|
||||
@override
|
||||
bool containsKey(Object key) =>
|
||||
(key is OriginalImageProvider ? _large : _small).containsKey(key);
|
||||
|
||||
@override
|
||||
int get currentSize => _small.currentSize + _large.currentSize;
|
||||
|
||||
@override
|
||||
int get currentSizeBytes => _small.currentSizeBytes + _large.currentSizeBytes;
|
||||
|
||||
@override
|
||||
bool evict(Object key, {bool includeLive = true}) =>
|
||||
(key is OriginalImageProvider ? _large : _small)
|
||||
.evict(key, includeLive: includeLive);
|
||||
|
||||
@override
|
||||
int get liveImageCount => _small.liveImageCount + _large.liveImageCount;
|
||||
|
||||
@override
|
||||
int get pendingImageCount =>
|
||||
_small.pendingImageCount + _large.pendingImageCount;
|
||||
|
||||
@override
|
||||
ImageStreamCompleter? putIfAbsent(
|
||||
Object key,
|
||||
ImageStreamCompleter Function() loader, {
|
||||
ImageErrorListener? onError,
|
||||
}) =>
|
||||
(key is OriginalImageProvider ? _large : _small)
|
||||
.putIfAbsent(key, loader, onError: onError);
|
||||
|
||||
@override
|
||||
ImageCacheStatus statusForKey(Object key) =>
|
||||
(key is OriginalImageProvider ? _large : _small).statusForKey(key);
|
||||
}
|
||||
73
mobile/lib/shared/cache/original_image_provider.dart
vendored
Normal file
73
mobile/lib/shared/cache/original_image_provider.dart
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
/// Loads the original image for local assets
|
||||
@immutable
|
||||
final class OriginalImageProvider extends ImageProvider<OriginalImageProvider> {
|
||||
final Asset asset;
|
||||
|
||||
const OriginalImageProvider(this.asset);
|
||||
|
||||
@override
|
||||
Future<OriginalImageProvider> obtainKey(ImageConfiguration configuration) =>
|
||||
SynchronousFuture<OriginalImageProvider>(this);
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
OriginalImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) =>
|
||||
MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode),
|
||||
scale: 1.0,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(asset.fileName);
|
||||
},
|
||||
);
|
||||
|
||||
Future<ui.Codec> _loadAsync(
|
||||
OriginalImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
final ui.ImmutableBuffer buffer;
|
||||
if (asset.isImage) {
|
||||
final File? file = await asset.local?.originFile;
|
||||
if (file == null) {
|
||||
throw StateError("Opening file for asset ${asset.fileName} failed");
|
||||
}
|
||||
try {
|
||||
buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
|
||||
} catch (error) {
|
||||
throw StateError("Loading asset ${asset.fileName} failed");
|
||||
}
|
||||
} else {
|
||||
final thumbBytes = await asset.local?.thumbnailData;
|
||||
if (thumbBytes == null) {
|
||||
throw StateError("Loading thumb for video ${asset.fileName} failed");
|
||||
}
|
||||
buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
}
|
||||
try {
|
||||
final codec = await decode(buffer);
|
||||
debugPrint("Decoded image ${asset.fileName}");
|
||||
return codec;
|
||||
} catch (error) {
|
||||
throw StateError("Decoding asset ${asset.fileName} failed");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! OriginalImageProvider) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return asset == other.asset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.hashCode;
|
||||
}
|
||||
8
mobile/lib/shared/cache/widgets_binding.dart
vendored
Normal file
8
mobile/lib/shared/cache/widgets_binding.dart
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'custom_image_cache.dart';
|
||||
|
||||
final class ImmichWidgetsBinding extends WidgetsFlutterBinding {
|
||||
@override
|
||||
ImageCache createImageCache() => CustomImageCache();
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class Album {
|
||||
required this.name,
|
||||
required this.createdAt,
|
||||
required this.modifiedAt,
|
||||
this.lastModifiedAssetTimestamp,
|
||||
required this.shared,
|
||||
});
|
||||
|
||||
@@ -29,6 +30,7 @@ class Album {
|
||||
String name;
|
||||
DateTime createdAt;
|
||||
DateTime modifiedAt;
|
||||
DateTime? lastModifiedAssetTimestamp;
|
||||
bool shared;
|
||||
final IsarLink<User> owner = IsarLink<User>();
|
||||
final IsarLink<Asset> thumbnail = IsarLink<Asset>();
|
||||
@@ -83,12 +85,21 @@ class Album {
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
if (other is! Album) return false;
|
||||
|
||||
final lastModifiedAssetTimestampIsSetAndEqual =
|
||||
lastModifiedAssetTimestamp != null &&
|
||||
other.lastModifiedAssetTimestamp != null
|
||||
? lastModifiedAssetTimestamp!
|
||||
.isAtSameMomentAs(other.lastModifiedAssetTimestamp!)
|
||||
: true;
|
||||
|
||||
return id == other.id &&
|
||||
remoteId == other.remoteId &&
|
||||
localId == other.localId &&
|
||||
name == other.name &&
|
||||
createdAt.isAtSameMomentAs(other.createdAt) &&
|
||||
modifiedAt.isAtSameMomentAs(other.modifiedAt) &&
|
||||
lastModifiedAssetTimestampIsSetAndEqual &&
|
||||
shared == other.shared &&
|
||||
owner.value == other.owner.value &&
|
||||
thumbnail.value == other.thumbnail.value &&
|
||||
@@ -105,6 +116,7 @@ class Album {
|
||||
name.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
modifiedAt.hashCode ^
|
||||
lastModifiedAssetTimestamp.hashCode ^
|
||||
shared.hashCode ^
|
||||
owner.value.hashCode ^
|
||||
thumbnail.value.hashCode ^
|
||||
@@ -130,6 +142,7 @@ class Album {
|
||||
name: dto.albumName,
|
||||
createdAt: dto.createdAt,
|
||||
modifiedAt: dto.updatedAt,
|
||||
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
|
||||
shared: dto.shared,
|
||||
);
|
||||
a.owner.value = await db.users.getById(dto.ownerId);
|
||||
|
||||
@@ -22,28 +22,33 @@ const AlbumSchema = CollectionSchema(
|
||||
name: r'createdAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'localId': PropertySchema(
|
||||
r'lastModifiedAssetTimestamp': PropertySchema(
|
||||
id: 1,
|
||||
name: r'lastModifiedAssetTimestamp',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'localId': PropertySchema(
|
||||
id: 2,
|
||||
name: r'localId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'modifiedAt': PropertySchema(
|
||||
id: 2,
|
||||
id: 3,
|
||||
name: r'modifiedAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'name': PropertySchema(
|
||||
id: 3,
|
||||
id: 4,
|
||||
name: r'name',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'remoteId': PropertySchema(
|
||||
id: 4,
|
||||
id: 5,
|
||||
name: r'remoteId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'shared': PropertySchema(
|
||||
id: 5,
|
||||
id: 6,
|
||||
name: r'shared',
|
||||
type: IsarType.bool,
|
||||
)
|
||||
@@ -143,11 +148,12 @@ void _albumSerialize(
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeDateTime(offsets[0], object.createdAt);
|
||||
writer.writeString(offsets[1], object.localId);
|
||||
writer.writeDateTime(offsets[2], object.modifiedAt);
|
||||
writer.writeString(offsets[3], object.name);
|
||||
writer.writeString(offsets[4], object.remoteId);
|
||||
writer.writeBool(offsets[5], object.shared);
|
||||
writer.writeDateTime(offsets[1], object.lastModifiedAssetTimestamp);
|
||||
writer.writeString(offsets[2], object.localId);
|
||||
writer.writeDateTime(offsets[3], object.modifiedAt);
|
||||
writer.writeString(offsets[4], object.name);
|
||||
writer.writeString(offsets[5], object.remoteId);
|
||||
writer.writeBool(offsets[6], object.shared);
|
||||
}
|
||||
|
||||
Album _albumDeserialize(
|
||||
@@ -158,11 +164,12 @@ Album _albumDeserialize(
|
||||
) {
|
||||
final object = Album(
|
||||
createdAt: reader.readDateTime(offsets[0]),
|
||||
localId: reader.readStringOrNull(offsets[1]),
|
||||
modifiedAt: reader.readDateTime(offsets[2]),
|
||||
name: reader.readString(offsets[3]),
|
||||
remoteId: reader.readStringOrNull(offsets[4]),
|
||||
shared: reader.readBool(offsets[5]),
|
||||
lastModifiedAssetTimestamp: reader.readDateTimeOrNull(offsets[1]),
|
||||
localId: reader.readStringOrNull(offsets[2]),
|
||||
modifiedAt: reader.readDateTime(offsets[3]),
|
||||
name: reader.readString(offsets[4]),
|
||||
remoteId: reader.readStringOrNull(offsets[5]),
|
||||
shared: reader.readBool(offsets[6]),
|
||||
);
|
||||
object.id = id;
|
||||
return object;
|
||||
@@ -178,14 +185,16 @@ P _albumDeserializeProp<P>(
|
||||
case 0:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 1:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
return (reader.readDateTimeOrNull(offset)) as P;
|
||||
case 2:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 3:
|
||||
return (reader.readString(offset)) as P;
|
||||
case 4:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 3:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 4:
|
||||
return (reader.readString(offset)) as P;
|
||||
case 5:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 6:
|
||||
return (reader.readBool(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
@@ -520,6 +529,80 @@ extension AlbumQueryFilter on QueryBuilder<Album, Album, QFilterCondition> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampEqualTo(DateTime? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampGreaterThan(
|
||||
DateTime? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampLessThan(
|
||||
DateTime? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition>
|
||||
lastModifiedAssetTimestampBetween(
|
||||
DateTime? lower,
|
||||
DateTime? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.between(
|
||||
property: r'lastModifiedAssetTimestamp',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterFilterCondition> localIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
@@ -1158,6 +1241,19 @@ extension AlbumQuerySortBy on QueryBuilder<Album, Album, QSortBy> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> sortByLastModifiedAssetTimestamp() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy>
|
||||
sortByLastModifiedAssetTimestampDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> sortByLocalId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'localId', Sort.asc);
|
||||
@@ -1244,6 +1340,19 @@ extension AlbumQuerySortThenBy on QueryBuilder<Album, Album, QSortThenBy> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> thenByLastModifiedAssetTimestamp() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy>
|
||||
thenByLastModifiedAssetTimestampDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'lastModifiedAssetTimestamp', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QAfterSortBy> thenByLocalId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'localId', Sort.asc);
|
||||
@@ -1312,6 +1421,12 @@ extension AlbumQueryWhereDistinct on QueryBuilder<Album, Album, QDistinct> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QDistinct> distinctByLastModifiedAssetTimestamp() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'lastModifiedAssetTimestamp');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, Album, QDistinct> distinctByLocalId(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
@@ -1359,6 +1474,13 @@ extension AlbumQueryProperty on QueryBuilder<Album, Album, QQueryProperty> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, DateTime?, QQueryOperations>
|
||||
lastModifiedAssetTimestampProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'lastModifiedAssetTimestamp');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Album, String?, QQueryOperations> localIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'localId');
|
||||
|
||||
@@ -30,7 +30,8 @@ class Asset {
|
||||
exifInfo =
|
||||
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
|
||||
isFavorite = remote.isFavorite,
|
||||
isArchived = remote.isArchived;
|
||||
isArchived = remote.isArchived,
|
||||
isTrashed = remote.isTrashed;
|
||||
|
||||
Asset.local(AssetEntity local, List<int> hash)
|
||||
: localId = local.id,
|
||||
@@ -45,6 +46,7 @@ class Asset {
|
||||
updatedAt = local.modifiedDateTime,
|
||||
isFavorite = local.isFavorite,
|
||||
isArchived = false,
|
||||
isTrashed = false,
|
||||
fileCreatedAt = local.createDateTime {
|
||||
if (fileCreatedAt.year == 1970) {
|
||||
fileCreatedAt = fileModifiedAt;
|
||||
@@ -74,6 +76,7 @@ class Asset {
|
||||
this.exifInfo,
|
||||
required this.isFavorite,
|
||||
required this.isArchived,
|
||||
required this.isTrashed,
|
||||
});
|
||||
|
||||
@ignore
|
||||
@@ -100,12 +103,6 @@ class Asset {
|
||||
|
||||
/// stores the raw SHA1 bytes as a base64 String
|
||||
/// because Isar cannot sort lists of byte arrays
|
||||
@Index(
|
||||
unique: true,
|
||||
replace: false,
|
||||
type: IndexType.hash,
|
||||
composite: [CompositeIndex("ownerId")],
|
||||
)
|
||||
String checksum;
|
||||
|
||||
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||
@@ -114,6 +111,11 @@ class Asset {
|
||||
@Index(unique: false, replace: false, type: IndexType.hash)
|
||||
String? localId;
|
||||
|
||||
@Index(
|
||||
unique: true,
|
||||
replace: false,
|
||||
composite: [CompositeIndex("checksum", type: IndexType.hash)],
|
||||
)
|
||||
int ownerId;
|
||||
|
||||
DateTime fileCreatedAt;
|
||||
@@ -139,6 +141,8 @@ class Asset {
|
||||
|
||||
bool isArchived;
|
||||
|
||||
bool isTrashed;
|
||||
|
||||
@ignore
|
||||
ExifInfo? exifInfo;
|
||||
|
||||
@@ -178,6 +182,7 @@ class Asset {
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
if (other is! Asset) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return id == other.id &&
|
||||
checksum == other.checksum &&
|
||||
remoteId == other.remoteId &&
|
||||
@@ -194,7 +199,8 @@ class Asset {
|
||||
livePhotoVideoId == other.livePhotoVideoId &&
|
||||
isFavorite == other.isFavorite &&
|
||||
isLocal == other.isLocal &&
|
||||
isArchived == other.isArchived;
|
||||
isArchived == other.isArchived &&
|
||||
isTrashed == other.isTrashed;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -216,7 +222,8 @@ class Asset {
|
||||
livePhotoVideoId.hashCode ^
|
||||
isFavorite.hashCode ^
|
||||
isLocal.hashCode ^
|
||||
isArchived.hashCode;
|
||||
isArchived.hashCode ^
|
||||
isTrashed.hashCode;
|
||||
|
||||
/// Returns `true` if this [Asset] can updated with values from parameter [a]
|
||||
bool canUpdate(Asset a) {
|
||||
@@ -229,8 +236,9 @@ class Asset {
|
||||
width == null && a.width != null ||
|
||||
height == null && a.height != null ||
|
||||
livePhotoVideoId == null && a.livePhotoVideoId != null ||
|
||||
!isRemote && a.isRemote && isFavorite != a.isFavorite ||
|
||||
!isRemote && a.isRemote && isArchived != a.isArchived;
|
||||
isFavorite != a.isFavorite ||
|
||||
isArchived != a.isArchived ||
|
||||
isTrashed != a.isTrashed;
|
||||
}
|
||||
|
||||
/// Returns a new [Asset] with values from this and merged & updated with [a]
|
||||
@@ -261,6 +269,7 @@ class Asset {
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
isFavorite: isFavorite,
|
||||
isArchived: isArchived,
|
||||
isTrashed: isTrashed,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -275,6 +284,7 @@ class Asset {
|
||||
// isFavorite + isArchived are not set by device-only assets
|
||||
isFavorite: a.isFavorite,
|
||||
isArchived: a.isArchived,
|
||||
isTrashed: a.isTrashed,
|
||||
exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
|
||||
);
|
||||
} else {
|
||||
@@ -306,6 +316,7 @@ class Asset {
|
||||
String? livePhotoVideoId,
|
||||
bool? isFavorite,
|
||||
bool? isArchived,
|
||||
bool? isTrashed,
|
||||
ExifInfo? exifInfo,
|
||||
}) =>
|
||||
Asset(
|
||||
@@ -325,6 +336,7 @@ class Asset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
isArchived: isArchived ?? this.isArchived,
|
||||
isTrashed: isTrashed ?? this.isTrashed,
|
||||
exifInfo: exifInfo ?? this.exifInfo,
|
||||
);
|
||||
|
||||
@@ -378,7 +390,8 @@ class Asset {
|
||||
"storage": "$storage",
|
||||
"width": ${width ?? "N/A"},
|
||||
"height": ${height ?? "N/A"},
|
||||
"isArchived": $isArchived
|
||||
"isArchived": $isArchived,
|
||||
"isTrashed": $isTrashed,
|
||||
}""";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,39 +57,44 @@ const AssetSchema = CollectionSchema(
|
||||
name: r'isFavorite',
|
||||
type: IsarType.bool,
|
||||
),
|
||||
r'livePhotoVideoId': PropertySchema(
|
||||
r'isTrashed': PropertySchema(
|
||||
id: 8,
|
||||
name: r'isTrashed',
|
||||
type: IsarType.bool,
|
||||
),
|
||||
r'livePhotoVideoId': PropertySchema(
|
||||
id: 9,
|
||||
name: r'livePhotoVideoId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'localId': PropertySchema(
|
||||
id: 9,
|
||||
id: 10,
|
||||
name: r'localId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'ownerId': PropertySchema(
|
||||
id: 10,
|
||||
id: 11,
|
||||
name: r'ownerId',
|
||||
type: IsarType.long,
|
||||
),
|
||||
r'remoteId': PropertySchema(
|
||||
id: 11,
|
||||
id: 12,
|
||||
name: r'remoteId',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'type': PropertySchema(
|
||||
id: 12,
|
||||
id: 13,
|
||||
name: r'type',
|
||||
type: IsarType.byte,
|
||||
enumMap: _AssettypeEnumValueMap,
|
||||
),
|
||||
r'updatedAt': PropertySchema(
|
||||
id: 13,
|
||||
id: 14,
|
||||
name: r'updatedAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
r'width': PropertySchema(
|
||||
id: 14,
|
||||
id: 15,
|
||||
name: r'width',
|
||||
type: IsarType.int,
|
||||
)
|
||||
@@ -100,24 +105,6 @@ const AssetSchema = CollectionSchema(
|
||||
deserializeProp: _assetDeserializeProp,
|
||||
idName: r'id',
|
||||
indexes: {
|
||||
r'checksum_ownerId': IndexSchema(
|
||||
id: 5611361749756160119,
|
||||
name: r'checksum_ownerId',
|
||||
unique: true,
|
||||
replace: false,
|
||||
properties: [
|
||||
IndexPropertySchema(
|
||||
name: r'checksum',
|
||||
type: IndexType.hash,
|
||||
caseSensitive: true,
|
||||
),
|
||||
IndexPropertySchema(
|
||||
name: r'ownerId',
|
||||
type: IndexType.value,
|
||||
caseSensitive: false,
|
||||
)
|
||||
],
|
||||
),
|
||||
r'remoteId': IndexSchema(
|
||||
id: 6301175856541681032,
|
||||
name: r'remoteId',
|
||||
@@ -143,6 +130,24 @@ const AssetSchema = CollectionSchema(
|
||||
caseSensitive: true,
|
||||
)
|
||||
],
|
||||
),
|
||||
r'ownerId_checksum': IndexSchema(
|
||||
id: -3295822444433175883,
|
||||
name: r'ownerId_checksum',
|
||||
unique: true,
|
||||
replace: false,
|
||||
properties: [
|
||||
IndexPropertySchema(
|
||||
name: r'ownerId',
|
||||
type: IndexType.value,
|
||||
caseSensitive: false,
|
||||
),
|
||||
IndexPropertySchema(
|
||||
name: r'checksum',
|
||||
type: IndexType.hash,
|
||||
caseSensitive: true,
|
||||
)
|
||||
],
|
||||
)
|
||||
},
|
||||
links: {},
|
||||
@@ -196,13 +201,14 @@ void _assetSerialize(
|
||||
writer.writeInt(offsets[5], object.height);
|
||||
writer.writeBool(offsets[6], object.isArchived);
|
||||
writer.writeBool(offsets[7], object.isFavorite);
|
||||
writer.writeString(offsets[8], object.livePhotoVideoId);
|
||||
writer.writeString(offsets[9], object.localId);
|
||||
writer.writeLong(offsets[10], object.ownerId);
|
||||
writer.writeString(offsets[11], object.remoteId);
|
||||
writer.writeByte(offsets[12], object.type.index);
|
||||
writer.writeDateTime(offsets[13], object.updatedAt);
|
||||
writer.writeInt(offsets[14], object.width);
|
||||
writer.writeBool(offsets[8], object.isTrashed);
|
||||
writer.writeString(offsets[9], object.livePhotoVideoId);
|
||||
writer.writeString(offsets[10], object.localId);
|
||||
writer.writeLong(offsets[11], object.ownerId);
|
||||
writer.writeString(offsets[12], object.remoteId);
|
||||
writer.writeByte(offsets[13], object.type.index);
|
||||
writer.writeDateTime(offsets[14], object.updatedAt);
|
||||
writer.writeInt(offsets[15], object.width);
|
||||
}
|
||||
|
||||
Asset _assetDeserialize(
|
||||
@@ -221,14 +227,15 @@ Asset _assetDeserialize(
|
||||
id: id,
|
||||
isArchived: reader.readBool(offsets[6]),
|
||||
isFavorite: reader.readBool(offsets[7]),
|
||||
livePhotoVideoId: reader.readStringOrNull(offsets[8]),
|
||||
localId: reader.readStringOrNull(offsets[9]),
|
||||
ownerId: reader.readLong(offsets[10]),
|
||||
remoteId: reader.readStringOrNull(offsets[11]),
|
||||
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
|
||||
isTrashed: reader.readBool(offsets[8]),
|
||||
livePhotoVideoId: reader.readStringOrNull(offsets[9]),
|
||||
localId: reader.readStringOrNull(offsets[10]),
|
||||
ownerId: reader.readLong(offsets[11]),
|
||||
remoteId: reader.readStringOrNull(offsets[12]),
|
||||
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
|
||||
AssetType.other,
|
||||
updatedAt: reader.readDateTime(offsets[13]),
|
||||
width: reader.readIntOrNull(offsets[14]),
|
||||
updatedAt: reader.readDateTime(offsets[14]),
|
||||
width: reader.readIntOrNull(offsets[15]),
|
||||
);
|
||||
return object;
|
||||
}
|
||||
@@ -257,19 +264,21 @@ P _assetDeserializeProp<P>(
|
||||
case 7:
|
||||
return (reader.readBool(offset)) as P;
|
||||
case 8:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
return (reader.readBool(offset)) as P;
|
||||
case 9:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 10:
|
||||
return (reader.readLong(offset)) as P;
|
||||
case 11:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 11:
|
||||
return (reader.readLong(offset)) as P;
|
||||
case 12:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 13:
|
||||
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
|
||||
AssetType.other) as P;
|
||||
case 13:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 14:
|
||||
return (reader.readDateTime(offset)) as P;
|
||||
case 15:
|
||||
return (reader.readIntOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
@@ -302,89 +311,89 @@ void _assetAttach(IsarCollection<dynamic> col, Id id, Asset object) {
|
||||
}
|
||||
|
||||
extension AssetByIndex on IsarCollection<Asset> {
|
||||
Future<Asset?> getByChecksumOwnerId(String checksum, int ownerId) {
|
||||
return getByIndex(r'checksum_ownerId', [checksum, ownerId]);
|
||||
Future<Asset?> getByOwnerIdChecksum(int ownerId, String checksum) {
|
||||
return getByIndex(r'ownerId_checksum', [ownerId, checksum]);
|
||||
}
|
||||
|
||||
Asset? getByChecksumOwnerIdSync(String checksum, int ownerId) {
|
||||
return getByIndexSync(r'checksum_ownerId', [checksum, ownerId]);
|
||||
Asset? getByOwnerIdChecksumSync(int ownerId, String checksum) {
|
||||
return getByIndexSync(r'ownerId_checksum', [ownerId, checksum]);
|
||||
}
|
||||
|
||||
Future<bool> deleteByChecksumOwnerId(String checksum, int ownerId) {
|
||||
return deleteByIndex(r'checksum_ownerId', [checksum, ownerId]);
|
||||
Future<bool> deleteByOwnerIdChecksum(int ownerId, String checksum) {
|
||||
return deleteByIndex(r'ownerId_checksum', [ownerId, checksum]);
|
||||
}
|
||||
|
||||
bool deleteByChecksumOwnerIdSync(String checksum, int ownerId) {
|
||||
return deleteByIndexSync(r'checksum_ownerId', [checksum, ownerId]);
|
||||
bool deleteByOwnerIdChecksumSync(int ownerId, String checksum) {
|
||||
return deleteByIndexSync(r'ownerId_checksum', [ownerId, checksum]);
|
||||
}
|
||||
|
||||
Future<List<Asset?>> getAllByChecksumOwnerId(
|
||||
List<String> checksumValues, List<int> ownerIdValues) {
|
||||
final len = checksumValues.length;
|
||||
assert(ownerIdValues.length == len,
|
||||
Future<List<Asset?>> getAllByOwnerIdChecksum(
|
||||
List<int> ownerIdValues, List<String> checksumValues) {
|
||||
final len = ownerIdValues.length;
|
||||
assert(checksumValues.length == len,
|
||||
'All index values must have the same length');
|
||||
final values = <List<dynamic>>[];
|
||||
for (var i = 0; i < len; i++) {
|
||||
values.add([checksumValues[i], ownerIdValues[i]]);
|
||||
values.add([ownerIdValues[i], checksumValues[i]]);
|
||||
}
|
||||
|
||||
return getAllByIndex(r'checksum_ownerId', values);
|
||||
return getAllByIndex(r'ownerId_checksum', values);
|
||||
}
|
||||
|
||||
List<Asset?> getAllByChecksumOwnerIdSync(
|
||||
List<String> checksumValues, List<int> ownerIdValues) {
|
||||
final len = checksumValues.length;
|
||||
assert(ownerIdValues.length == len,
|
||||
List<Asset?> getAllByOwnerIdChecksumSync(
|
||||
List<int> ownerIdValues, List<String> checksumValues) {
|
||||
final len = ownerIdValues.length;
|
||||
assert(checksumValues.length == len,
|
||||
'All index values must have the same length');
|
||||
final values = <List<dynamic>>[];
|
||||
for (var i = 0; i < len; i++) {
|
||||
values.add([checksumValues[i], ownerIdValues[i]]);
|
||||
values.add([ownerIdValues[i], checksumValues[i]]);
|
||||
}
|
||||
|
||||
return getAllByIndexSync(r'checksum_ownerId', values);
|
||||
return getAllByIndexSync(r'ownerId_checksum', values);
|
||||
}
|
||||
|
||||
Future<int> deleteAllByChecksumOwnerId(
|
||||
List<String> checksumValues, List<int> ownerIdValues) {
|
||||
final len = checksumValues.length;
|
||||
assert(ownerIdValues.length == len,
|
||||
Future<int> deleteAllByOwnerIdChecksum(
|
||||
List<int> ownerIdValues, List<String> checksumValues) {
|
||||
final len = ownerIdValues.length;
|
||||
assert(checksumValues.length == len,
|
||||
'All index values must have the same length');
|
||||
final values = <List<dynamic>>[];
|
||||
for (var i = 0; i < len; i++) {
|
||||
values.add([checksumValues[i], ownerIdValues[i]]);
|
||||
values.add([ownerIdValues[i], checksumValues[i]]);
|
||||
}
|
||||
|
||||
return deleteAllByIndex(r'checksum_ownerId', values);
|
||||
return deleteAllByIndex(r'ownerId_checksum', values);
|
||||
}
|
||||
|
||||
int deleteAllByChecksumOwnerIdSync(
|
||||
List<String> checksumValues, List<int> ownerIdValues) {
|
||||
final len = checksumValues.length;
|
||||
assert(ownerIdValues.length == len,
|
||||
int deleteAllByOwnerIdChecksumSync(
|
||||
List<int> ownerIdValues, List<String> checksumValues) {
|
||||
final len = ownerIdValues.length;
|
||||
assert(checksumValues.length == len,
|
||||
'All index values must have the same length');
|
||||
final values = <List<dynamic>>[];
|
||||
for (var i = 0; i < len; i++) {
|
||||
values.add([checksumValues[i], ownerIdValues[i]]);
|
||||
values.add([ownerIdValues[i], checksumValues[i]]);
|
||||
}
|
||||
|
||||
return deleteAllByIndexSync(r'checksum_ownerId', values);
|
||||
return deleteAllByIndexSync(r'ownerId_checksum', values);
|
||||
}
|
||||
|
||||
Future<Id> putByChecksumOwnerId(Asset object) {
|
||||
return putByIndex(r'checksum_ownerId', object);
|
||||
Future<Id> putByOwnerIdChecksum(Asset object) {
|
||||
return putByIndex(r'ownerId_checksum', object);
|
||||
}
|
||||
|
||||
Id putByChecksumOwnerIdSync(Asset object, {bool saveLinks = true}) {
|
||||
return putByIndexSync(r'checksum_ownerId', object, saveLinks: saveLinks);
|
||||
Id putByOwnerIdChecksumSync(Asset object, {bool saveLinks = true}) {
|
||||
return putByIndexSync(r'ownerId_checksum', object, saveLinks: saveLinks);
|
||||
}
|
||||
|
||||
Future<List<Id>> putAllByChecksumOwnerId(List<Asset> objects) {
|
||||
return putAllByIndex(r'checksum_ownerId', objects);
|
||||
Future<List<Id>> putAllByOwnerIdChecksum(List<Asset> objects) {
|
||||
return putAllByIndex(r'ownerId_checksum', objects);
|
||||
}
|
||||
|
||||
List<Id> putAllByChecksumOwnerIdSync(List<Asset> objects,
|
||||
List<Id> putAllByOwnerIdChecksumSync(List<Asset> objects,
|
||||
{bool saveLinks = true}) {
|
||||
return putAllByIndexSync(r'checksum_ownerId', objects,
|
||||
return putAllByIndexSync(r'ownerId_checksum', objects,
|
||||
saveLinks: saveLinks);
|
||||
}
|
||||
}
|
||||
@@ -463,145 +472,6 @@ extension AssetQueryWhere on QueryBuilder<Asset, Asset, QWhereClause> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumEqualToAnyOwnerId(
|
||||
String checksum) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'checksum_ownerId',
|
||||
value: [checksum],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumNotEqualToAnyOwnerId(
|
||||
String checksum) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [],
|
||||
upper: [checksum],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [checksum],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [checksum],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [],
|
||||
upper: [checksum],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumOwnerIdEqualTo(
|
||||
String checksum, int ownerId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'checksum_ownerId',
|
||||
value: [checksum, ownerId],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause>
|
||||
checksumEqualToOwnerIdNotEqualTo(String checksum, int ownerId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [checksum],
|
||||
upper: [checksum, ownerId],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [checksum, ownerId],
|
||||
includeLower: false,
|
||||
upper: [checksum],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [checksum, ownerId],
|
||||
includeLower: false,
|
||||
upper: [checksum],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [checksum],
|
||||
upper: [checksum, ownerId],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause>
|
||||
checksumEqualToOwnerIdGreaterThan(
|
||||
String checksum,
|
||||
int ownerId, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [checksum, ownerId],
|
||||
includeLower: include,
|
||||
upper: [checksum],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumEqualToOwnerIdLessThan(
|
||||
String checksum,
|
||||
int ownerId, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [checksum],
|
||||
upper: [checksum, ownerId],
|
||||
includeUpper: include,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> checksumEqualToOwnerIdBetween(
|
||||
String checksum,
|
||||
int lowerOwnerId,
|
||||
int upperOwnerId, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'checksum_ownerId',
|
||||
lower: [checksum, lowerOwnerId],
|
||||
includeLower: includeLower,
|
||||
upper: [checksum, upperOwnerId],
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> remoteIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
@@ -731,6 +601,141 @@ extension AssetQueryWhere on QueryBuilder<Asset, Asset, QWhereClause> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdEqualToAnyChecksum(
|
||||
int ownerId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'ownerId_checksum',
|
||||
value: [ownerId],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdNotEqualToAnyChecksum(
|
||||
int ownerId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [],
|
||||
upper: [ownerId],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [ownerId],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [ownerId],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [],
|
||||
upper: [ownerId],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdGreaterThanAnyChecksum(
|
||||
int ownerId, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [ownerId],
|
||||
includeLower: include,
|
||||
upper: [],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdLessThanAnyChecksum(
|
||||
int ownerId, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [],
|
||||
upper: [ownerId],
|
||||
includeUpper: include,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdBetweenAnyChecksum(
|
||||
int lowerOwnerId,
|
||||
int upperOwnerId, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [lowerOwnerId],
|
||||
includeLower: includeLower,
|
||||
upper: [upperOwnerId],
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause> ownerIdChecksumEqualTo(
|
||||
int ownerId, String checksum) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'ownerId_checksum',
|
||||
value: [ownerId, checksum],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterWhereClause>
|
||||
ownerIdEqualToChecksumNotEqualTo(int ownerId, String checksum) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [ownerId],
|
||||
upper: [ownerId, checksum],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [ownerId, checksum],
|
||||
includeLower: false,
|
||||
upper: [ownerId],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [ownerId, checksum],
|
||||
includeLower: false,
|
||||
upper: [ownerId],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'ownerId_checksum',
|
||||
lower: [ownerId],
|
||||
upper: [ownerId, checksum],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
|
||||
@@ -1294,6 +1299,16 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> isTrashedEqualTo(
|
||||
bool value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(FilterCondition.equalTo(
|
||||
property: r'isTrashed',
|
||||
value: value,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterFilterCondition> livePhotoVideoIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(const FilterCondition.isNull(
|
||||
@@ -2062,6 +2077,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsTrashed() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isTrashed', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsTrashedDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isTrashed', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> sortByLivePhotoVideoId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'livePhotoVideoId', Sort.asc);
|
||||
@@ -2256,6 +2283,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsTrashed() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isTrashed', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsTrashedDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'isTrashed', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QAfterSortBy> thenByLivePhotoVideoId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'livePhotoVideoId', Sort.asc);
|
||||
@@ -2392,6 +2431,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QDistinct> distinctByIsTrashed() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'isTrashed');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, Asset, QDistinct> distinctByLivePhotoVideoId(
|
||||
{bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
@@ -2494,6 +2539,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, bool, QQueryOperations> isTrashedProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'isTrashed');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<Asset, String?, QQueryOperations> livePhotoVideoIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'livePhotoVideoId');
|
||||
|
||||
@@ -2,22 +2,30 @@ import 'package:openapi/api.dart';
|
||||
|
||||
class ServerInfoState {
|
||||
final ServerVersionResponseDto serverVersion;
|
||||
final ServerFeaturesDto serverFeatures;
|
||||
final ServerConfigDto serverConfig;
|
||||
final bool isVersionMismatch;
|
||||
final String versionMismatchErrorMessage;
|
||||
|
||||
ServerInfoState({
|
||||
required this.serverVersion,
|
||||
required this.serverFeatures,
|
||||
required this.serverConfig,
|
||||
required this.isVersionMismatch,
|
||||
required this.versionMismatchErrorMessage,
|
||||
});
|
||||
|
||||
ServerInfoState copyWith({
|
||||
ServerVersionResponseDto? serverVersion,
|
||||
ServerFeaturesDto? serverFeatures,
|
||||
ServerConfigDto? serverConfig,
|
||||
bool? isVersionMismatch,
|
||||
String? versionMismatchErrorMessage,
|
||||
}) {
|
||||
return ServerInfoState(
|
||||
serverVersion: serverVersion ?? this.serverVersion,
|
||||
serverFeatures: serverFeatures ?? this.serverFeatures,
|
||||
serverConfig: serverConfig ?? this.serverConfig,
|
||||
isVersionMismatch: isVersionMismatch ?? this.isVersionMismatch,
|
||||
versionMismatchErrorMessage:
|
||||
versionMismatchErrorMessage ?? this.versionMismatchErrorMessage,
|
||||
@@ -26,7 +34,7 @@ class ServerInfoState {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServerInfoState( serverVersion: $serverVersion, isVersionMismatch: $isVersionMismatch, versionMismatchErrorMessage: $versionMismatchErrorMessage)';
|
||||
return 'ServerInfoState( serverVersion: $serverVersion, serverFeatures: $serverFeatures, serverConfig: $serverConfig, isVersionMismatch: $isVersionMismatch, versionMismatchErrorMessage: $versionMismatchErrorMessage)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -35,6 +43,8 @@ class ServerInfoState {
|
||||
|
||||
return other is ServerInfoState &&
|
||||
other.serverVersion == serverVersion &&
|
||||
other.serverFeatures == serverFeatures &&
|
||||
other.serverConfig == serverConfig &&
|
||||
other.isVersionMismatch == isVersionMismatch &&
|
||||
other.versionMismatchErrorMessage == versionMismatchErrorMessage;
|
||||
}
|
||||
@@ -42,6 +52,8 @@ class ServerInfoState {
|
||||
@override
|
||||
int get hashCode {
|
||||
return serverVersion.hashCode ^
|
||||
serverFeatures.hashCode ^
|
||||
serverConfig.hashCode ^
|
||||
isVersionMismatch.hashCode ^
|
||||
versionMismatchErrorMessage.hashCode;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user