Compare commits
47 commits
renovate/d
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
e1739ac4fc | ||
|
8736c77f7a | ||
|
338a028185 | ||
|
e2d0e944eb | ||
|
f53b70571b | ||
|
2814de4420 | ||
|
024fe1141b | ||
|
086a957a2b | ||
|
84c5b08c25 | ||
|
7e8488694d | ||
|
231b89c9c0 | ||
|
d5f6584e1d | ||
|
7702560b12 | ||
|
982183600d | ||
|
933c24ea6f | ||
|
05e9697dff | ||
|
259700c45f | ||
|
22d79850f6 | ||
|
56aed8246d | ||
|
ca1be71bca | ||
|
6111bf157e | ||
|
2195730fa6 | ||
|
1dc832d392 | ||
|
1a63d3837e | ||
|
bdbaa166d9 | ||
|
812e67d55d | ||
|
dfd6846deb | ||
|
6d3421a505 | ||
|
ede9de146a | ||
|
6959cf689b | ||
|
36ba48b8ae | ||
|
8a2b36ad55 | ||
|
6000c7f3bc | ||
|
5aa658de59 | ||
|
6673f1eb24 | ||
|
a02e91169d | ||
|
ec92608024 | ||
|
5a50d32748 | ||
|
cbdcbd3ab4 | ||
|
cb00d45e3d | ||
|
2b2b1bba63 | ||
|
031420bc78 | ||
|
387faa13d5 | ||
|
6979d43650 | ||
|
af8bb132d0 | ||
|
40964187bb | ||
|
fe3d951f26 |
168 changed files with 7108 additions and 1043 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
@ -17,3 +17,5 @@ web/src/api/open-api/**/*.md -diff -merge
|
|||
web/src/api/open-api/**/*.md linguist-generated=true
|
||||
web/src/api/open-api/**/*.ts -diff -merge
|
||||
web/src/api/open-api/**/*.ts linguist-generated=true
|
||||
|
||||
*.sh text eol=lf
|
||||
|
|
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
@ -213,7 +213,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres@sha256:71da05df8c4f1e1bac9b92ebfba2a0eeb183f6ac6a972fd5e55e8146e29efe9c
|
||||
image: postgres@sha256:6dfee32131933ab4ca25a00360c3f427fdc134de56f9a90c6c9a4956b48aea85
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
<p align="center">
|
||||
<img src="design/immich-logo.svg" width="150" title="Login mit eigener URL">
|
||||
</p>
|
||||
<h3 align="center">Immich - Hoch performante, selbst gehostete Backup Lösung für Fotos und Videos</h3>
|
||||
<h3 align="center">Immich - Hoch performante, selbst gehostete Backup-Lösung für Fotos und Videos</h3>
|
||||
<br/>
|
||||
<a href="https://immich.app">
|
||||
<img src="design/immich-screenshots.png" title="Haupt-Screenshot">
|
||||
|
@ -32,10 +32,10 @@
|
|||
|
||||
## Warnung
|
||||
|
||||
- ⚠️ Das Projekt befindet sich unter **sehr aktiver** Entwicklung.
|
||||
- ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung.
|
||||
- ⚠️ Erwarte Fehler und Änderungen mit Breaking-Changes.
|
||||
- ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.**
|
||||
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup Regel für deine wertvollen Fotos und Videos!
|
||||
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
|
||||
|
||||
## Inhalt
|
||||
|
||||
|
@ -54,9 +54,9 @@ Die Hauptdokumentation, inklusive Installationsanleitungen, ist unter https://im
|
|||
|
||||
## Demo
|
||||
|
||||
Die Web-Demo kannst du unter https://demo.immich.app finden.
|
||||
Die Web-Demo kannst Du unter https://demo.immich.app finden.
|
||||
|
||||
Für die Handy-App kannst du `https://demo.immich.app/api` als `Server Endpoint URL` angeben.
|
||||
Für die Handy-App kannst Du `https://demo.immich.app/api` als `Server Endpoint URL` angeben.
|
||||
|
||||
```bash title="Demo Credential"
|
||||
Die Anmeldedaten
|
||||
|
@ -73,8 +73,8 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
|||
| Funktionen | Mobil | Web |
|
||||
| ---------------------------------------------------- | ------ | ----- |
|
||||
| Fotos & Videos hochladen und ansehen | Ja | Ja |
|
||||
| Automatisches Backup wenn die App offen ist | Ja | n. a. |
|
||||
| Selektive Auswahl von Alben zum Backup | Ja | n. a. |
|
||||
| Automatisches Backup wenn die App geöffnet ist | Ja | n. a. |
|
||||
| Selektive Auswahl von Alben zum Sichern | Ja | n. a. |
|
||||
| Fotos und Videos auf das Gerät herunterladen | Ja | Ja |
|
||||
| Unterstützt mehrere Benutzer | Ja | Ja |
|
||||
| Album und geteilte Alben | Ja | Ja |
|
||||
|
@ -82,7 +82,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
|||
| Unterstützt RAW Formate | Ja | Ja |
|
||||
| Metadaten anzeigen (EXIF, Karte) | Ja | Ja |
|
||||
| Suchen nach Metadaten, Objekten, Gesichtern und CLIP | Ja | Ja |
|
||||
| Administrative Funktionen (Nutzerverwaltung) | Nein | Ja |
|
||||
| Administrative Funktionen (Benutzerverwaltung) | Nein | Ja |
|
||||
| Backup im Hintergrund | Ja | n. a. |
|
||||
| Virtuelles Scrollen | Ja | Ja |
|
||||
| OAuth Unterstützung | Ja | Ja |
|
||||
|
@ -101,22 +101,22 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
|||
|
||||
## Unterstütze das Projekt
|
||||
|
||||
Ich habe mich diesem Projekt verpflichtet und werde nicht aufgeben. Ich werde die Dokumentation weiter aktualisieren, neue Funktionen hinzufügen und Fehler beheben. Allerdings kann ich das nicht alleine schaffen. Daher brauche ich Eure Unterstützung mir zusätzliche Motivation zu geben um weiterzumachen.
|
||||
Ich habe mich diesem Projekt verpflichtet und werde nicht aufgeben. Ich werde die Dokumentation weiter aktualisieren, neue Funktionen hinzufügen und Fehler beheben. Allerdings kann ich das nicht alleine schaffen. Daher brauche ich Eure Unterstützung, um mir zusätzliche Motivation zu geben, weiterzumachen.
|
||||
|
||||
Wie unsere Gastgeber in der [selfhosted.show - In der Episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) gesagt haben, ist dies ein riesiges Unterfangen, welchem das Team und ich uns annehmen. In Zukunft würde ich liebend gerne Vollzeit an dem Projekt arbeiten und bitte daher um Eure Unterstützung.
|
||||
|
||||
Wenn Du denkst, dass dies die richtige Sache ist und dich selbst die App für eine längere Zeit nutzen siehst, dann denke bitte darüber nach das Projekt mit einer der unten aufgelisteten Optionen zu unterstützen.
|
||||
Wenn Du denkst, dass dies die richtige Sache ist und dich selbst die App für eine längere Zeit nutzen siehst, dann denke bitte darüber nach, das Projekt mit einer der unten aufgelisteten Optionen zu unterstützen.
|
||||
|
||||
### Spenden
|
||||
|
||||
- [Monatliche Spende](https://github.com/sponsors/alextran1502) via GitHub Sponsors
|
||||
- [Einmalige Spende](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
|
||||
- [Monatliche Spende](https://github.com/sponsors/immich-app) via GitHub Sponsors
|
||||
- [Einmalige Spende](https://github.com/sponsors/immich-app?frequency=one-time&sponsor=immich-app) via GitHub Sponsors
|
||||
- [Librepay](https://liberapay.com/alex.tran1502/)
|
||||
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
|
||||
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
|
||||
- ZCash: u1smm4wvqegcp46zss2jf5xptchgeczp4rx7a0wu3mermf2wxahm26yyz5w9mw3f2p4emwlljxjumg774kgs8rntt9yags0whnzane4n67z4c7gppq4yyvcj404ne3r769prwzd9j8ntvqp44fa6d67sf7rmcfjmds3gmeceff4u8e92rh38nd30cr96xw6vfhk6scu4ws90ldzupr3sz
|
||||
|
||||
## Unterstützer
|
||||
## Mitwirkende
|
||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||
</a>
|
||||
|
|
12
cli/package-lock.json
generated
12
cli/package-lock.json
generated
|
@ -1604,9 +1604,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
|
||||
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
|
||||
"version": "20.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz",
|
||||
"integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
|
@ -7990,9 +7990,9 @@
|
|||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "20.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
|
||||
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
|
||||
"version": "20.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz",
|
||||
"integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": "~5.26.4"
|
||||
|
|
591
cli/src/api/open-api/api.ts
generated
591
cli/src/api/open-api/api.ts
generated
|
@ -586,6 +586,142 @@ export const AssetBulkUploadCheckResultReasonEnum = {
|
|||
|
||||
export type AssetBulkUploadCheckResultReasonEnum = typeof AssetBulkUploadCheckResultReasonEnum[keyof typeof AssetBulkUploadCheckResultReasonEnum];
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AssetFaceResponseDto
|
||||
*/
|
||||
export interface AssetFaceResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceResponseDto
|
||||
*/
|
||||
'boundingBoxX1': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceResponseDto
|
||||
*/
|
||||
'boundingBoxX2': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceResponseDto
|
||||
*/
|
||||
'boundingBoxY1': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceResponseDto
|
||||
*/
|
||||
'boundingBoxY2': number;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetFaceResponseDto
|
||||
*/
|
||||
'id': string;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceResponseDto
|
||||
*/
|
||||
'imageHeight': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceResponseDto
|
||||
*/
|
||||
'imageWidth': number;
|
||||
/**
|
||||
*
|
||||
* @type {PersonResponseDto}
|
||||
* @memberof AssetFaceResponseDto
|
||||
*/
|
||||
'person': PersonResponseDto | null;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AssetFaceUpdateDto
|
||||
*/
|
||||
export interface AssetFaceUpdateDto {
|
||||
/**
|
||||
*
|
||||
* @type {Array<AssetFaceUpdateItem>}
|
||||
* @memberof AssetFaceUpdateDto
|
||||
*/
|
||||
'data': Array<AssetFaceUpdateItem>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AssetFaceUpdateItem
|
||||
*/
|
||||
export interface AssetFaceUpdateItem {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetFaceUpdateItem
|
||||
*/
|
||||
'assetId': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetFaceUpdateItem
|
||||
*/
|
||||
'personId': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface AssetFaceWithoutPersonResponseDto
|
||||
*/
|
||||
export interface AssetFaceWithoutPersonResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceWithoutPersonResponseDto
|
||||
*/
|
||||
'boundingBoxX1': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceWithoutPersonResponseDto
|
||||
*/
|
||||
'boundingBoxX2': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceWithoutPersonResponseDto
|
||||
*/
|
||||
'boundingBoxY1': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceWithoutPersonResponseDto
|
||||
*/
|
||||
'boundingBoxY2': number;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetFaceWithoutPersonResponseDto
|
||||
*/
|
||||
'id': string;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceWithoutPersonResponseDto
|
||||
*/
|
||||
'imageHeight': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof AssetFaceWithoutPersonResponseDto
|
||||
*/
|
||||
'imageWidth': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
|
@ -842,10 +978,10 @@ export interface AssetResponseDto {
|
|||
'ownerId': string;
|
||||
/**
|
||||
*
|
||||
* @type {Array<PersonResponseDto>}
|
||||
* @type {Array<PersonWithFacesResponseDto>}
|
||||
* @memberof AssetResponseDto
|
||||
*/
|
||||
'people'?: Array<PersonResponseDto>;
|
||||
'people'?: Array<PersonWithFacesResponseDto>;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
|
@ -1672,6 +1808,19 @@ export interface ExifResponseDto {
|
|||
*/
|
||||
'timeZone'?: string | null;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface FaceDto
|
||||
*/
|
||||
export interface FaceDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof FaceDto
|
||||
*/
|
||||
'id': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
|
@ -1785,7 +1934,8 @@ export const JobCommand = {
|
|||
Start: 'start',
|
||||
Pause: 'pause',
|
||||
Resume: 'resume',
|
||||
Empty: 'empty'
|
||||
Empty: 'empty',
|
||||
ClearFailed: 'clear-failed'
|
||||
} as const;
|
||||
|
||||
export type JobCommand = typeof JobCommand[keyof typeof JobCommand];
|
||||
|
@ -2563,6 +2713,49 @@ export interface PersonUpdateDto {
|
|||
*/
|
||||
'name'?: string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface PersonWithFacesResponseDto
|
||||
*/
|
||||
export interface PersonWithFacesResponseDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof PersonWithFacesResponseDto
|
||||
*/
|
||||
'birthDate': string | null;
|
||||
/**
|
||||
*
|
||||
* @type {Array<AssetFaceWithoutPersonResponseDto>}
|
||||
* @memberof PersonWithFacesResponseDto
|
||||
*/
|
||||
'faces': Array<AssetFaceWithoutPersonResponseDto>;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof PersonWithFacesResponseDto
|
||||
*/
|
||||
'id': string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof PersonWithFacesResponseDto
|
||||
*/
|
||||
'isHidden': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof PersonWithFacesResponseDto
|
||||
*/
|
||||
'name': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof PersonWithFacesResponseDto
|
||||
*/
|
||||
'thumbnailPath': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
|
@ -11348,6 +11541,233 @@ export class AuthenticationApi extends BaseAPI {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* FaceApi - axios parameter creator
|
||||
* @export
|
||||
*/
|
||||
export const FaceApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getFaces: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('getFaces', 'id', id)
|
||||
const localVarPath = `/face`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (id !== undefined) {
|
||||
localVarQueryParameter['id'] = id;
|
||||
}
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {FaceDto} faceDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
reassignFacesById: async (id: string, faceDto: FaceDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('reassignFacesById', 'id', id)
|
||||
// verify required parameter 'faceDto' is not null or undefined
|
||||
assertParamExists('reassignFacesById', 'faceDto', faceDto)
|
||||
const localVarPath = `/face/{id}`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* FaceApi - functional programming interface
|
||||
* @export
|
||||
*/
|
||||
export const FaceApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosParamCreator = FaceApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getFaces(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetFaceResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getFaces(id, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {FaceDto} faceDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async reassignFacesById(id: string, faceDto: FaceDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* FaceApi - factory interface
|
||||
* @export
|
||||
*/
|
||||
export const FaceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||
const localVarFp = FaceApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {FaceApiGetFacesRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetFaceResponseDto>> {
|
||||
return localVarFp.getFaces(requestParameters.id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
|
||||
return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Request parameters for getFaces operation in FaceApi.
|
||||
* @export
|
||||
* @interface FaceApiGetFacesRequest
|
||||
*/
|
||||
export interface FaceApiGetFacesRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof FaceApiGetFaces
|
||||
*/
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for reassignFacesById operation in FaceApi.
|
||||
* @export
|
||||
* @interface FaceApiReassignFacesByIdRequest
|
||||
*/
|
||||
export interface FaceApiReassignFacesByIdRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof FaceApiReassignFacesById
|
||||
*/
|
||||
readonly id: string
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {FaceDto}
|
||||
* @memberof FaceApiReassignFacesById
|
||||
*/
|
||||
readonly faceDto: FaceDto
|
||||
}
|
||||
|
||||
/**
|
||||
* FaceApi - object-oriented interface
|
||||
* @export
|
||||
* @class FaceApi
|
||||
* @extends {BaseAPI}
|
||||
*/
|
||||
export class FaceApi extends BaseAPI {
|
||||
/**
|
||||
*
|
||||
* @param {FaceApiGetFacesRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof FaceApi
|
||||
*/
|
||||
public getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig) {
|
||||
return FaceApiFp(this.configuration).getFaces(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof FaceApi
|
||||
*/
|
||||
public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) {
|
||||
return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* JobApi - axios parameter creator
|
||||
* @export
|
||||
|
@ -13179,6 +13599,44 @@ export class PartnerApi extends BaseAPI {
|
|||
*/
|
||||
export const PersonApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
createPerson: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/person`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {boolean} [withHidden]
|
||||
|
@ -13438,6 +13896,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
|
|||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {AssetFaceUpdateDto} assetFaceUpdateDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
reassignFaces: async (id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('reassignFaces', 'id', id)
|
||||
// verify required parameter 'assetFaceUpdateDto' is not null or undefined
|
||||
assertParamExists('reassignFaces', 'assetFaceUpdateDto', assetFaceUpdateDto)
|
||||
const localVarPath = `/person/{id}/reassign`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(assetFaceUpdateDto, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {PeopleUpdateDto} peopleUpdateDto
|
||||
|
@ -13540,6 +14046,15 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
|
|||
export const PersonApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async createPerson(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.createPerson(options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {boolean} [withHidden]
|
||||
|
@ -13601,6 +14116,17 @@ export const PersonApiFp = function(configuration?: Configuration) {
|
|||
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {AssetFaceUpdateDto} assetFaceUpdateDto
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async reassignFaces(id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFaces(id, assetFaceUpdateDto, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {PeopleUpdateDto} peopleUpdateDto
|
||||
|
@ -13632,6 +14158,14 @@ export const PersonApiFp = function(configuration?: Configuration) {
|
|||
export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||
const localVarFp = PersonApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
createPerson(options?: AxiosRequestConfig): AxiosPromise<PersonResponseDto> {
|
||||
return localVarFp.createPerson(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
|
||||
|
@ -13686,6 +14220,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
|
|||
mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
|
||||
return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {PersonApiReassignFacesRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
|
||||
return localVarFp.reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
|
||||
|
@ -13798,6 +14341,27 @@ export interface PersonApiMergePersonRequest {
|
|||
readonly mergePersonDto: MergePersonDto
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for reassignFaces operation in PersonApi.
|
||||
* @export
|
||||
* @interface PersonApiReassignFacesRequest
|
||||
*/
|
||||
export interface PersonApiReassignFacesRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof PersonApiReassignFaces
|
||||
*/
|
||||
readonly id: string
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {AssetFaceUpdateDto}
|
||||
* @memberof PersonApiReassignFaces
|
||||
*/
|
||||
readonly assetFaceUpdateDto: AssetFaceUpdateDto
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for updatePeople operation in PersonApi.
|
||||
* @export
|
||||
|
@ -13840,6 +14404,16 @@ export interface PersonApiUpdatePersonRequest {
|
|||
* @extends {BaseAPI}
|
||||
*/
|
||||
export class PersonApi extends BaseAPI {
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof PersonApi
|
||||
*/
|
||||
public createPerson(options?: AxiosRequestConfig) {
|
||||
return PersonApiFp(this.configuration).createPerson(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
|
||||
|
@ -13906,6 +14480,17 @@ export class PersonApi extends BaseAPI {
|
|||
return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PersonApiReassignFacesRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof PersonApi
|
||||
*/
|
||||
public reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig) {
|
||||
return PersonApiFp(this.configuration).reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
|
||||
|
|
|
@ -108,11 +108,11 @@ services:
|
|||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5
|
||||
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
|
||||
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
|
|
@ -65,12 +65,12 @@ services:
|
|||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5
|
||||
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
|
||||
restart: always
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
|
||||
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
|
|
@ -23,7 +23,7 @@ services:
|
|||
- database
|
||||
|
||||
database:
|
||||
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
|
||||
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
|
||||
command: -c fsync=off
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
|
@ -69,12 +69,12 @@ services:
|
|||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: redis:6.2-alpine@sha256:80cc8518800438c684a53ed829c621c94afd1087aaeb59b0d4343ed3e7bcf6c5
|
||||
image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46
|
||||
restart: always
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: postgres:14-alpine@sha256:50d9be76e9a90da4c781554955e0ffc79d9d5c4226838e64b36aacc97cbc35ad
|
||||
image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
|
|
@ -144,7 +144,7 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server"
|
|||
From the directory you created in Step 1, (which should now contain your customized `docker-compose.yml` and `.env` files) run `docker-compose up -d`.
|
||||
|
||||
```bash title="Start the containers using docker compose command"
|
||||
docker-compose up -d # or `docker compose up -d` based on your docker-compose version
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
:::tip
|
||||
|
@ -162,7 +162,7 @@ If `IMMICH_VERSION` is set, it will need to be updated to the latest or desired
|
|||
When a new version of Immich is [released](https://github.com/immich-app/immich/releases), the application can be upgraded with the following commands, run in the directory with the `docker-compose.yml` file:
|
||||
|
||||
```bash title="Upgrade Immich"
|
||||
docker-compose pull && docker-compose up -d # Or `docker compose up -d`
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
:::caution Automatic Updates
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.11-bookworm@sha256:e5a1b0a194a5fbf94f6e350b31c9a508723f9eeb2f9e9e32c3b65df8520a40cc as builder
|
||||
FROM python:3.11-bookworm@sha256:ba7a7ac30c38e119c4304f98ef0e188f90f4f67a958bb6899da9defb99bfb471 as builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
@ -13,7 +13,7 @@ ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
|
|||
COPY poetry.lock pyproject.toml ./
|
||||
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:1bc6a3e9356d64ea632791653bc71a56340e8741dab66434ab2739ebf6aed29d
|
||||
FROM python:3.11-slim-bookworm@sha256:cc758519481092eb5a4a5ab0c1b303e288880d59afc601958d19e95b300bc86b
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tini libmimalloc2.0 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM mambaorg/micromamba:bookworm-slim@sha256:d20c621f3ae42f50f380166b15b6c88b14fa62ab6ea188f2cef33451d64057c7 as builder
|
||||
FROM mambaorg/micromamba:bookworm-slim@sha256:e296d47be09fc5d260eba9b191f60496f028a4f3ec41e8a14d48c0bae2c60244 as builder
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
TRANSFORMERS_CACHE=/cache \
|
||||
|
|
|
@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip
|
||||
distributionSha256Sum=518a863631feb7452b8f1b3dc2aaee5f388355cc3421bbd0275fbeadd77e84b2
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip
|
||||
distributionSha256Sum=6001aba9b2204d26fa25a5800bb9382cf3ee01ccb78fe77317b2872336eb2f80
|
|
@ -144,6 +144,8 @@
|
|||
"control_bottom_app_bar_stack": "Stack",
|
||||
"control_bottom_app_bar_unarchive": "Unarchive",
|
||||
"control_bottom_app_bar_upload": "Upload",
|
||||
"control_bottom_app_bar_edit_time": "Edit Date & Time",
|
||||
"control_bottom_app_bar_edit_location": "Edit Location",
|
||||
"create_album_page_untitled": "Untitled",
|
||||
"create_shared_album_page_create": "Create",
|
||||
"create_shared_album_page_share": "Share",
|
||||
|
@ -165,6 +167,7 @@
|
|||
"exif_bottom_sheet_description": "Add Description...",
|
||||
"exif_bottom_sheet_details": "DETAILS",
|
||||
"exif_bottom_sheet_location": "LOCATION",
|
||||
"exif_bottom_sheet_location_add": "Add a location",
|
||||
"experimental_settings_new_asset_list_subtitle": "Work in progress",
|
||||
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
|
||||
"experimental_settings_subtitle": "Use at your own risk!",
|
||||
|
@ -461,5 +464,18 @@
|
|||
"viewer_remove_from_stack": "Remove from Stack",
|
||||
"viewer_stack_use_as_main_asset": "Use as Main Asset",
|
||||
"viewer_unstack": "Un-Stack",
|
||||
"scaffold_body_error_occured": "Error occured"
|
||||
"scaffold_body_error_occurred": "Error occurred",
|
||||
"edit_date_time_dialog_date_time": "Date and Time",
|
||||
"edit_date_time_dialog_timezone": "Timezone",
|
||||
"action_common_cancel": "Cancel",
|
||||
"action_common_update": "Update",
|
||||
"edit_location_dialog_title": "Location",
|
||||
"map_location_picker_page_use_location": "Use this location",
|
||||
"location_picker_choose_on_map": "Choose on map",
|
||||
"location_picker_latitude": "Latitude",
|
||||
"location_picker_latitude_hint": "Enter your latitude here",
|
||||
"location_picker_latitude_error": "Enter a valid latitude",
|
||||
"location_picker_longitude": "Longitude",
|
||||
"location_picker_longitude_hint": "Enter your longitude here",
|
||||
"location_picker_longitude_error": "Enter a valid longitude"
|
||||
}
|
||||
|
|
36
mobile/lib/extensions/asset_extensions.dart
Normal file
36
mobile/lib/extensions/asset_extensions.dart
Normal file
|
@ -0,0 +1,36 @@
|
|||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:timezone/timezone.dart';
|
||||
|
||||
extension TZExtension on Asset {
|
||||
/// Returns the created time of the asset from the exif info (if available) or from
|
||||
/// the fileCreatedAt field, adjusted to the timezone value from the exif info along with
|
||||
/// the timezone offset in [Duration]
|
||||
(DateTime, Duration) getTZAdjustedTimeAndOffset() {
|
||||
DateTime dt = fileCreatedAt.toLocal();
|
||||
if (exifInfo?.dateTimeOriginal != null) {
|
||||
dt = exifInfo!.dateTimeOriginal!;
|
||||
if (exifInfo?.timeZone != null) {
|
||||
dt = dt.toUtc();
|
||||
try {
|
||||
final location = getLocation(exifInfo!.timeZone!);
|
||||
dt = TZDateTime.from(dt, location);
|
||||
} on LocationNotFoundException {
|
||||
RegExp re = RegExp(
|
||||
r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final m = re.firstMatch(exifInfo!.timeZone!);
|
||||
if (m != null) {
|
||||
final duration = Duration(
|
||||
hours: int.parse(m.group(1) ?? '0'),
|
||||
minutes: int.parse(m.group(2) ?? '0'),
|
||||
);
|
||||
dt = dt.add(duration);
|
||||
return (dt, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (dt, dt.timeZoneOffset);
|
||||
}
|
||||
}
|
4
mobile/lib/extensions/duration_extensions.dart
Normal file
4
mobile/lib/extensions/duration_extensions.dart
Normal file
|
@ -0,0 +1,4 @@
|
|||
extension TZOffsetExtension on Duration {
|
||||
String formatAsOffset() =>
|
||||
"${isNegative ? '-' : '+'}${inHours.abs().toString().padLeft(2, '0')}:${inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
|
||||
}
|
|
@ -95,7 +95,11 @@ class ActivityStatisticsNotifier extends StateNotifier<int> {
|
|||
}
|
||||
|
||||
Future<void> fetchStatistics() async {
|
||||
state = await _activityService.getStatistics(albumId, assetId: assetId);
|
||||
final count =
|
||||
await _activityService.getStatistics(albumId, assetId: assetId);
|
||||
if (mounted) {
|
||||
state = count;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addActivity() async {
|
||||
|
|
|
@ -68,46 +68,46 @@ class AlbumThumbnailListTile extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: album.thumbnail.value == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0,
|
||||
right: 8.0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
album.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
: 'album_thumbnail_card_items',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(args: ['${album.assetCount}']),
|
||||
if (album.shared)
|
||||
const Text(
|
||||
'album_thumbnail_card_shared',
|
||||
style: TextStyle(
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
: 'album_thumbnail_card_items',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
],
|
||||
).tr(args: ['${album.assetCount}']),
|
||||
if (album.shared)
|
||||
const Text(
|
||||
'album_thumbnail_card_shared',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -135,49 +135,56 @@ class LibraryPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
Widget buildCreateAlbumButton() {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.autoPush(CreateAlbumRoute(isSharedAlbum: false));
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
var cardSize = constraints.maxWidth;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.autoPush(CreateAlbumRoute(isSharedAlbum: false));
|
||||
},
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: 32), // Adjust padding to suit
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 53, 53, 53)
|
||||
: const Color.fromARGB(255, 203, 203, 203),
|
||||
),
|
||||
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.add_rounded,
|
||||
size: 28,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8.0,
|
||||
bottom: 16,
|
||||
),
|
||||
child: Text(
|
||||
'library_page_new_album',
|
||||
style: context.textTheme.labelLarge,
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDarkTheme
|
||||
? const Color.fromARGB(255, 53, 53, 53)
|
||||
: const Color.fromARGB(255, 203, 203, 203),
|
||||
),
|
||||
color: isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.add_rounded,
|
||||
size: 28,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8.0,
|
||||
bottom: 16,
|
||||
),
|
||||
child: Text(
|
||||
'library_page_new_album',
|
||||
style: context.textTheme.labelLarge,
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,14 +4,15 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:timezone/timezone.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
@ -21,111 +22,84 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
|
||||
const ExifBottomSheet({Key? key, required this.asset}) : super(key: key);
|
||||
|
||||
bool hasCoordinates(ExifInfo? exifInfo) =>
|
||||
exifInfo != null &&
|
||||
exifInfo.latitude != null &&
|
||||
exifInfo.longitude != null &&
|
||||
exifInfo.latitude != 0 &&
|
||||
exifInfo.longitude != 0;
|
||||
|
||||
String formatTimeZone(Duration d) =>
|
||||
"GMT${d.isNegative ? '-' : '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}";
|
||||
|
||||
String get formattedDateTime {
|
||||
DateTime dt = asset.fileCreatedAt.toLocal();
|
||||
String? timeZone;
|
||||
if (asset.exifInfo?.dateTimeOriginal != null) {
|
||||
dt = asset.exifInfo!.dateTimeOriginal!;
|
||||
if (asset.exifInfo?.timeZone != null) {
|
||||
dt = dt.toUtc();
|
||||
try {
|
||||
final location = getLocation(asset.exifInfo!.timeZone!);
|
||||
dt = TZDateTime.from(dt, location);
|
||||
} on LocationNotFoundException {
|
||||
RegExp re = RegExp(
|
||||
r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final m = re.firstMatch(asset.exifInfo!.timeZone!);
|
||||
if (m != null) {
|
||||
final duration = Duration(
|
||||
hours: int.parse(m.group(1) ?? '0'),
|
||||
minutes: int.parse(m.group(2) ?? '0'),
|
||||
);
|
||||
dt = dt.add(duration);
|
||||
timeZone = formatTimeZone(duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final date = DateFormat.yMMMEd().format(dt);
|
||||
final time = DateFormat.jm().format(dt);
|
||||
timeZone ??= formatTimeZone(dt.timeZoneOffset);
|
||||
|
||||
return '$date • $time $timeZone';
|
||||
}
|
||||
|
||||
Future<Uri?> _createCoordinatesUri(ExifInfo? exifInfo) async {
|
||||
if (!hasCoordinates(exifInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final double latitude = exifInfo!.latitude!;
|
||||
final double longitude = exifInfo.longitude!;
|
||||
|
||||
const zoomLevel = 16;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
Uri uri = Uri(
|
||||
scheme: 'geo',
|
||||
host: '$latitude,$longitude',
|
||||
queryParameters: {
|
||||
'z': '$zoomLevel',
|
||||
'q': '$latitude,$longitude($formattedDateTime)',
|
||||
},
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
var params = {
|
||||
'll': '$latitude,$longitude',
|
||||
'q': formattedDateTime,
|
||||
'z': '$zoomLevel',
|
||||
};
|
||||
Uri uri = Uri.https('maps.apple.com', '/', params);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
return Uri(
|
||||
scheme: 'https',
|
||||
host: 'openstreetmap.org',
|
||||
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
|
||||
fragment: 'map=$zoomLevel/$latitude/$longitude',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetWithExif = ref.watch(assetDetailProvider(asset));
|
||||
final exifInfo = (assetWithExif.value ?? asset).exifInfo;
|
||||
var textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
|
||||
bool hasCoordinates() =>
|
||||
exifInfo != null &&
|
||||
exifInfo.latitude != null &&
|
||||
exifInfo.longitude != null &&
|
||||
exifInfo.latitude != 0 &&
|
||||
exifInfo.longitude != 0;
|
||||
|
||||
String formattedDateTime() {
|
||||
final (dt, timeZone) =
|
||||
(assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset();
|
||||
final date = DateFormat.yMMMEd().format(dt);
|
||||
final time = DateFormat.jm().format(dt);
|
||||
|
||||
return '$date • $time GMT${timeZone.formatAsOffset()}';
|
||||
}
|
||||
|
||||
Future<Uri?> createCoordinatesUri() async {
|
||||
if (!hasCoordinates()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final double latitude = exifInfo!.latitude!;
|
||||
final double longitude = exifInfo.longitude!;
|
||||
|
||||
const zoomLevel = 16;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
Uri uri = Uri(
|
||||
scheme: 'geo',
|
||||
host: '$latitude,$longitude',
|
||||
queryParameters: {
|
||||
'z': '$zoomLevel',
|
||||
'q': '$latitude,$longitude($formattedDateTime)',
|
||||
},
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
var params = {
|
||||
'll': '$latitude,$longitude',
|
||||
'q': formattedDateTime,
|
||||
'z': '$zoomLevel',
|
||||
};
|
||||
Uri uri = Uri.https('maps.apple.com', '/', params);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
return Uri(
|
||||
scheme: 'https',
|
||||
host: 'openstreetmap.org',
|
||||
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
|
||||
fragment: 'map=$zoomLevel/$latitude/$longitude',
|
||||
);
|
||||
}
|
||||
|
||||
buildMap() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return MapThumbnail(
|
||||
showAttribution: false,
|
||||
coords: LatLng(
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
height: 150,
|
||||
zoom: 16.0,
|
||||
width: constraints.maxWidth,
|
||||
zoom: 12.0,
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
|
@ -139,7 +113,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
),
|
||||
],
|
||||
onTap: (tapPosition, latLong) async {
|
||||
Uri? uri = await _createCoordinatesUri(exifInfo);
|
||||
Uri? uri = await createCoordinatesUri();
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
|
@ -181,8 +155,26 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
|
||||
buildLocation() {
|
||||
// Guard no lat/lng
|
||||
if (!hasCoordinates(exifInfo)) {
|
||||
return Container();
|
||||
if (!hasCoordinates()) {
|
||||
return asset.isRemote
|
||||
? ListTile(
|
||||
minLeadingWidth: 0,
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
leading: const Icon(Icons.location_on),
|
||||
title: Text(
|
||||
"exif_bottom_sheet_location_add",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
onTap: () => handleEditLocation(
|
||||
ref,
|
||||
context,
|
||||
[assetWithExif.value ?? asset],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
|
@ -191,13 +183,29 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"exif_bottom_sheet_location",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).tr(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"exif_bottom_sheet_location",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color:
|
||||
context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).tr(),
|
||||
if (asset.isRemote)
|
||||
IconButton(
|
||||
onPressed: () => handleEditLocation(
|
||||
ref,
|
||||
context,
|
||||
[assetWithExif.value ?? asset],
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
buildMap(),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
|
@ -233,12 +241,27 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
buildDate() {
|
||||
return Text(
|
||||
formattedDateTime,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
formattedDateTime(),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (asset.isRemote)
|
||||
IconButton(
|
||||
onPressed: () => handleEditDateTime(
|
||||
ref,
|
||||
context,
|
||||
[assetWithExif.value ?? asset],
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -363,7 +386,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: hasCoordinates(exifInfo) ? 5 : 0,
|
||||
flex: hasCoordinates() ? 5 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: buildLocation(),
|
||||
|
@ -402,9 +425,8 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
buildLocation(),
|
||||
SizedBox(height: hasCoordinates(exifInfo) ? 16.0 : 0.0),
|
||||
SizedBox(height: hasCoordinates() ? 16.0 : 6.0),
|
||||
buildDetail(),
|
||||
const SizedBox(height: 50),
|
||||
],
|
||||
|
|
|
@ -795,6 +795,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
tag: isFromDto
|
||||
? '${a.remoteId}-$heroOffset'
|
||||
: a.id + heroOffset,
|
||||
transitionOnUserGestures: true,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
|
|
|
@ -375,6 +375,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
await _getBackupAlbumsInfo();
|
||||
await updateServerInfo();
|
||||
await _updateBackupAssetCount();
|
||||
} else {
|
||||
log.warning("cannot get backup info - background backup is in progress!");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,8 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
final void Function() onCreateNewAlbum;
|
||||
final void Function() onUpload;
|
||||
final void Function() onStack;
|
||||
final void Function() onEditTime;
|
||||
final void Function() onEditLocation;
|
||||
|
||||
final List<Album> albums;
|
||||
final List<Album> sharedAlbums;
|
||||
|
@ -37,6 +39,8 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
required this.onCreateNewAlbum,
|
||||
required this.onUpload,
|
||||
required this.onStack,
|
||||
required this.onEditTime,
|
||||
required this.onEditLocation,
|
||||
this.selectionAssetState = const SelectionAssetState(),
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
|
@ -74,6 +78,18 @@ class ControlBottomAppBar extends ConsumerWidget {
|
|||
label: "control_bottom_app_bar_favorite".tr(),
|
||||
onPressed: enabled ? onFavorite : null,
|
||||
),
|
||||
if (hasRemote)
|
||||
ControlBoxButton(
|
||||
iconData: Icons.edit_calendar_outlined,
|
||||
label: "control_bottom_app_bar_edit_time".tr(),
|
||||
onPressed: enabled ? onEditTime : null,
|
||||
),
|
||||
if (hasRemote)
|
||||
ControlBoxButton(
|
||||
iconData: Icons.edit_location_alt_outlined,
|
||||
label: "control_bottom_app_bar_edit_location".tr(),
|
||||
onPressed: enabled ? onEditLocation : null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.delete_outline_rounded,
|
||||
label: "control_bottom_app_bar_delete".tr(),
|
||||
|
|
|
@ -213,10 +213,10 @@ class HomePage extends HookConsumerWidget {
|
|||
processing.value = true;
|
||||
selectionEnabledHook.value = false;
|
||||
try {
|
||||
ref.read(manualUploadProvider.notifier).uploadAssets(
|
||||
context,
|
||||
selection.value.where((a) => a.storage == AssetState.local),
|
||||
);
|
||||
ref.read(manualUploadProvider.notifier).uploadAssets(
|
||||
context,
|
||||
selection.value.where((a) => a.storage == AssetState.local),
|
||||
);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
|
@ -312,6 +312,34 @@ class HomePage extends HookConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
void onEditTime() async {
|
||||
try {
|
||||
final remoteAssets = ownedRemoteSelection(
|
||||
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
handleEditDateTime(ref, context, remoteAssets.toList());
|
||||
}
|
||||
} finally {
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onEditLocation() async {
|
||||
try {
|
||||
final remoteAssets = ownedRemoteSelection(
|
||||
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
handleEditLocation(ref, context, remoteAssets.toList());
|
||||
}
|
||||
} finally {
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshAssets() async {
|
||||
final fullRefresh = refreshCount.value > 0;
|
||||
await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh);
|
||||
|
@ -411,6 +439,8 @@ class HomePage extends HookConsumerWidget {
|
|||
enabled: !processing.value,
|
||||
selectionAssetState: selectionAssetState.value,
|
||||
onStack: onStack,
|
||||
onEditTime: onEditTime,
|
||||
onEditLocation: onEditLocation,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -5,6 +5,8 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
|
@ -21,6 +23,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||
AuthenticationNotifier(
|
||||
this._apiService,
|
||||
this._db,
|
||||
this._ref,
|
||||
) : super(
|
||||
AuthenticationState(
|
||||
deviceId: "",
|
||||
|
@ -36,6 +39,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final StateNotifierProviderRef<AuthenticationNotifier, AuthenticationState>
|
||||
_ref;
|
||||
final _log = Logger("AuthenticationNotifier");
|
||||
|
||||
Future<bool> login(
|
||||
|
@ -111,6 +116,8 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
|||
Store.delete(StoreKey.currentUser),
|
||||
Store.delete(StoreKey.accessToken),
|
||||
]);
|
||||
_ref.invalidate(albumProvider);
|
||||
_ref.invalidate(sharedAlbumProvider);
|
||||
|
||||
state = state.copyWith(
|
||||
deviceId: "",
|
||||
|
@ -222,5 +229,6 @@ final authenticationProvider =
|
|||
return AuthenticationNotifier(
|
||||
ref.watch(apiServiceProvider),
|
||||
ref.watch(dbProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
|
113
mobile/lib/modules/map/ui/map_location_picker.dart
Normal file
113
mobile/lib/modules/map/ui/map_location_picker.dart
Normal file
|
@ -0,0 +1,113 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class MapLocationPickerPage extends HookConsumerWidget {
|
||||
final LatLng? initialLatLng;
|
||||
|
||||
const MapLocationPickerPage({super.key, this.initialLatLng});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedLatLng = useState<LatLng>(initialLatLng ?? LatLng(0, 0));
|
||||
final isDarkTheme =
|
||||
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
|
||||
final isLoading =
|
||||
ref.watch(mapStateNotifier.select((state) => state.isLoading));
|
||||
final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
|
||||
|
||||
return Theme(
|
||||
// Override app theme based on map theme
|
||||
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (!isLoading)
|
||||
FlutterMap(
|
||||
options: MapOptions(
|
||||
maxBounds:
|
||||
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
|
||||
interactiveFlags: InteractiveFlag.doubleTapZoom |
|
||||
InteractiveFlag.drag |
|
||||
InteractiveFlag.flingAnimation |
|
||||
InteractiveFlag.pinchMove |
|
||||
InteractiveFlag.pinchZoom,
|
||||
center: LatLng(20, 20),
|
||||
zoom: 2,
|
||||
minZoom: 1,
|
||||
maxZoom: maxZoom,
|
||||
onTap: (tapPosition, point) => selectedLatLng.value = point,
|
||||
),
|
||||
children: [
|
||||
ref.read(mapStateNotifier.notifier).getTileLayer(),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: selectedLatLng.value,
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png'),
|
||||
),
|
||||
height: 40,
|
||||
width: 40,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isLoading)
|
||||
Positioned(
|
||||
top: context.height * 0.35,
|
||||
left: context.width * 0.425,
|
||||
child: const ImmichLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomSheet: BottomSheet(
|
||||
onClosing: () {},
|
||||
builder: (context) => SizedBox(
|
||||
height: 150,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"${selectedLatLng.value.latitude.toStringAsFixed(4)}, ${selectedLatLng.value.longitude.toStringAsFixed(4)}",
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => context.autoPop(selectedLatLng.value),
|
||||
child: const Text("map_location_picker_page_use_location")
|
||||
.tr(),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.autoPop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: context.colorScheme.error,
|
||||
),
|
||||
child: const Text("action_common_cancel").tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/utils/map_controller_hook.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
|
@ -12,13 +14,15 @@ class MapThumbnail extends HookConsumerWidget {
|
|||
final double zoom;
|
||||
final List<Marker> markers;
|
||||
final double height;
|
||||
final double width;
|
||||
final bool showAttribution;
|
||||
final bool isDarkTheme;
|
||||
|
||||
const MapThumbnail({
|
||||
super.key,
|
||||
required this.coords,
|
||||
required this.height,
|
||||
this.height = 100,
|
||||
this.width = 100,
|
||||
this.onTap,
|
||||
this.zoom = 1,
|
||||
this.showAttribution = true,
|
||||
|
@ -28,18 +32,33 @@ class MapThumbnail extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapController = useMapController();
|
||||
final isMapReady = useRef(false);
|
||||
ref.watch(mapStateNotifier.select((s) => s.mapStyle));
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (isMapReady.value && mapController.center != coords) {
|
||||
mapController.move(coords, zoom);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[coords],
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
width: width,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: FlutterMap(
|
||||
mapController: mapController,
|
||||
options: MapOptions(
|
||||
interactiveFlags: InteractiveFlag.none,
|
||||
center: coords,
|
||||
zoom: zoom,
|
||||
onTap: onTap,
|
||||
onMapReady: () => isMapReady.value = true,
|
||||
),
|
||||
nonRotatedChildren: [
|
||||
if (showAttribution)
|
||||
|
|
32
mobile/lib/modules/map/utils/map_controller_hook.dart
Normal file
32
mobile/lib/modules/map/utils/map_controller_hook.dart
Normal file
|
@ -0,0 +1,32 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
MapController useMapController({
|
||||
String? debugLabel,
|
||||
List<Object?>? keys,
|
||||
}) {
|
||||
return use(_MapControllerHook(keys: keys));
|
||||
}
|
||||
|
||||
class _MapControllerHook extends Hook<MapController> {
|
||||
const _MapControllerHook({List<Object?>? keys}) : super(keys: keys);
|
||||
|
||||
@override
|
||||
HookState<MapController, Hook<MapController>> createState() =>
|
||||
_MapControllerHookState();
|
||||
}
|
||||
|
||||
class _MapControllerHookState
|
||||
extends HookState<MapController, _MapControllerHook> {
|
||||
late final controller = MapController();
|
||||
|
||||
@override
|
||||
MapController build(BuildContext context) => controller;
|
||||
|
||||
@override
|
||||
void dispose() => controller.dispose();
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useMapController';
|
||||
}
|
|
@ -55,6 +55,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
|||
// in onMapEvent() since MapEventMove#id is not populated properly in the
|
||||
// current version of flutter_map(4.0.0) used
|
||||
bool forceAssetUpdate = false;
|
||||
bool isMapReady = false;
|
||||
late final Debounce debounce;
|
||||
|
||||
@override
|
||||
|
@ -79,7 +80,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
|||
bool forceReload = false,
|
||||
}) {
|
||||
try {
|
||||
final bounds = mapController.bounds;
|
||||
final bounds = isMapReady ? mapController.bounds : null;
|
||||
if (bounds != null) {
|
||||
final oldAssetsInBounds = assetsInBounds.toSet();
|
||||
assetsInBounds =
|
||||
|
@ -455,6 +456,7 @@ class MapPageState extends ConsumerState<MapPage> {
|
|||
minZoom: 1,
|
||||
maxZoom: maxZoom,
|
||||
onMapReady: () {
|
||||
isMapReady = true;
|
||||
mapController.mapEventStream.listen(onMapEvent);
|
||||
},
|
||||
),
|
||||
|
|
|
@ -6,7 +6,7 @@ part of 'person.service.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$personServiceHash() => r'3fc3dcf4603c7b55c0deae65f39f6c212eea492b';
|
||||
String _$personServiceHash() => r'cde0a9c029d16ddde2adcd58ae8c863bf8cc1fed';
|
||||
|
||||
/// See also [personService].
|
||||
@ProviderFor(personService)
|
||||
|
|
|
@ -29,9 +29,8 @@ class CuratedPlacesRow extends CuratedRow {
|
|||
onTap: () => context.autoPush(
|
||||
const MapRoute(),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: imageSize,
|
||||
width: imageSize,
|
||||
child: SizedBox.square(
|
||||
dimension: imageSize,
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
|
@ -43,6 +42,7 @@ class CuratedPlacesRow extends CuratedRow {
|
|||
5,
|
||||
),
|
||||
height: imageSize,
|
||||
width: imageSize,
|
||||
showAttribution: false,
|
||||
isDarkTheme: context.isDarkTheme,
|
||||
),
|
||||
|
|
|
@ -7,7 +7,7 @@ part of 'app_settings.provider.dart';
|
|||
// **************************************************************************
|
||||
|
||||
String _$appSettingsServiceHash() =>
|
||||
r'957a65af6967701112f3076b507f9738fec4b7be';
|
||||
r'45ea609a91d250290431a7a08a14d16b37c7515d';
|
||||
|
||||
/// See also [appSettingsService].
|
||||
@ProviderFor(appSettingsService)
|
||||
|
|
31
mobile/lib/routing/custom_transition_builders.dart
Normal file
31
mobile/lib/routing/custom_transition_builders.dart
Normal file
|
@ -0,0 +1,31 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomTransitionsBuilders {
|
||||
const CustomTransitionsBuilders._();
|
||||
|
||||
static const ZoomPageTransitionsBuilder zoomPageTransitionsBuilder =
|
||||
ZoomPageTransitionsBuilder();
|
||||
|
||||
static const RouteTransitionsBuilder zoomedPage = _zoomedPage;
|
||||
|
||||
static Widget _zoomedPage(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) {
|
||||
return zoomPageTransitionsBuilder.buildTransitions(
|
||||
// Empty PageRoute<> object, only used to pass allowSnapshotting to ZoomPageTransitionsBuilder
|
||||
PageRouteBuilder(
|
||||
allowSnapshotting: true,
|
||||
fullscreenDialog: false,
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const SizedBox.shrink(),
|
||||
),
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
child,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
|
|||
import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/library_page.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_location_picker.dart';
|
||||
import 'package:immich_mobile/modules/map/views/map_page.dart';
|
||||
import 'package:immich_mobile/modules/memories/models/memory.dart';
|
||||
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
|
||||
|
@ -43,6 +44,7 @@ import 'package:immich_mobile/modules/search/views/search_page.dart';
|
|||
import 'package:immich_mobile/modules/search/views/search_result_page.dart';
|
||||
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
|
||||
import 'package:immich_mobile/routing/auth_guard.dart';
|
||||
import 'package:immich_mobile/routing/custom_transition_builders.dart';
|
||||
import 'package:immich_mobile/routing/duplicate_guard.dart';
|
||||
import 'package:immich_mobile/routing/backup_permission_guard.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
@ -56,7 +58,8 @@ import 'package:immich_mobile/shared/views/app_log_page.dart';
|
|||
import 'package:immich_mobile/shared/views/splash_screen.dart';
|
||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photo_manager/photo_manager.dart' hide LatLng;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
part 'router.gr.dart';
|
||||
|
||||
|
@ -86,9 +89,10 @@ part 'router.gr.dart';
|
|||
],
|
||||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
AutoRoute(
|
||||
CustomRoute(
|
||||
page: GalleryViewerPage,
|
||||
guards: [AuthGuard, DuplicateGuard],
|
||||
transitionsBuilder: CustomTransitionsBuilders.zoomedPage,
|
||||
),
|
||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(
|
||||
|
@ -170,6 +174,10 @@ part 'router.gr.dart';
|
|||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
durationInMilliseconds: 200,
|
||||
),
|
||||
CustomRoute<LatLng?>(
|
||||
page: MapLocationPickerPage,
|
||||
guards: [AuthGuard, DuplicateGuard],
|
||||
),
|
||||
],
|
||||
)
|
||||
class AppRouter extends _$AppRouter {
|
||||
|
|
|
@ -63,7 +63,7 @@ class _$AppRouter extends RootStackRouter {
|
|||
},
|
||||
GalleryViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<GalleryViewerRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
return CustomPage<dynamic>(
|
||||
routeData: routeData,
|
||||
child: GalleryViewerPage(
|
||||
key: args.key,
|
||||
|
@ -75,6 +75,9 @@ class _$AppRouter extends RootStackRouter {
|
|||
isOwner: args.isOwner,
|
||||
sharedAlbumId: args.sharedAlbumId,
|
||||
),
|
||||
transitionsBuilder: CustomTransitionsBuilders.zoomedPage,
|
||||
opaque: true,
|
||||
barrierDismissible: false,
|
||||
);
|
||||
},
|
||||
VideoViewerRoute.name: (routeData) {
|
||||
|
@ -357,6 +360,19 @@ class _$AppRouter extends RootStackRouter {
|
|||
barrierDismissible: false,
|
||||
);
|
||||
},
|
||||
MapLocationPickerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<MapLocationPickerRouteArgs>(
|
||||
orElse: () => const MapLocationPickerRouteArgs());
|
||||
return CustomPage<LatLng?>(
|
||||
routeData: routeData,
|
||||
child: MapLocationPickerPage(
|
||||
key: args.key,
|
||||
initialLatLng: args.initialLatLng,
|
||||
),
|
||||
opaque: true,
|
||||
barrierDismissible: false,
|
||||
);
|
||||
},
|
||||
HomeRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
|
@ -701,6 +717,14 @@ class _$AppRouter extends RootStackRouter {
|
|||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
MapLocationPickerRoute.name,
|
||||
path: '/map-location-picker-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -1618,6 +1642,40 @@ class ActivitiesRouteArgs {
|
|||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [MapLocationPickerPage]
|
||||
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
|
||||
MapLocationPickerRoute({
|
||||
Key? key,
|
||||
LatLng? initialLatLng,
|
||||
}) : super(
|
||||
MapLocationPickerRoute.name,
|
||||
path: '/map-location-picker-page',
|
||||
args: MapLocationPickerRouteArgs(
|
||||
key: key,
|
||||
initialLatLng: initialLatLng,
|
||||
),
|
||||
);
|
||||
|
||||
static const String name = 'MapLocationPickerRoute';
|
||||
}
|
||||
|
||||
class MapLocationPickerRouteArgs {
|
||||
const MapLocationPickerRouteArgs({
|
||||
this.key,
|
||||
this.initialLatLng,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final LatLng? initialLatLng;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MapLocationPickerRouteArgs{key: $key, initialLatLng: $initialLatLng}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [HomePage]
|
||||
class HomeRoute extends PageRouteInfo<void> {
|
||||
|
|
|
@ -256,6 +256,8 @@ class Asset {
|
|||
isFavorite != a.isFavorite ||
|
||||
isArchived != a.isArchived ||
|
||||
isTrashed != a.isTrashed ||
|
||||
a.exifInfo?.latitude != exifInfo?.latitude ||
|
||||
a.exifInfo?.longitude != exifInfo?.longitude ||
|
||||
// no local stack count or different count from remote
|
||||
((stackCount == null && a.stackCount != null) ||
|
||||
(stackCount != null &&
|
||||
|
|
2
mobile/lib/shared/providers/api.provider.g.dart
generated
2
mobile/lib/shared/providers/api.provider.g.dart
generated
|
@ -6,7 +6,7 @@ part of 'api.provider.dart';
|
|||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$apiServiceHash() => r'03cbd33147a7058d56175e532ac47e1aa4858c6d';
|
||||
String _$apiServiceHash() => r'5b8beddb448316bdae5e3963ff77601653715729';
|
||||
|
||||
/// See also [apiService].
|
||||
@ProviderFor(apiService)
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/server_info/server_version.model.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
|
@ -14,13 +17,33 @@ import 'package:socket_io_client/socket_io_client.dart';
|
|||
|
||||
enum PendingAction {
|
||||
assetDelete,
|
||||
assetUploaded,
|
||||
assetHidden,
|
||||
}
|
||||
|
||||
class PendingChange {
|
||||
final String id;
|
||||
final PendingAction action;
|
||||
final dynamic value;
|
||||
|
||||
const PendingChange(this.action, this.value);
|
||||
const PendingChange(
|
||||
this.id,
|
||||
this.action,
|
||||
this.value,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'PendingChange(id: $id, action: $action, value: $value)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is PendingChange && other.id == id && other.action == action;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ action.hashCode;
|
||||
}
|
||||
|
||||
class WebsocketState {
|
||||
|
@ -131,6 +154,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||
socket.on('on_asset_trash', _handleServerUpdates);
|
||||
socket.on('on_asset_restore', _handleServerUpdates);
|
||||
socket.on('on_asset_update', _handleServerUpdates);
|
||||
socket.on('on_asset_hidden', _handleOnAssetHidden);
|
||||
socket.on('on_new_release', _handleReleaseUpdates);
|
||||
} catch (e) {
|
||||
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
|
||||
|
@ -163,35 +187,78 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||
}
|
||||
|
||||
void addPendingChange(PendingAction action, dynamic value) {
|
||||
final now = DateTime.now();
|
||||
state = state.copyWith(
|
||||
pendingChanges: [...state.pendingChanges, PendingChange(action, value)],
|
||||
pendingChanges: [
|
||||
...state.pendingChanges,
|
||||
PendingChange(now.millisecondsSinceEpoch.toString(), action, value),
|
||||
],
|
||||
);
|
||||
_debounce(handlePendingChanges);
|
||||
}
|
||||
|
||||
void handlePendingChanges() {
|
||||
Future<void> _handlePendingDeletes() async {
|
||||
final deleteChanges = state.pendingChanges
|
||||
.where((c) => c.action == PendingAction.assetDelete)
|
||||
.toList();
|
||||
if (deleteChanges.isNotEmpty) {
|
||||
List<String> remoteIds =
|
||||
deleteChanges.map((a) => a.value.toString()).toList();
|
||||
_ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
|
||||
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
|
||||
state = state.copyWith(
|
||||
pendingChanges: state.pendingChanges
|
||||
.where((c) => c.action != PendingAction.assetDelete)
|
||||
.whereNot((c) => deleteChanges.contains(c))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleOnUploadSuccess(dynamic data) {
|
||||
final dto = AssetResponseDto.fromJson(data);
|
||||
if (dto != null) {
|
||||
final newAsset = Asset.remote(dto);
|
||||
_ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
|
||||
Future<void> _handlePendingUploaded() async {
|
||||
final uploadedChanges = state.pendingChanges
|
||||
.where((c) => c.action == PendingAction.assetUploaded)
|
||||
.toList();
|
||||
if (uploadedChanges.isNotEmpty) {
|
||||
List<AssetResponseDto?> remoteAssets = uploadedChanges
|
||||
.map((a) => AssetResponseDto.fromJson(a.value))
|
||||
.toList();
|
||||
for (final dto in remoteAssets) {
|
||||
if (dto != null) {
|
||||
final newAsset = Asset.remote(dto);
|
||||
await _ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
|
||||
}
|
||||
}
|
||||
state = state.copyWith(
|
||||
pendingChanges: state.pendingChanges
|
||||
.whereNot((c) => uploadedChanges.contains(c))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlingPendingHidden() async {
|
||||
final hiddenChanges = state.pendingChanges
|
||||
.where((c) => c.action == PendingAction.assetHidden)
|
||||
.toList();
|
||||
if (hiddenChanges.isNotEmpty) {
|
||||
List<String> remoteIds =
|
||||
hiddenChanges.map((a) => a.value.toString()).toList();
|
||||
final db = _ref.watch(dbProvider);
|
||||
await db.writeTxn(() => db.assets.deleteAllByRemoteId(remoteIds));
|
||||
|
||||
state = state.copyWith(
|
||||
pendingChanges: state.pendingChanges
|
||||
.whereNot((c) => hiddenChanges.contains(c))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void handlePendingChanges() async {
|
||||
await _handlePendingUploaded();
|
||||
await _handlePendingDeletes();
|
||||
await _handlingPendingHidden();
|
||||
}
|
||||
|
||||
void _handleOnConfigUpdate(dynamic _) {
|
||||
_ref.read(serverInfoProvider.notifier).getServerFeatures();
|
||||
_ref.read(serverInfoProvider.notifier).getServerConfig();
|
||||
|
@ -202,10 +269,14 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||
_ref.read(assetProvider.notifier).getAllAsset();
|
||||
}
|
||||
|
||||
void _handleOnAssetDelete(dynamic data) {
|
||||
addPendingChange(PendingAction.assetDelete, data);
|
||||
_debounce(handlePendingChanges);
|
||||
}
|
||||
void _handleOnUploadSuccess(dynamic data) =>
|
||||
addPendingChange(PendingAction.assetUploaded, data);
|
||||
|
||||
void _handleOnAssetDelete(dynamic data) =>
|
||||
addPendingChange(PendingAction.assetDelete, data);
|
||||
|
||||
void _handleOnAssetHidden(dynamic data) =>
|
||||
addPendingChange(PendingAction.assetHidden, data);
|
||||
|
||||
_handleReleaseUpdates(dynamic data) {
|
||||
// Json guard
|
||||
|
|
|
@ -11,6 +11,7 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
|
|||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
|
@ -181,4 +182,27 @@ class AssetService {
|
|||
Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool isArchive) {
|
||||
return updateAssets(assets, UpdateAssetDto(isArchived: isArchive));
|
||||
}
|
||||
|
||||
Future<List<Asset?>> changeDateTime(
|
||||
List<Asset> assets,
|
||||
String updatedDt,
|
||||
) {
|
||||
return updateAssets(
|
||||
assets,
|
||||
UpdateAssetDto(dateTimeOriginal: updatedDt),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Asset?>> changeLocation(
|
||||
List<Asset> assets,
|
||||
LatLng location,
|
||||
) {
|
||||
return updateAssets(
|
||||
assets,
|
||||
UpdateAssetDto(
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -401,6 +401,10 @@ class SyncService {
|
|||
|
||||
final Album a = await Album.remote(dto);
|
||||
await _db.writeTxn(() => _db.albums.store(a));
|
||||
} else {
|
||||
_log.warning(
|
||||
"Failed to add album from server: assetCount ${dto.assetCount} != "
|
||||
"asset array length ${dto.assets.length} for album ${dto.albumName}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
260
mobile/lib/shared/ui/date_time_picker.dart
Normal file
260
mobile/lib/shared/ui/date_time_picker.dart
Normal file
|
@ -0,0 +1,260 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/timezone.dart';
|
||||
|
||||
Future<String?> showDateTimePicker({
|
||||
required BuildContext context,
|
||||
DateTime? initialDateTime,
|
||||
String? initialTZ,
|
||||
Duration? initialTZOffset,
|
||||
}) {
|
||||
return showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => _DateTimePicker(
|
||||
initialDateTime: initialDateTime,
|
||||
initialTZ: initialTZ,
|
||||
initialTZOffset: initialTZOffset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getFormattedOffset(int offsetInMilli, tz.Location location) {
|
||||
return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
|
||||
}
|
||||
|
||||
class _DateTimePicker extends HookWidget {
|
||||
final DateTime? initialDateTime;
|
||||
final String? initialTZ;
|
||||
final Duration? initialTZOffset;
|
||||
|
||||
const _DateTimePicker({
|
||||
this.initialDateTime,
|
||||
this.initialTZ,
|
||||
this.initialTZOffset,
|
||||
});
|
||||
|
||||
_TimeZoneOffset _getInitiationLocation() {
|
||||
if (initialTZ != null) {
|
||||
try {
|
||||
return _TimeZoneOffset.fromLocation(
|
||||
tz.timeZoneDatabase.get(initialTZ!),
|
||||
);
|
||||
} on LocationNotFoundException {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Duration? tzOffset = initialTZOffset ?? initialDateTime?.timeZoneOffset;
|
||||
|
||||
if (tzOffset != null) {
|
||||
final offsetInMilli = tzOffset.inMilliseconds;
|
||||
// get all locations with matching offset
|
||||
final locations = tz.timeZoneDatabase.locations.values.where(
|
||||
(location) => location.currentTimeZone.offset == offsetInMilli,
|
||||
);
|
||||
// Prefer locations with abbreviation first
|
||||
final location = locations.firstWhereOrNull(
|
||||
(e) => !e.currentTimeZone.abbreviation.contains("0"),
|
||||
) ??
|
||||
locations.firstOrNull;
|
||||
if (location != null) {
|
||||
return _TimeZoneOffset.fromLocation(location);
|
||||
}
|
||||
}
|
||||
|
||||
return _TimeZoneOffset.fromLocation(tz.getLocation("UTC"));
|
||||
}
|
||||
|
||||
// returns a list of location<name> along with it's offset in duration
|
||||
List<_TimeZoneOffset> getAllTimeZones() {
|
||||
return tz.timeZoneDatabase.locations.values
|
||||
.where((l) => !l.currentTimeZone.abbreviation.contains("0"))
|
||||
.map(_TimeZoneOffset.fromLocation)
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = useState<DateTime>(initialDateTime ?? DateTime.now());
|
||||
final tzOffset = useState<_TimeZoneOffset>(_getInitiationLocation());
|
||||
final timeZones = useMemoized(() => getAllTimeZones(), const []);
|
||||
|
||||
void pickDate() async {
|
||||
final now = DateTime.now();
|
||||
// Handles cases where the date from the asset is far off in the future
|
||||
final initialDate = date.value.isAfter(now) ? now : date.value;
|
||||
final newDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initialDate,
|
||||
firstDate: DateTime(1800),
|
||||
lastDate: now,
|
||||
);
|
||||
if (newDate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(date.value),
|
||||
);
|
||||
|
||||
if (newTime == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
date.value = newDate.copyWith(hour: newTime.hour, minute: newTime.minute);
|
||||
}
|
||||
|
||||
void popWithDateTime() {
|
||||
final formattedDateTime =
|
||||
DateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date.value);
|
||||
final dtWithOffset = formattedDateTime +
|
||||
Duration(milliseconds: tzOffset.value.offsetInMilliseconds)
|
||||
.formatAsOffset();
|
||||
context.pop(dtWithOffset);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
contentPadding: const EdgeInsets.all(30),
|
||||
alignment: Alignment.center,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"edit_date_time_dialog_date_time",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
TextButton.icon(
|
||||
onPressed: pickDate,
|
||||
icon: Text(
|
||||
DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
|
||||
style: context.textTheme.bodyLarge
|
||||
?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
label: const Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
"edit_date_time_dialog_timezone",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
DropdownMenu(
|
||||
menuHeight: 300,
|
||||
width: 280,
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
trailingIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
textStyle: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
menuStyle: const MenuStyle(
|
||||
fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)),
|
||||
alignment: Alignment(-1.25, 0.5),
|
||||
),
|
||||
onSelected: (value) => tzOffset.value = value!,
|
||||
initialSelection: tzOffset.value,
|
||||
dropdownMenuEntries: timeZones
|
||||
.map(
|
||||
(t) => DropdownMenuEntry<_TimeZoneOffset>(
|
||||
value: t,
|
||||
label: t.display,
|
||||
style: ButtonStyle(
|
||||
textStyle: MaterialStatePropertyAll(
|
||||
context.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(
|
||||
"action_common_cancel",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: popWithDateTime,
|
||||
child: Text(
|
||||
"action_common_update",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimeZoneOffset implements Comparable<_TimeZoneOffset> {
|
||||
final String display;
|
||||
final Location location;
|
||||
|
||||
const _TimeZoneOffset({
|
||||
required this.display,
|
||||
required this.location,
|
||||
});
|
||||
|
||||
_TimeZoneOffset copyWith({
|
||||
String? display,
|
||||
Location? location,
|
||||
}) {
|
||||
return _TimeZoneOffset(
|
||||
display: display ?? this.display,
|
||||
location: location ?? this.location,
|
||||
);
|
||||
}
|
||||
|
||||
int get offsetInMilliseconds => location.currentTimeZone.offset;
|
||||
|
||||
_TimeZoneOffset.fromLocation(tz.Location l)
|
||||
: display = _getFormattedOffset(l.currentTimeZone.offset, l),
|
||||
location = l;
|
||||
|
||||
@override
|
||||
int compareTo(_TimeZoneOffset other) {
|
||||
return offsetInMilliseconds.compareTo(other.offsetInMilliseconds);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'_TimeZoneOffset(display: $display, location: $location)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is _TimeZoneOffset &&
|
||||
other.display == display &&
|
||||
other.offsetInMilliseconds == offsetInMilliseconds;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode;
|
||||
}
|
256
mobile/lib/shared/ui/location_picker.dart
Normal file
256
mobile/lib/shared/ui/location_picker.dart
Normal file
|
@ -0,0 +1,256 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
Future<LatLng?> showLocationPicker({
|
||||
required BuildContext context,
|
||||
LatLng? initialLatLng,
|
||||
}) {
|
||||
return showDialog<LatLng?>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (ctx) => _LocationPicker(
|
||||
initialLatLng: initialLatLng,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
enum _LocationPickerMode { map, manual }
|
||||
|
||||
bool _validateLat(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -90 && l < 90;
|
||||
}
|
||||
|
||||
bool _validateLong(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -180 && l < 180;
|
||||
}
|
||||
|
||||
class _LocationPicker extends HookWidget {
|
||||
final LatLng? initialLatLng;
|
||||
|
||||
const _LocationPicker({
|
||||
this.initialLatLng,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latitude = useState(initialLatLng?.latitude ?? 0.0);
|
||||
final longitude = useState(initialLatLng?.longitude ?? 0.0);
|
||||
final latlng = LatLng(latitude.value, longitude.value);
|
||||
final pickerMode = useState(_LocationPickerMode.map);
|
||||
final latitudeController = useTextEditingController();
|
||||
final isValidLatitude = useState(true);
|
||||
final latitiudeFocusNode = useFocusNode();
|
||||
final longitudeController = useTextEditingController();
|
||||
final longitudeFocusNode = useFocusNode();
|
||||
final isValidLongitude = useState(true);
|
||||
|
||||
void validateInputs() {
|
||||
isValidLatitude.value = _validateLat(latitudeController.text);
|
||||
if (isValidLatitude.value) {
|
||||
latitude.value = latitudeController.text.toDouble();
|
||||
}
|
||||
isValidLongitude.value = _validateLong(longitudeController.text);
|
||||
if (isValidLongitude.value) {
|
||||
longitude.value = longitudeController.text.toDouble();
|
||||
}
|
||||
}
|
||||
|
||||
void validateAndPop() {
|
||||
if (pickerMode.value == _LocationPickerMode.manual) {
|
||||
validateInputs();
|
||||
}
|
||||
if (isValidLatitude.value && isValidLongitude.value) {
|
||||
return context.pop(latlng);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> buildMapPickerMode() {
|
||||
return [
|
||||
TextButton.icon(
|
||||
icon: Text(
|
||||
"${latitude.value.toStringAsFixed(4)}, ${longitude.value.toStringAsFixed(4)}",
|
||||
),
|
||||
label: const Icon(Icons.edit_outlined, size: 16),
|
||||
onPressed: () {
|
||||
latitudeController.text = latitude.value.toStringAsFixed(4);
|
||||
longitudeController.text = longitude.value.toStringAsFixed(4);
|
||||
pickerMode.value = _LocationPickerMode.manual;
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
MapThumbnail(
|
||||
coords: latlng,
|
||||
height: 200,
|
||||
width: 200,
|
||||
zoom: 6,
|
||||
showAttribution: false,
|
||||
onTap: (p0, p1) async {
|
||||
final newLatLng = await context.autoPush<LatLng?>(
|
||||
MapLocationPickerRoute(initialLatLng: latlng),
|
||||
);
|
||||
if (newLatLng != null) {
|
||||
latitude.value = newLatLng.latitude;
|
||||
longitude.value = newLatLng.longitude;
|
||||
}
|
||||
},
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: LatLng(
|
||||
latitude.value,
|
||||
longitude.value,
|
||||
),
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> buildManualPickerMode() {
|
||||
return [
|
||||
TextButton.icon(
|
||||
icon: const Text("location_picker_choose_on_map").tr(),
|
||||
label: const Icon(Icons.map_outlined, size: 16),
|
||||
onPressed: () {
|
||||
validateInputs();
|
||||
if (isValidLatitude.value && isValidLongitude.value) {
|
||||
pickerMode.value = _LocationPickerMode.map;
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
TextField(
|
||||
controller: latitudeController,
|
||||
focusNode: latitiudeFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'location_picker_latitude'.tr(),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'location_picker_latitude_hint'.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
errorText: isValidLatitude.value
|
||||
? null
|
||||
: "location_picker_latitude_error".tr(),
|
||||
),
|
||||
onEditingComplete: () {
|
||||
isValidLatitude.value = _validateLat(latitudeController.text);
|
||||
if (isValidLatitude.value) {
|
||||
latitude.value = latitudeController.text.toDouble();
|
||||
longitudeFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(8)],
|
||||
onTapOutside: (_) => latitiudeFocusNode.unfocus(),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
TextField(
|
||||
controller: longitudeController,
|
||||
focusNode: longitudeFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'location_picker_longitude'.tr(),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'location_picker_longitude_hint'.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
errorText: isValidLongitude.value
|
||||
? null
|
||||
: "location_picker_longitude_error".tr(),
|
||||
),
|
||||
onEditingComplete: () {
|
||||
isValidLongitude.value = _validateLong(longitudeController.text);
|
||||
if (isValidLongitude.value) {
|
||||
longitude.value = longitudeController.text.toDouble();
|
||||
longitudeFocusNode.unfocus();
|
||||
}
|
||||
},
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(8)],
|
||||
onTapOutside: (_) => longitudeFocusNode.unfocus(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
contentPadding: const EdgeInsets.all(30),
|
||||
alignment: Alignment.center,
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"edit_location_dialog_title",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
if (pickerMode.value == _LocationPickerMode.manual)
|
||||
...buildManualPickerMode(),
|
||||
if (pickerMode.value == _LocationPickerMode.map)
|
||||
...buildMapPickerMode(),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(
|
||||
"action_common_cancel",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: validateAndPop,
|
||||
child: Text(
|
||||
"action_common_update",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ class ScaffoldErrorBody extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"scaffold_body_error_occured",
|
||||
"scaffold_body_error_occurred",
|
||||
style: context.textTheme.displayMedium,
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
|
|
|
@ -20,7 +20,7 @@ final immichThemeProvider = StateProvider<ThemeMode>((ref) {
|
|||
}
|
||||
});
|
||||
|
||||
ThemeData base = ThemeData(
|
||||
final ThemeData base = ThemeData(
|
||||
chipTheme: const ChipThemeData(
|
||||
side: BorderSide.none,
|
||||
),
|
||||
|
@ -30,7 +30,7 @@ ThemeData base = ThemeData(
|
|||
),
|
||||
);
|
||||
|
||||
ThemeData immichLightTheme = ThemeData(
|
||||
final ThemeData immichLightTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
primarySwatch: Colors.indigo,
|
||||
|
@ -153,7 +153,7 @@ ThemeData immichLightTheme = ThemeData(
|
|||
),
|
||||
);
|
||||
|
||||
ThemeData immichDarkTheme = ThemeData(
|
||||
final ThemeData immichDarkTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
primarySwatch: Colors.indigo,
|
||||
|
|
|
@ -2,12 +2,17 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/date_time_picker.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/location_picker.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
void handleShareAssets(
|
||||
WidgetRef ref,
|
||||
|
@ -85,3 +90,60 @@ Future<void> handleFavoriteAssets(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleEditDateTime(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection,
|
||||
) async {
|
||||
DateTime? initialDate;
|
||||
String? timeZone;
|
||||
Duration? offset;
|
||||
if (selection.length == 1) {
|
||||
final asset = selection.first;
|
||||
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
|
||||
final (dt, oft) = assetWithExif.getTZAdjustedTimeAndOffset();
|
||||
initialDate = dt;
|
||||
offset = oft;
|
||||
timeZone = assetWithExif.exifInfo?.timeZone;
|
||||
}
|
||||
final dateTime = await showDateTimePicker(
|
||||
context: context,
|
||||
initialDateTime: initialDate,
|
||||
initialTZ: timeZone,
|
||||
initialTZOffset: offset,
|
||||
);
|
||||
if (dateTime == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(assetServiceProvider).changeDateTime(selection.toList(), dateTime);
|
||||
}
|
||||
|
||||
Future<void> handleEditLocation(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection,
|
||||
) async {
|
||||
LatLng? initialLatLng;
|
||||
if (selection.length == 1) {
|
||||
final asset = selection.first;
|
||||
final assetWithExif = await ref.watch(assetServiceProvider).loadExif(asset);
|
||||
if (assetWithExif.exifInfo?.latitude != null &&
|
||||
assetWithExif.exifInfo?.longitude != null) {
|
||||
initialLatLng = LatLng(
|
||||
assetWithExif.exifInfo!.latitude!,
|
||||
assetWithExif.exifInfo!.longitude!,
|
||||
);
|
||||
}
|
||||
}
|
||||
final location = await showLocationPicker(
|
||||
context: context,
|
||||
initialLatLng: initialLatLng,
|
||||
);
|
||||
if (location == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(assetServiceProvider).changeLocation(selection.toList(), location);
|
||||
}
|
||||
|
|
21
mobile/openapi/.openapi-generator/FILES
generated
21
mobile/openapi/.openapi-generator/FILES
generated
|
@ -24,6 +24,10 @@ doc/AssetBulkUploadCheckDto.md
|
|||
doc/AssetBulkUploadCheckItem.md
|
||||
doc/AssetBulkUploadCheckResponseDto.md
|
||||
doc/AssetBulkUploadCheckResult.md
|
||||
doc/AssetFaceResponseDto.md
|
||||
doc/AssetFaceUpdateDto.md
|
||||
doc/AssetFaceUpdateItem.md
|
||||
doc/AssetFaceWithoutPersonResponseDto.md
|
||||
doc/AssetFileUploadResponseDto.md
|
||||
doc/AssetIdsDto.md
|
||||
doc/AssetIdsResponseDto.md
|
||||
|
@ -60,6 +64,8 @@ doc/DownloadInfoDto.md
|
|||
doc/DownloadResponseDto.md
|
||||
doc/EntityType.md
|
||||
doc/ExifResponseDto.md
|
||||
doc/FaceApi.md
|
||||
doc/FaceDto.md
|
||||
doc/FileChecksumDto.md
|
||||
doc/FileChecksumResponseDto.md
|
||||
doc/FileReportDto.md
|
||||
|
@ -100,6 +106,7 @@ doc/PersonApi.md
|
|||
doc/PersonResponseDto.md
|
||||
doc/PersonStatisticsResponseDto.md
|
||||
doc/PersonUpdateDto.md
|
||||
doc/PersonWithFacesResponseDto.md
|
||||
doc/QueueStatusDto.md
|
||||
doc/ReactionLevel.md
|
||||
doc/ReactionType.md
|
||||
|
@ -177,6 +184,7 @@ lib/api/api_key_api.dart
|
|||
lib/api/asset_api.dart
|
||||
lib/api/audit_api.dart
|
||||
lib/api/authentication_api.dart
|
||||
lib/api/face_api.dart
|
||||
lib/api/job_api.dart
|
||||
lib/api/library_api.dart
|
||||
lib/api/o_auth_api.dart
|
||||
|
@ -213,6 +221,10 @@ lib/model/asset_bulk_upload_check_dto.dart
|
|||
lib/model/asset_bulk_upload_check_item.dart
|
||||
lib/model/asset_bulk_upload_check_response_dto.dart
|
||||
lib/model/asset_bulk_upload_check_result.dart
|
||||
lib/model/asset_face_response_dto.dart
|
||||
lib/model/asset_face_update_dto.dart
|
||||
lib/model/asset_face_update_item.dart
|
||||
lib/model/asset_face_without_person_response_dto.dart
|
||||
lib/model/asset_file_upload_response_dto.dart
|
||||
lib/model/asset_ids_dto.dart
|
||||
lib/model/asset_ids_response_dto.dart
|
||||
|
@ -247,6 +259,7 @@ lib/model/download_info_dto.dart
|
|||
lib/model/download_response_dto.dart
|
||||
lib/model/entity_type.dart
|
||||
lib/model/exif_response_dto.dart
|
||||
lib/model/face_dto.dart
|
||||
lib/model/file_checksum_dto.dart
|
||||
lib/model/file_checksum_response_dto.dart
|
||||
lib/model/file_report_dto.dart
|
||||
|
@ -282,6 +295,7 @@ lib/model/people_update_item.dart
|
|||
lib/model/person_response_dto.dart
|
||||
lib/model/person_statistics_response_dto.dart
|
||||
lib/model/person_update_dto.dart
|
||||
lib/model/person_with_faces_response_dto.dart
|
||||
lib/model/queue_status_dto.dart
|
||||
lib/model/reaction_level.dart
|
||||
lib/model/reaction_type.dart
|
||||
|
@ -367,6 +381,10 @@ test/asset_bulk_upload_check_dto_test.dart
|
|||
test/asset_bulk_upload_check_item_test.dart
|
||||
test/asset_bulk_upload_check_response_dto_test.dart
|
||||
test/asset_bulk_upload_check_result_test.dart
|
||||
test/asset_face_response_dto_test.dart
|
||||
test/asset_face_update_dto_test.dart
|
||||
test/asset_face_update_item_test.dart
|
||||
test/asset_face_without_person_response_dto_test.dart
|
||||
test/asset_file_upload_response_dto_test.dart
|
||||
test/asset_ids_dto_test.dart
|
||||
test/asset_ids_response_dto_test.dart
|
||||
|
@ -403,6 +421,8 @@ test/download_info_dto_test.dart
|
|||
test/download_response_dto_test.dart
|
||||
test/entity_type_test.dart
|
||||
test/exif_response_dto_test.dart
|
||||
test/face_api_test.dart
|
||||
test/face_dto_test.dart
|
||||
test/file_checksum_dto_test.dart
|
||||
test/file_checksum_response_dto_test.dart
|
||||
test/file_report_dto_test.dart
|
||||
|
@ -443,6 +463,7 @@ test/person_api_test.dart
|
|||
test/person_response_dto_test.dart
|
||||
test/person_statistics_response_dto_test.dart
|
||||
test/person_update_dto_test.dart
|
||||
test/person_with_faces_response_dto_test.dart
|
||||
test/queue_status_dto_test.dart
|
||||
test/reaction_level_test.dart
|
||||
test/reaction_type_test.dart
|
||||
|
|
10
mobile/openapi/README.md
generated
10
mobile/openapi/README.md
generated
|
@ -133,6 +133,8 @@ Class | Method | HTTP request | Description
|
|||
*AuthenticationApi* | [**logoutAuthDevices**](doc//AuthenticationApi.md#logoutauthdevices) | **DELETE** /auth/devices |
|
||||
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
|
||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
|
||||
*FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face |
|
||||
*FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} |
|
||||
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
|
||||
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} |
|
||||
*LibraryApi* | [**createLibrary**](doc//LibraryApi.md#createlibrary) | **POST** /library |
|
||||
|
@ -153,12 +155,14 @@ Class | Method | HTTP request | Description
|
|||
*PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partner |
|
||||
*PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partner/{id} |
|
||||
*PartnerApi* | [**updatePartner**](doc//PartnerApi.md#updatepartner) | **PUT** /partner/{id} |
|
||||
*PersonApi* | [**createPerson**](doc//PersonApi.md#createperson) | **POST** /person |
|
||||
*PersonApi* | [**getAllPeople**](doc//PersonApi.md#getallpeople) | **GET** /person |
|
||||
*PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} |
|
||||
*PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |
|
||||
*PersonApi* | [**getPersonStatistics**](doc//PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics |
|
||||
*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |
|
||||
*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge |
|
||||
*PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign |
|
||||
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person |
|
||||
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} |
|
||||
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
|
||||
|
@ -224,6 +228,10 @@ Class | Method | HTTP request | Description
|
|||
- [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
|
||||
- [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md)
|
||||
- [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
|
||||
- [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
|
||||
- [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
|
||||
- [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
|
||||
- [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md)
|
||||
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
|
||||
- [AssetIdsDto](doc//AssetIdsDto.md)
|
||||
- [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
|
||||
|
@ -258,6 +266,7 @@ Class | Method | HTTP request | Description
|
|||
- [DownloadResponseDto](doc//DownloadResponseDto.md)
|
||||
- [EntityType](doc//EntityType.md)
|
||||
- [ExifResponseDto](doc//ExifResponseDto.md)
|
||||
- [FaceDto](doc//FaceDto.md)
|
||||
- [FileChecksumDto](doc//FileChecksumDto.md)
|
||||
- [FileChecksumResponseDto](doc//FileChecksumResponseDto.md)
|
||||
- [FileReportDto](doc//FileReportDto.md)
|
||||
|
@ -293,6 +302,7 @@ Class | Method | HTTP request | Description
|
|||
- [PersonResponseDto](doc//PersonResponseDto.md)
|
||||
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
|
||||
- [PersonUpdateDto](doc//PersonUpdateDto.md)
|
||||
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
|
||||
- [QueueStatusDto](doc//QueueStatusDto.md)
|
||||
- [ReactionLevel](doc//ReactionLevel.md)
|
||||
- [ReactionType](doc//ReactionType.md)
|
||||
|
|
22
mobile/openapi/doc/AssetFaceResponseDto.md
generated
Normal file
22
mobile/openapi/doc/AssetFaceResponseDto.md
generated
Normal file
|
@ -0,0 +1,22 @@
|
|||
# openapi.model.AssetFaceResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**boundingBoxX1** | **int** | |
|
||||
**boundingBoxX2** | **int** | |
|
||||
**boundingBoxY1** | **int** | |
|
||||
**boundingBoxY2** | **int** | |
|
||||
**id** | **String** | |
|
||||
**imageHeight** | **int** | |
|
||||
**imageWidth** | **int** | |
|
||||
**person** | [**PersonResponseDto**](PersonResponseDto.md) | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
15
mobile/openapi/doc/AssetFaceUpdateDto.md
generated
Normal file
15
mobile/openapi/doc/AssetFaceUpdateDto.md
generated
Normal file
|
@ -0,0 +1,15 @@
|
|||
# openapi.model.AssetFaceUpdateDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**data** | [**List<AssetFaceUpdateItem>**](AssetFaceUpdateItem.md) | | [default to const []]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
16
mobile/openapi/doc/AssetFaceUpdateItem.md
generated
Normal file
16
mobile/openapi/doc/AssetFaceUpdateItem.md
generated
Normal file
|
@ -0,0 +1,16 @@
|
|||
# openapi.model.AssetFaceUpdateItem
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**assetId** | **String** | |
|
||||
**personId** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
21
mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md
generated
Normal file
21
mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md
generated
Normal file
|
@ -0,0 +1,21 @@
|
|||
# openapi.model.AssetFaceWithoutPersonResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**boundingBoxX1** | **int** | |
|
||||
**boundingBoxX2** | **int** | |
|
||||
**boundingBoxY1** | **int** | |
|
||||
**boundingBoxY2** | **int** | |
|
||||
**id** | **String** | |
|
||||
**imageHeight** | **int** | |
|
||||
**imageWidth** | **int** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
2
mobile/openapi/doc/AssetResponseDto.md
generated
2
mobile/openapi/doc/AssetResponseDto.md
generated
|
@ -30,7 +30,7 @@ Name | Type | Description | Notes
|
|||
**originalPath** | **String** | |
|
||||
**owner** | [**UserResponseDto**](UserResponseDto.md) | | [optional]
|
||||
**ownerId** | **String** | |
|
||||
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
|
||||
**people** | [**List<PersonWithFacesResponseDto>**](PersonWithFacesResponseDto.md) | | [optional] [default to const []]
|
||||
**resized** | **bool** | |
|
||||
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
|
||||
**stack** | [**List<AssetResponseDto>**](AssetResponseDto.md) | | [optional] [default to const []]
|
||||
|
|
127
mobile/openapi/doc/FaceApi.md
generated
Normal file
127
mobile/openapi/doc/FaceApi.md
generated
Normal file
|
@ -0,0 +1,127 @@
|
|||
# openapi.api.FaceApi
|
||||
|
||||
## Load the API package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
All URIs are relative to */api*
|
||||
|
||||
Method | HTTP request | Description
|
||||
------------- | ------------- | -------------
|
||||
[**getFaces**](FaceApi.md#getfaces) | **GET** /face |
|
||||
[**reassignFacesById**](FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} |
|
||||
|
||||
|
||||
# **getFaces**
|
||||
> List<AssetFaceResponseDto> getFaces(id)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = FaceApi();
|
||||
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
|
||||
try {
|
||||
final result = api_instance.getFaces(id);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling FaceApi->getFaces: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**id** | **String**| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**List<AssetFaceResponseDto>**](AssetFaceResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: Not defined
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **reassignFacesById**
|
||||
> PersonResponseDto reassignFacesById(id, faceDto)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = FaceApi();
|
||||
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
final faceDto = FaceDto(); // FaceDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.reassignFacesById(id, faceDto);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling FaceApi->reassignFacesById: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**id** | **String**| |
|
||||
**faceDto** | [**FaceDto**](FaceDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**PersonResponseDto**](PersonResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: application/json
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
15
mobile/openapi/doc/FaceDto.md
generated
Normal file
15
mobile/openapi/doc/FaceDto.md
generated
Normal file
|
@ -0,0 +1,15 @@
|
|||
# openapi.model.FaceDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**id** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
110
mobile/openapi/doc/PersonApi.md
generated
110
mobile/openapi/doc/PersonApi.md
generated
|
@ -9,16 +9,69 @@ All URIs are relative to */api*
|
|||
|
||||
Method | HTTP request | Description
|
||||
------------- | ------------- | -------------
|
||||
[**createPerson**](PersonApi.md#createperson) | **POST** /person |
|
||||
[**getAllPeople**](PersonApi.md#getallpeople) | **GET** /person |
|
||||
[**getPerson**](PersonApi.md#getperson) | **GET** /person/{id} |
|
||||
[**getPersonAssets**](PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |
|
||||
[**getPersonStatistics**](PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics |
|
||||
[**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |
|
||||
[**mergePerson**](PersonApi.md#mergeperson) | **POST** /person/{id}/merge |
|
||||
[**reassignFaces**](PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign |
|
||||
[**updatePeople**](PersonApi.md#updatepeople) | **PUT** /person |
|
||||
[**updatePerson**](PersonApi.md#updateperson) | **PUT** /person/{id} |
|
||||
|
||||
|
||||
# **createPerson**
|
||||
> PersonResponseDto createPerson()
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = PersonApi();
|
||||
|
||||
try {
|
||||
final result = api_instance.createPerson();
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling PersonApi->createPerson: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
This endpoint does not need any parameter.
|
||||
|
||||
### Return type
|
||||
|
||||
[**PersonResponseDto**](PersonResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: Not defined
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getAllPeople**
|
||||
> PeopleResponseDto getAllPeople(withHidden)
|
||||
|
||||
|
@ -351,6 +404,63 @@ Name | Type | Description | Notes
|
|||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **reassignFaces**
|
||||
> List<PersonResponseDto> reassignFaces(id, assetFaceUpdateDto)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = PersonApi();
|
||||
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
final assetFaceUpdateDto = AssetFaceUpdateDto(); // AssetFaceUpdateDto |
|
||||
|
||||
try {
|
||||
final result = api_instance.reassignFaces(id, assetFaceUpdateDto);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling PersonApi->reassignFaces: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**id** | **String**| |
|
||||
**assetFaceUpdateDto** | [**AssetFaceUpdateDto**](AssetFaceUpdateDto.md)| |
|
||||
|
||||
### Return type
|
||||
|
||||
[**List<PersonResponseDto>**](PersonResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: application/json
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **updatePeople**
|
||||
> List<BulkIdResponseDto> updatePeople(peopleUpdateDto)
|
||||
|
||||
|
|
20
mobile/openapi/doc/PersonWithFacesResponseDto.md
generated
Normal file
20
mobile/openapi/doc/PersonWithFacesResponseDto.md
generated
Normal file
|
@ -0,0 +1,20 @@
|
|||
# openapi.model.PersonWithFacesResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**birthDate** | [**DateTime**](DateTime.md) | |
|
||||
**faces** | [**List<AssetFaceWithoutPersonResponseDto>**](AssetFaceWithoutPersonResponseDto.md) | | [default to const []]
|
||||
**id** | **String** | |
|
||||
**isHidden** | **bool** | |
|
||||
**name** | **String** | |
|
||||
**thumbnailPath** | **String** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
7
mobile/openapi/lib/api.dart
generated
7
mobile/openapi/lib/api.dart
generated
|
@ -34,6 +34,7 @@ part 'api/album_api.dart';
|
|||
part 'api/asset_api.dart';
|
||||
part 'api/audit_api.dart';
|
||||
part 'api/authentication_api.dart';
|
||||
part 'api/face_api.dart';
|
||||
part 'api/job_api.dart';
|
||||
part 'api/library_api.dart';
|
||||
part 'api/o_auth_api.dart';
|
||||
|
@ -63,6 +64,10 @@ part 'model/asset_bulk_upload_check_dto.dart';
|
|||
part 'model/asset_bulk_upload_check_item.dart';
|
||||
part 'model/asset_bulk_upload_check_response_dto.dart';
|
||||
part 'model/asset_bulk_upload_check_result.dart';
|
||||
part 'model/asset_face_response_dto.dart';
|
||||
part 'model/asset_face_update_dto.dart';
|
||||
part 'model/asset_face_update_item.dart';
|
||||
part 'model/asset_face_without_person_response_dto.dart';
|
||||
part 'model/asset_file_upload_response_dto.dart';
|
||||
part 'model/asset_ids_dto.dart';
|
||||
part 'model/asset_ids_response_dto.dart';
|
||||
|
@ -97,6 +102,7 @@ part 'model/download_info_dto.dart';
|
|||
part 'model/download_response_dto.dart';
|
||||
part 'model/entity_type.dart';
|
||||
part 'model/exif_response_dto.dart';
|
||||
part 'model/face_dto.dart';
|
||||
part 'model/file_checksum_dto.dart';
|
||||
part 'model/file_checksum_response_dto.dart';
|
||||
part 'model/file_report_dto.dart';
|
||||
|
@ -132,6 +138,7 @@ part 'model/people_update_item.dart';
|
|||
part 'model/person_response_dto.dart';
|
||||
part 'model/person_statistics_response_dto.dart';
|
||||
part 'model/person_update_dto.dart';
|
||||
part 'model/person_with_faces_response_dto.dart';
|
||||
part 'model/queue_status_dto.dart';
|
||||
part 'model/reaction_level.dart';
|
||||
part 'model/reaction_type.dart';
|
||||
|
|
122
mobile/openapi/lib/api/face_api.dart
generated
Normal file
122
mobile/openapi/lib/api/face_api.dart
generated
Normal file
|
@ -0,0 +1,122 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class FaceApi {
|
||||
FaceApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Performs an HTTP 'GET /face' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<Response> getFacesWithHttpInfo(String id,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/face';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
queryParams.addAll(_queryParams('', 'id', id));
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
Future<List<AssetFaceResponseDto>?> getFaces(String id,) async {
|
||||
final response = await getFacesWithHttpInfo(id,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<AssetFaceResponseDto>') as List)
|
||||
.cast<AssetFaceResponseDto>()
|
||||
.toList();
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'PUT /face/{id}' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [FaceDto] faceDto (required):
|
||||
Future<Response> reassignFacesByIdWithHttpInfo(String id, FaceDto faceDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/face/{id}'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = faceDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [FaceDto] faceDto (required):
|
||||
Future<PersonResponseDto?> reassignFacesById(String id, FaceDto faceDto,) async {
|
||||
final response = await reassignFacesByIdWithHttpInfo(id, faceDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonResponseDto',) as PersonResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
96
mobile/openapi/lib/api/person_api.dart
generated
96
mobile/openapi/lib/api/person_api.dart
generated
|
@ -16,6 +16,47 @@ class PersonApi {
|
|||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Performs an HTTP 'POST /person' operation and returns the [Response].
|
||||
Future<Response> createPersonWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/person';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
Future<PersonResponseDto?> createPerson() async {
|
||||
final response = await createPersonWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonResponseDto',) as PersonResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'GET /person' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
|
@ -317,6 +358,61 @@ class PersonApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'PUT /person/{id}/reassign' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [AssetFaceUpdateDto] assetFaceUpdateDto (required):
|
||||
Future<Response> reassignFacesWithHttpInfo(String id, AssetFaceUpdateDto assetFaceUpdateDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/person/{id}/reassign'
|
||||
.replaceAll('{id}', id);
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = assetFaceUpdateDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [AssetFaceUpdateDto] assetFaceUpdateDto (required):
|
||||
Future<List<PersonResponseDto>?> reassignFaces(String id, AssetFaceUpdateDto assetFaceUpdateDto,) async {
|
||||
final response = await reassignFacesWithHttpInfo(id, assetFaceUpdateDto,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<PersonResponseDto>') as List)
|
||||
.cast<PersonResponseDto>()
|
||||
.toList();
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Performs an HTTP 'PUT /person' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
|
|
12
mobile/openapi/lib/api_client.dart
generated
12
mobile/openapi/lib/api_client.dart
generated
|
@ -215,6 +215,14 @@ class ApiClient {
|
|||
return AssetBulkUploadCheckResponseDto.fromJson(value);
|
||||
case 'AssetBulkUploadCheckResult':
|
||||
return AssetBulkUploadCheckResult.fromJson(value);
|
||||
case 'AssetFaceResponseDto':
|
||||
return AssetFaceResponseDto.fromJson(value);
|
||||
case 'AssetFaceUpdateDto':
|
||||
return AssetFaceUpdateDto.fromJson(value);
|
||||
case 'AssetFaceUpdateItem':
|
||||
return AssetFaceUpdateItem.fromJson(value);
|
||||
case 'AssetFaceWithoutPersonResponseDto':
|
||||
return AssetFaceWithoutPersonResponseDto.fromJson(value);
|
||||
case 'AssetFileUploadResponseDto':
|
||||
return AssetFileUploadResponseDto.fromJson(value);
|
||||
case 'AssetIdsDto':
|
||||
|
@ -283,6 +291,8 @@ class ApiClient {
|
|||
return EntityTypeTypeTransformer().decode(value);
|
||||
case 'ExifResponseDto':
|
||||
return ExifResponseDto.fromJson(value);
|
||||
case 'FaceDto':
|
||||
return FaceDto.fromJson(value);
|
||||
case 'FileChecksumDto':
|
||||
return FileChecksumDto.fromJson(value);
|
||||
case 'FileChecksumResponseDto':
|
||||
|
@ -353,6 +363,8 @@ class ApiClient {
|
|||
return PersonStatisticsResponseDto.fromJson(value);
|
||||
case 'PersonUpdateDto':
|
||||
return PersonUpdateDto.fromJson(value);
|
||||
case 'PersonWithFacesResponseDto':
|
||||
return PersonWithFacesResponseDto.fromJson(value);
|
||||
case 'QueueStatusDto':
|
||||
return QueueStatusDto.fromJson(value);
|
||||
case 'ReactionLevel':
|
||||
|
|
158
mobile/openapi/lib/model/asset_face_response_dto.dart
generated
Normal file
158
mobile/openapi/lib/model/asset_face_response_dto.dart
generated
Normal file
|
@ -0,0 +1,158 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetFaceResponseDto {
|
||||
/// Returns a new [AssetFaceResponseDto] instance.
|
||||
AssetFaceResponseDto({
|
||||
required this.boundingBoxX1,
|
||||
required this.boundingBoxX2,
|
||||
required this.boundingBoxY1,
|
||||
required this.boundingBoxY2,
|
||||
required this.id,
|
||||
required this.imageHeight,
|
||||
required this.imageWidth,
|
||||
required this.person,
|
||||
});
|
||||
|
||||
int boundingBoxX1;
|
||||
|
||||
int boundingBoxX2;
|
||||
|
||||
int boundingBoxY1;
|
||||
|
||||
int boundingBoxY2;
|
||||
|
||||
String id;
|
||||
|
||||
int imageHeight;
|
||||
|
||||
int imageWidth;
|
||||
|
||||
PersonResponseDto? person;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetFaceResponseDto &&
|
||||
other.boundingBoxX1 == boundingBoxX1 &&
|
||||
other.boundingBoxX2 == boundingBoxX2 &&
|
||||
other.boundingBoxY1 == boundingBoxY1 &&
|
||||
other.boundingBoxY2 == boundingBoxY2 &&
|
||||
other.id == id &&
|
||||
other.imageHeight == imageHeight &&
|
||||
other.imageWidth == imageWidth &&
|
||||
other.person == person;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(boundingBoxX1.hashCode) +
|
||||
(boundingBoxX2.hashCode) +
|
||||
(boundingBoxY1.hashCode) +
|
||||
(boundingBoxY2.hashCode) +
|
||||
(id.hashCode) +
|
||||
(imageHeight.hashCode) +
|
||||
(imageWidth.hashCode) +
|
||||
(person == null ? 0 : person!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetFaceResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, person=$person]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'boundingBoxX1'] = this.boundingBoxX1;
|
||||
json[r'boundingBoxX2'] = this.boundingBoxX2;
|
||||
json[r'boundingBoxY1'] = this.boundingBoxY1;
|
||||
json[r'boundingBoxY2'] = this.boundingBoxY2;
|
||||
json[r'id'] = this.id;
|
||||
json[r'imageHeight'] = this.imageHeight;
|
||||
json[r'imageWidth'] = this.imageWidth;
|
||||
if (this.person != null) {
|
||||
json[r'person'] = this.person;
|
||||
} else {
|
||||
// json[r'person'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetFaceResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetFaceResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetFaceResponseDto(
|
||||
boundingBoxX1: mapValueOfType<int>(json, r'boundingBoxX1')!,
|
||||
boundingBoxX2: mapValueOfType<int>(json, r'boundingBoxX2')!,
|
||||
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
|
||||
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
|
||||
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
|
||||
person: PersonResponseDto.fromJson(json[r'person']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetFaceResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetFaceResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetFaceResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetFaceResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetFaceResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetFaceResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetFaceResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetFaceResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetFaceResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetFaceResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'boundingBoxX1',
|
||||
'boundingBoxX2',
|
||||
'boundingBoxY1',
|
||||
'boundingBoxY2',
|
||||
'id',
|
||||
'imageHeight',
|
||||
'imageWidth',
|
||||
'person',
|
||||
};
|
||||
}
|
||||
|
98
mobile/openapi/lib/model/asset_face_update_dto.dart
generated
Normal file
98
mobile/openapi/lib/model/asset_face_update_dto.dart
generated
Normal file
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetFaceUpdateDto {
|
||||
/// Returns a new [AssetFaceUpdateDto] instance.
|
||||
AssetFaceUpdateDto({
|
||||
this.data = const [],
|
||||
});
|
||||
|
||||
List<AssetFaceUpdateItem> data;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetFaceUpdateDto &&
|
||||
other.data == data;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(data.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetFaceUpdateDto[data=$data]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'data'] = this.data;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetFaceUpdateDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetFaceUpdateDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetFaceUpdateDto(
|
||||
data: AssetFaceUpdateItem.listFromJson(json[r'data']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetFaceUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetFaceUpdateDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetFaceUpdateDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetFaceUpdateDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetFaceUpdateDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetFaceUpdateDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetFaceUpdateDto-objects as value to a dart map
|
||||
static Map<String, List<AssetFaceUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetFaceUpdateDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetFaceUpdateDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'data',
|
||||
};
|
||||
}
|
||||
|
106
mobile/openapi/lib/model/asset_face_update_item.dart
generated
Normal file
106
mobile/openapi/lib/model/asset_face_update_item.dart
generated
Normal file
|
@ -0,0 +1,106 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetFaceUpdateItem {
|
||||
/// Returns a new [AssetFaceUpdateItem] instance.
|
||||
AssetFaceUpdateItem({
|
||||
required this.assetId,
|
||||
required this.personId,
|
||||
});
|
||||
|
||||
String assetId;
|
||||
|
||||
String personId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetFaceUpdateItem &&
|
||||
other.assetId == assetId &&
|
||||
other.personId == personId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetId.hashCode) +
|
||||
(personId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetFaceUpdateItem[assetId=$assetId, personId=$personId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetId'] = this.assetId;
|
||||
json[r'personId'] = this.personId;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetFaceUpdateItem] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetFaceUpdateItem? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetFaceUpdateItem(
|
||||
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||
personId: mapValueOfType<String>(json, r'personId')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetFaceUpdateItem> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetFaceUpdateItem>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetFaceUpdateItem.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetFaceUpdateItem> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetFaceUpdateItem>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetFaceUpdateItem.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetFaceUpdateItem-objects as value to a dart map
|
||||
static Map<String, List<AssetFaceUpdateItem>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetFaceUpdateItem>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetFaceUpdateItem.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'assetId',
|
||||
'personId',
|
||||
};
|
||||
}
|
||||
|
146
mobile/openapi/lib/model/asset_face_without_person_response_dto.dart
generated
Normal file
146
mobile/openapi/lib/model/asset_face_without_person_response_dto.dart
generated
Normal file
|
@ -0,0 +1,146 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class AssetFaceWithoutPersonResponseDto {
|
||||
/// Returns a new [AssetFaceWithoutPersonResponseDto] instance.
|
||||
AssetFaceWithoutPersonResponseDto({
|
||||
required this.boundingBoxX1,
|
||||
required this.boundingBoxX2,
|
||||
required this.boundingBoxY1,
|
||||
required this.boundingBoxY2,
|
||||
required this.id,
|
||||
required this.imageHeight,
|
||||
required this.imageWidth,
|
||||
});
|
||||
|
||||
int boundingBoxX1;
|
||||
|
||||
int boundingBoxX2;
|
||||
|
||||
int boundingBoxY1;
|
||||
|
||||
int boundingBoxY2;
|
||||
|
||||
String id;
|
||||
|
||||
int imageHeight;
|
||||
|
||||
int imageWidth;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetFaceWithoutPersonResponseDto &&
|
||||
other.boundingBoxX1 == boundingBoxX1 &&
|
||||
other.boundingBoxX2 == boundingBoxX2 &&
|
||||
other.boundingBoxY1 == boundingBoxY1 &&
|
||||
other.boundingBoxY2 == boundingBoxY2 &&
|
||||
other.id == id &&
|
||||
other.imageHeight == imageHeight &&
|
||||
other.imageWidth == imageWidth;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(boundingBoxX1.hashCode) +
|
||||
(boundingBoxX2.hashCode) +
|
||||
(boundingBoxY1.hashCode) +
|
||||
(boundingBoxY2.hashCode) +
|
||||
(id.hashCode) +
|
||||
(imageHeight.hashCode) +
|
||||
(imageWidth.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetFaceWithoutPersonResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'boundingBoxX1'] = this.boundingBoxX1;
|
||||
json[r'boundingBoxX2'] = this.boundingBoxX2;
|
||||
json[r'boundingBoxY1'] = this.boundingBoxY1;
|
||||
json[r'boundingBoxY2'] = this.boundingBoxY2;
|
||||
json[r'id'] = this.id;
|
||||
json[r'imageHeight'] = this.imageHeight;
|
||||
json[r'imageWidth'] = this.imageWidth;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AssetFaceWithoutPersonResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AssetFaceWithoutPersonResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetFaceWithoutPersonResponseDto(
|
||||
boundingBoxX1: mapValueOfType<int>(json, r'boundingBoxX1')!,
|
||||
boundingBoxX2: mapValueOfType<int>(json, r'boundingBoxX2')!,
|
||||
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
|
||||
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
|
||||
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AssetFaceWithoutPersonResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AssetFaceWithoutPersonResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AssetFaceWithoutPersonResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AssetFaceWithoutPersonResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AssetFaceWithoutPersonResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AssetFaceWithoutPersonResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AssetFaceWithoutPersonResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AssetFaceWithoutPersonResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AssetFaceWithoutPersonResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AssetFaceWithoutPersonResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'boundingBoxX1',
|
||||
'boundingBoxX2',
|
||||
'boundingBoxY1',
|
||||
'boundingBoxY2',
|
||||
'id',
|
||||
'imageHeight',
|
||||
'imageWidth',
|
||||
};
|
||||
}
|
||||
|
4
mobile/openapi/lib/model/asset_response_dto.dart
generated
4
mobile/openapi/lib/model/asset_response_dto.dart
generated
|
@ -104,7 +104,7 @@ class AssetResponseDto {
|
|||
|
||||
String ownerId;
|
||||
|
||||
List<PersonResponseDto> people;
|
||||
List<PersonWithFacesResponseDto> people;
|
||||
|
||||
bool resized;
|
||||
|
||||
|
@ -299,7 +299,7 @@ class AssetResponseDto {
|
|||
originalPath: mapValueOfType<String>(json, r'originalPath')!,
|
||||
owner: UserResponseDto.fromJson(json[r'owner']),
|
||||
ownerId: mapValueOfType<String>(json, r'ownerId')!,
|
||||
people: PersonResponseDto.listFromJson(json[r'people']),
|
||||
people: PersonWithFacesResponseDto.listFromJson(json[r'people']),
|
||||
resized: mapValueOfType<bool>(json, r'resized')!,
|
||||
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
|
||||
stack: AssetResponseDto.listFromJson(json[r'stack']),
|
||||
|
|
98
mobile/openapi/lib/model/face_dto.dart
generated
Normal file
98
mobile/openapi/lib/model/face_dto.dart
generated
Normal file
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class FaceDto {
|
||||
/// Returns a new [FaceDto] instance.
|
||||
FaceDto({
|
||||
required this.id,
|
||||
});
|
||||
|
||||
String id;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is FaceDto &&
|
||||
other.id == id;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(id.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'FaceDto[id=$id]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'id'] = this.id;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [FaceDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static FaceDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return FaceDto(
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<FaceDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <FaceDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = FaceDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, FaceDto> mapFromJson(dynamic json) {
|
||||
final map = <String, FaceDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = FaceDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of FaceDto-objects as value to a dart map
|
||||
static Map<String, List<FaceDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<FaceDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = FaceDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'id',
|
||||
};
|
||||
}
|
||||
|
3
mobile/openapi/lib/model/job_command.dart
generated
3
mobile/openapi/lib/model/job_command.dart
generated
|
@ -27,6 +27,7 @@ class JobCommand {
|
|||
static const pause = JobCommand._(r'pause');
|
||||
static const resume = JobCommand._(r'resume');
|
||||
static const empty = JobCommand._(r'empty');
|
||||
static const clearFailed = JobCommand._(r'clear-failed');
|
||||
|
||||
/// List of all possible values in this [enum][JobCommand].
|
||||
static const values = <JobCommand>[
|
||||
|
@ -34,6 +35,7 @@ class JobCommand {
|
|||
pause,
|
||||
resume,
|
||||
empty,
|
||||
clearFailed,
|
||||
];
|
||||
|
||||
static JobCommand? fromJson(dynamic value) => JobCommandTypeTransformer().decode(value);
|
||||
|
@ -76,6 +78,7 @@ class JobCommandTypeTransformer {
|
|||
case r'pause': return JobCommand.pause;
|
||||
case r'resume': return JobCommand.resume;
|
||||
case r'empty': return JobCommand.empty;
|
||||
case r'clear-failed': return JobCommand.clearFailed;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
|
|
142
mobile/openapi/lib/model/person_with_faces_response_dto.dart
generated
Normal file
142
mobile/openapi/lib/model/person_with_faces_response_dto.dart
generated
Normal file
|
@ -0,0 +1,142 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class PersonWithFacesResponseDto {
|
||||
/// Returns a new [PersonWithFacesResponseDto] instance.
|
||||
PersonWithFacesResponseDto({
|
||||
required this.birthDate,
|
||||
this.faces = const [],
|
||||
required this.id,
|
||||
required this.isHidden,
|
||||
required this.name,
|
||||
required this.thumbnailPath,
|
||||
});
|
||||
|
||||
DateTime? birthDate;
|
||||
|
||||
List<AssetFaceWithoutPersonResponseDto> faces;
|
||||
|
||||
String id;
|
||||
|
||||
bool isHidden;
|
||||
|
||||
String name;
|
||||
|
||||
String thumbnailPath;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto &&
|
||||
other.birthDate == birthDate &&
|
||||
other.faces == faces &&
|
||||
other.id == id &&
|
||||
other.isHidden == isHidden &&
|
||||
other.name == name &&
|
||||
other.thumbnailPath == thumbnailPath;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(birthDate == null ? 0 : birthDate!.hashCode) +
|
||||
(faces.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isHidden.hashCode) +
|
||||
(name.hashCode) +
|
||||
(thumbnailPath.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.birthDate != null) {
|
||||
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
|
||||
} else {
|
||||
// json[r'birthDate'] = null;
|
||||
}
|
||||
json[r'faces'] = this.faces;
|
||||
json[r'id'] = this.id;
|
||||
json[r'isHidden'] = this.isHidden;
|
||||
json[r'name'] = this.name;
|
||||
json[r'thumbnailPath'] = this.thumbnailPath;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PersonWithFacesResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PersonWithFacesResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PersonWithFacesResponseDto(
|
||||
birthDate: mapDateTime(json, r'birthDate', ''),
|
||||
faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
|
||||
name: mapValueOfType<String>(json, r'name')!,
|
||||
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PersonWithFacesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PersonWithFacesResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PersonWithFacesResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PersonWithFacesResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PersonWithFacesResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PersonWithFacesResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PersonWithFacesResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PersonWithFacesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PersonWithFacesResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PersonWithFacesResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'birthDate',
|
||||
'faces',
|
||||
'id',
|
||||
'isHidden',
|
||||
'name',
|
||||
'thumbnailPath',
|
||||
};
|
||||
}
|
||||
|
62
mobile/openapi/test/asset_face_response_dto_test.dart
generated
Normal file
62
mobile/openapi/test/asset_face_response_dto_test.dart
generated
Normal file
|
@ -0,0 +1,62 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for AssetFaceResponseDto
|
||||
void main() {
|
||||
// final instance = AssetFaceResponseDto();
|
||||
|
||||
group('test AssetFaceResponseDto', () {
|
||||
// int boundingBoxX1
|
||||
test('to test the property `boundingBoxX1`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int boundingBoxX2
|
||||
test('to test the property `boundingBoxX2`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int boundingBoxY1
|
||||
test('to test the property `boundingBoxY1`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int boundingBoxY2
|
||||
test('to test the property `boundingBoxY2`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String id
|
||||
test('to test the property `id`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int imageHeight
|
||||
test('to test the property `imageHeight`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int imageWidth
|
||||
test('to test the property `imageWidth`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// PersonResponseDto person
|
||||
test('to test the property `person`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
27
mobile/openapi/test/asset_face_update_dto_test.dart
generated
Normal file
27
mobile/openapi/test/asset_face_update_dto_test.dart
generated
Normal file
|
@ -0,0 +1,27 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for AssetFaceUpdateDto
|
||||
void main() {
|
||||
// final instance = AssetFaceUpdateDto();
|
||||
|
||||
group('test AssetFaceUpdateDto', () {
|
||||
// List<AssetFaceUpdateItem> data (default value: const [])
|
||||
test('to test the property `data`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
32
mobile/openapi/test/asset_face_update_item_test.dart
generated
Normal file
32
mobile/openapi/test/asset_face_update_item_test.dart
generated
Normal file
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for AssetFaceUpdateItem
|
||||
void main() {
|
||||
// final instance = AssetFaceUpdateItem();
|
||||
|
||||
group('test AssetFaceUpdateItem', () {
|
||||
// String assetId
|
||||
test('to test the property `assetId`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String personId
|
||||
test('to test the property `personId`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
57
mobile/openapi/test/asset_face_without_person_response_dto_test.dart
generated
Normal file
57
mobile/openapi/test/asset_face_without_person_response_dto_test.dart
generated
Normal file
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for AssetFaceWithoutPersonResponseDto
|
||||
void main() {
|
||||
// final instance = AssetFaceWithoutPersonResponseDto();
|
||||
|
||||
group('test AssetFaceWithoutPersonResponseDto', () {
|
||||
// int boundingBoxX1
|
||||
test('to test the property `boundingBoxX1`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int boundingBoxX2
|
||||
test('to test the property `boundingBoxX2`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int boundingBoxY1
|
||||
test('to test the property `boundingBoxY1`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int boundingBoxY2
|
||||
test('to test the property `boundingBoxY2`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String id
|
||||
test('to test the property `id`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int imageHeight
|
||||
test('to test the property `imageHeight`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int imageWidth
|
||||
test('to test the property `imageWidth`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
2
mobile/openapi/test/asset_response_dto_test.dart
generated
2
mobile/openapi/test/asset_response_dto_test.dart
generated
|
@ -127,7 +127,7 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
// List<PersonResponseDto> people (default value: const [])
|
||||
// List<PersonWithFacesResponseDto> people (default value: const [])
|
||||
test('to test the property `people`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
|
31
mobile/openapi/test/face_api_test.dart
generated
Normal file
31
mobile/openapi/test/face_api_test.dart
generated
Normal file
|
@ -0,0 +1,31 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
|
||||
/// tests for FaceApi
|
||||
void main() {
|
||||
// final instance = FaceApi();
|
||||
|
||||
group('tests for FaceApi', () {
|
||||
//Future<List<AssetFaceResponseDto>> getFaces(String id) async
|
||||
test('test getFaces', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<PersonResponseDto> reassignFacesById(String id, FaceDto faceDto) async
|
||||
test('test reassignFacesById', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
});
|
||||
}
|
27
mobile/openapi/test/face_dto_test.dart
generated
Normal file
27
mobile/openapi/test/face_dto_test.dart
generated
Normal file
|
@ -0,0 +1,27 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for FaceDto
|
||||
void main() {
|
||||
// final instance = FaceDto();
|
||||
|
||||
group('test FaceDto', () {
|
||||
// String id
|
||||
test('to test the property `id`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
10
mobile/openapi/test/person_api_test.dart
generated
10
mobile/openapi/test/person_api_test.dart
generated
|
@ -17,6 +17,11 @@ void main() {
|
|||
// final instance = PersonApi();
|
||||
|
||||
group('tests for PersonApi', () {
|
||||
//Future<PersonResponseDto> createPerson() async
|
||||
test('test createPerson', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<PeopleResponseDto> getAllPeople({ bool withHidden }) async
|
||||
test('test getAllPeople', () async {
|
||||
// TODO
|
||||
|
@ -47,6 +52,11 @@ void main() {
|
|||
// TODO
|
||||
});
|
||||
|
||||
//Future<List<PersonResponseDto>> reassignFaces(String id, AssetFaceUpdateDto assetFaceUpdateDto) async
|
||||
test('test reassignFaces', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<List<BulkIdResponseDto>> updatePeople(PeopleUpdateDto peopleUpdateDto) async
|
||||
test('test updatePeople', () async {
|
||||
// TODO
|
||||
|
|
52
mobile/openapi/test/person_with_faces_response_dto_test.dart
generated
Normal file
52
mobile/openapi/test/person_with_faces_response_dto_test.dart
generated
Normal file
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for PersonWithFacesResponseDto
|
||||
void main() {
|
||||
// final instance = PersonWithFacesResponseDto();
|
||||
|
||||
group('test PersonWithFacesResponseDto', () {
|
||||
// DateTime birthDate
|
||||
test('to test the property `birthDate`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// List<AssetFaceWithoutPersonResponseDto> faces (default value: const [])
|
||||
test('to test the property `faces`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String id
|
||||
test('to test the property `id`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool isHidden
|
||||
test('to test the property `isHidden`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String name
|
||||
test('to test the property `name`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String thumbnailPath
|
||||
test('to test the property `thumbnailPath`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
131
mobile/test/asset_extensions_test.dart
Normal file
131
mobile/test/asset_extensions_test.dart
Normal file
|
@ -0,0 +1,131 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/exif_info.dart';
|
||||
import 'package:timezone/data/latest.dart';
|
||||
import 'package:timezone/timezone.dart';
|
||||
|
||||
ExifInfo makeExif({
|
||||
DateTime? dateTimeOriginal,
|
||||
String? timeZone,
|
||||
}) {
|
||||
return ExifInfo(
|
||||
dateTimeOriginal: dateTimeOriginal,
|
||||
timeZone: timeZone,
|
||||
);
|
||||
}
|
||||
|
||||
Asset makeAsset({
|
||||
required String id,
|
||||
required DateTime createdAt,
|
||||
ExifInfo? exifInfo,
|
||||
}) {
|
||||
return Asset(
|
||||
checksum: '',
|
||||
localId: id,
|
||||
remoteId: id,
|
||||
ownerId: 1,
|
||||
fileCreatedAt: createdAt,
|
||||
fileModifiedAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
durationInSeconds: 0,
|
||||
type: AssetType.image,
|
||||
fileName: id,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
stackCount: 0,
|
||||
exifInfo: exifInfo,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Init Timezone DB
|
||||
initializeTimeZones();
|
||||
|
||||
group("Returns local time and offset if no exifInfo", () {
|
||||
test('returns createdAt directly if in local', () {
|
||||
final createdAt = DateTime(2023, 12, 12, 12, 12, 12);
|
||||
final a = makeAsset(id: '1', createdAt: createdAt);
|
||||
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||
|
||||
expect(dt, createdAt);
|
||||
expect(tz, createdAt.timeZoneOffset);
|
||||
});
|
||||
|
||||
test('returns createdAt in local if in utc', () {
|
||||
final createdAt = DateTime.utc(2023, 12, 12, 12, 12, 12);
|
||||
final a = makeAsset(id: '1', createdAt: createdAt);
|
||||
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||
|
||||
final localCreatedAt = createdAt.toLocal();
|
||||
expect(dt, localCreatedAt);
|
||||
expect(tz, localCreatedAt.timeZoneOffset);
|
||||
});
|
||||
});
|
||||
|
||||
group("Returns dateTimeOriginal", () {
|
||||
test('Returns dateTimeOriginal in UTC from exifInfo without timezone', () {
|
||||
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
|
||||
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
|
||||
final e = makeExif(dateTimeOriginal: dateTimeOriginal);
|
||||
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
|
||||
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||
|
||||
final dateTimeInUTC = dateTimeOriginal.toUtc();
|
||||
expect(dt, dateTimeInUTC);
|
||||
expect(tz, dateTimeInUTC.timeZoneOffset);
|
||||
});
|
||||
|
||||
test('Returns dateTimeOriginal in UTC from exifInfo with invalid timezone',
|
||||
() {
|
||||
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
|
||||
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
|
||||
final e = makeExif(
|
||||
dateTimeOriginal: dateTimeOriginal,
|
||||
timeZone: "#_#",
|
||||
); // Invalid timezone
|
||||
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
|
||||
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||
|
||||
final dateTimeInUTC = dateTimeOriginal.toUtc();
|
||||
expect(dt, dateTimeInUTC);
|
||||
expect(tz, dateTimeInUTC.timeZoneOffset);
|
||||
});
|
||||
});
|
||||
|
||||
group("Returns adjusted time if timezone available", () {
|
||||
test('With timezone as location', () {
|
||||
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
|
||||
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
|
||||
const location = "Asia/Hong_Kong";
|
||||
final e =
|
||||
makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: location);
|
||||
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
|
||||
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||
|
||||
final adjustedTime =
|
||||
TZDateTime.from(dateTimeOriginal.toUtc(), getLocation(location));
|
||||
expect(dt, adjustedTime);
|
||||
expect(tz, adjustedTime.timeZoneOffset);
|
||||
});
|
||||
|
||||
test('With timezone as offset', () {
|
||||
final createdAt = DateTime.parse("2023-01-27T14:00:00-0500");
|
||||
final dateTimeOriginal = DateTime.parse("2022-01-27T14:00:00+0530");
|
||||
const offset = "utc+08:00";
|
||||
final e = makeExif(dateTimeOriginal: dateTimeOriginal, timeZone: offset);
|
||||
final a = makeAsset(id: '1', createdAt: createdAt, exifInfo: e);
|
||||
final (dt, tz) = a.getTZAdjustedTimeAndOffset();
|
||||
|
||||
final location = getLocation("Asia/Hong_Kong");
|
||||
final offsetFromLocation =
|
||||
Duration(milliseconds: location.currentTimeZone.offset);
|
||||
final adjustedTime = dateTimeOriginal.toUtc().add(offsetFromLocation);
|
||||
|
||||
// Adds the offset to the actual time and returns the offset separately
|
||||
expect(dt, adjustedTime);
|
||||
expect(tz, offsetFromLocation);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -52,7 +52,8 @@
|
|||
},
|
||||
{
|
||||
"groupName": "base-image",
|
||||
"matchPackagePrefixes": ["ghcr.io/immich-app/base-server"]
|
||||
"matchPackagePrefixes": ["ghcr.io/immich-app/base-server"],
|
||||
"minimumReleaseAge": "0"
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["docker"],
|
||||
|
@ -61,6 +62,9 @@
|
|||
"versioning": "node"
|
||||
}
|
||||
],
|
||||
"ignorePaths": [
|
||||
"mobile/openapi/pubspec.yaml"
|
||||
],
|
||||
"ignoreDeps": [
|
||||
"http",
|
||||
"latlong2",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:20231125@sha256:f33b6eaf384e76ef3705a6e2cc76d276144ad6d3366b82f9b45b07d6a19285e2 as dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:20231201@sha256:4701c0c5920c78e73040dd2b74d22042ffce393f1a9d3453d90a0ecf81ff8649 as dev
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
|
@ -23,7 +23,7 @@ RUN npm run build
|
|||
|
||||
|
||||
# prod build
|
||||
FROM ghcr.io/immich-app/base-server-prod:20231125@sha256:a0e15f5bf87a97a79a399a5adffb5fe5befc18fb212e8341e744d958fe41e32a
|
||||
FROM ghcr.io/immich-app/base-server-prod:20231201@sha256:b8e86cf4c3cad872f54bab25a83f7503480049eea5c0ae36a8b8460b13cad3b5
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production
|
||||
|
|
|
@ -3220,6 +3220,103 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/face": {
|
||||
"get": {
|
||||
"operationId": "getFaces",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetFaceResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Face"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/face/{id}": {
|
||||
"put": {
|
||||
"operationId": "reassignFacesById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FaceDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PersonResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Face"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/jobs": {
|
||||
"get": {
|
||||
"operationId": "getAllJobsStatus",
|
||||
|
@ -4022,6 +4119,36 @@
|
|||
"Person"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createPerson",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PersonResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Person"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "updatePeople",
|
||||
"parameters": [],
|
||||
|
@ -4258,6 +4385,61 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/person/{id}/reassign": {
|
||||
"put": {
|
||||
"operationId": "reassignFaces",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AssetFaceUpdateDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PersonResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Person"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/person/{id}/statistics": {
|
||||
"get": {
|
||||
"operationId": "getPersonStatistics",
|
||||
|
@ -6557,6 +6739,118 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetFaceResponseDto": {
|
||||
"properties": {
|
||||
"boundingBoxX1": {
|
||||
"type": "integer"
|
||||
},
|
||||
"boundingBoxX2": {
|
||||
"type": "integer"
|
||||
},
|
||||
"boundingBoxY1": {
|
||||
"type": "integer"
|
||||
},
|
||||
"boundingBoxY2": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"imageHeight": {
|
||||
"type": "integer"
|
||||
},
|
||||
"imageWidth": {
|
||||
"type": "integer"
|
||||
},
|
||||
"person": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/PersonResponseDto"
|
||||
}
|
||||
],
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"imageHeight",
|
||||
"imageWidth",
|
||||
"boundingBoxX1",
|
||||
"boundingBoxX2",
|
||||
"boundingBoxY1",
|
||||
"boundingBoxY2",
|
||||
"person"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetFaceUpdateDto": {
|
||||
"properties": {
|
||||
"data": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetFaceUpdateItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetFaceUpdateItem": {
|
||||
"properties": {
|
||||
"assetId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"personId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"personId",
|
||||
"assetId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetFaceWithoutPersonResponseDto": {
|
||||
"properties": {
|
||||
"boundingBoxX1": {
|
||||
"type": "integer"
|
||||
},
|
||||
"boundingBoxX2": {
|
||||
"type": "integer"
|
||||
},
|
||||
"boundingBoxY1": {
|
||||
"type": "integer"
|
||||
},
|
||||
"boundingBoxY2": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"imageHeight": {
|
||||
"type": "integer"
|
||||
},
|
||||
"imageWidth": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"imageHeight",
|
||||
"imageWidth",
|
||||
"boundingBoxX1",
|
||||
"boundingBoxX2",
|
||||
"boundingBoxY1",
|
||||
"boundingBoxY2"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AssetFileUploadResponseDto": {
|
||||
"properties": {
|
||||
"duplicate": {
|
||||
|
@ -6719,7 +7013,7 @@
|
|||
},
|
||||
"people": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PersonResponseDto"
|
||||
"$ref": "#/components/schemas/PersonWithFacesResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
|
@ -7452,6 +7746,18 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FaceDto": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileChecksumDto": {
|
||||
"properties": {
|
||||
"filenames": {
|
||||
|
@ -7548,7 +7854,8 @@
|
|||
"start",
|
||||
"pause",
|
||||
"resume",
|
||||
"empty"
|
||||
"empty",
|
||||
"clear-failed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
@ -8146,6 +8453,42 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PersonWithFacesResponseDto": {
|
||||
"properties": {
|
||||
"birthDate": {
|
||||
"format": "date",
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"faces": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"isHidden": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"thumbnailPath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"birthDate",
|
||||
"faces",
|
||||
"id",
|
||||
"name",
|
||||
"thumbnailPath",
|
||||
"isHidden"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"QueueStatusDto": {
|
||||
"properties": {
|
||||
"isActive": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { setDifference, setUnion } from '../domain.util';
|
||||
import { setDifference, setIsEqual, setUnion } from '../domain.util';
|
||||
import { IAccessRepository } from '../repositories';
|
||||
|
||||
export enum Permission {
|
||||
|
@ -41,6 +41,8 @@ export enum Permission {
|
|||
PERSON_READ = 'person.read',
|
||||
PERSON_WRITE = 'person.write',
|
||||
PERSON_MERGE = 'person.merge',
|
||||
PERSON_CREATE = 'person.create',
|
||||
PERSON_REASSIGN = 'person.reassign',
|
||||
|
||||
PARTNER_UPDATE = 'partner.update',
|
||||
}
|
||||
|
@ -76,7 +78,7 @@ export class AccessCore {
|
|||
async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
|
||||
ids = Array.isArray(ids) ? ids : [ids];
|
||||
const allowedIds = await this.checkAccess(authUser, permission, ids);
|
||||
if (new Set(ids).size !== allowedIds.size) {
|
||||
if (!setIsEqual(new Set(ids), allowedIds)) {
|
||||
throw new BadRequestException(`Not found or no ${permission} access`);
|
||||
}
|
||||
}
|
||||
|
@ -106,9 +108,24 @@ export class AccessCore {
|
|||
}
|
||||
|
||||
switch (permission) {
|
||||
case Permission.ASSET_READ:
|
||||
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
|
||||
|
||||
case Permission.ASSET_VIEW:
|
||||
return await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids);
|
||||
|
||||
case Permission.ASSET_DOWNLOAD:
|
||||
return !!authUser.isAllowDownload
|
||||
? await this.repository.asset.checkSharedLinkAccess(sharedLinkId, ids)
|
||||
: new Set();
|
||||
|
||||
case Permission.ASSET_UPLOAD:
|
||||
return authUser.isAllowUpload ? ids : new Set();
|
||||
|
||||
case Permission.ASSET_SHARE:
|
||||
// TODO: fix this to not use authUser.id for shared link access control
|
||||
return await this.repository.asset.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ALBUM_READ:
|
||||
return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids);
|
||||
|
||||
|
@ -116,46 +133,59 @@ export class AccessCore {
|
|||
return !!authUser.isAllowDownload
|
||||
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
|
||||
: new Set();
|
||||
}
|
||||
|
||||
const allowedIds = new Set();
|
||||
for (const id of ids) {
|
||||
const hasAccess = await this.hasSharedLinkAccess(authUser, permission, id);
|
||||
if (hasAccess) {
|
||||
allowedIds.add(id);
|
||||
}
|
||||
}
|
||||
return allowedIds;
|
||||
}
|
||||
|
||||
// TODO: Migrate logic to checkAccessSharedLink to evaluate permissions in bulk.
|
||||
private async hasSharedLinkAccess(authUser: AuthUserDto, permission: Permission, id: string) {
|
||||
const sharedLinkId = authUser.sharedLinkId;
|
||||
if (!sharedLinkId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (permission) {
|
||||
case Permission.ASSET_READ:
|
||||
return this.repository.asset.hasSharedLinkAccess(sharedLinkId, id);
|
||||
|
||||
case Permission.ASSET_VIEW:
|
||||
return await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id);
|
||||
|
||||
case Permission.ASSET_DOWNLOAD:
|
||||
return !!authUser.isAllowDownload && (await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id));
|
||||
|
||||
case Permission.ASSET_SHARE:
|
||||
// TODO: fix this to not use authUser.id for shared link access control
|
||||
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
default:
|
||||
return false;
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAccessOther(authUser: AuthUserDto, permission: Permission, ids: Set<string>) {
|
||||
switch (permission) {
|
||||
case Permission.ASSET_READ: {
|
||||
const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids);
|
||||
const isAlbum = await this.repository.asset.checkAlbumAccess(authUser.id, setDifference(ids, isOwner));
|
||||
const isPartner = await this.repository.asset.checkPartnerAccess(
|
||||
authUser.id,
|
||||
setDifference(ids, isOwner, isAlbum),
|
||||
);
|
||||
return setUnion(isOwner, isAlbum, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_SHARE: {
|
||||
const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids);
|
||||
const isPartner = await this.repository.asset.checkPartnerAccess(authUser.id, setDifference(ids, isOwner));
|
||||
return setUnion(isOwner, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_VIEW: {
|
||||
const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids);
|
||||
const isAlbum = await this.repository.asset.checkAlbumAccess(authUser.id, setDifference(ids, isOwner));
|
||||
const isPartner = await this.repository.asset.checkPartnerAccess(
|
||||
authUser.id,
|
||||
setDifference(ids, isOwner, isAlbum),
|
||||
);
|
||||
return setUnion(isOwner, isAlbum, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_DOWNLOAD: {
|
||||
const isOwner = await this.repository.asset.checkOwnerAccess(authUser.id, ids);
|
||||
const isAlbum = await this.repository.asset.checkAlbumAccess(authUser.id, setDifference(ids, isOwner));
|
||||
const isPartner = await this.repository.asset.checkPartnerAccess(
|
||||
authUser.id,
|
||||
setDifference(ids, isOwner, isAlbum),
|
||||
);
|
||||
return setUnion(isOwner, isAlbum, isPartner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_UPDATE:
|
||||
return await this.repository.asset.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ASSET_DELETE:
|
||||
return await this.repository.asset.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ASSET_RESTORE:
|
||||
return await this.repository.asset.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ALBUM_READ: {
|
||||
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner));
|
||||
|
@ -163,13 +193,13 @@ export class AccessCore {
|
|||
}
|
||||
|
||||
case Permission.ALBUM_UPDATE:
|
||||
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ALBUM_DELETE:
|
||||
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ALBUM_SHARE:
|
||||
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ALBUM_DOWNLOAD: {
|
||||
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
|
@ -178,16 +208,16 @@ export class AccessCore {
|
|||
}
|
||||
|
||||
case Permission.ALBUM_REMOVE_ASSET:
|
||||
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ASSET_UPLOAD:
|
||||
return this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.ARCHIVE_READ:
|
||||
return ids.has(authUser.id) ? new Set([authUser.id]) : new Set();
|
||||
|
||||
case Permission.AUTH_DEVICE_DELETE:
|
||||
return this.repository.authDevice.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.authDevice.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.TIMELINE_READ: {
|
||||
const isOwner = ids.has(authUser.id) ? new Set([authUser.id]) : new Set<string>();
|
||||
|
@ -205,22 +235,28 @@ export class AccessCore {
|
|||
}
|
||||
|
||||
case Permission.LIBRARY_UPDATE:
|
||||
return this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.LIBRARY_DELETE:
|
||||
return this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.library.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PERSON_READ:
|
||||
return this.repository.person.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.person.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PERSON_WRITE:
|
||||
return this.repository.person.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.person.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PERSON_MERGE:
|
||||
return this.repository.person.checkOwnerAccess(authUser.id, ids);
|
||||
return await this.repository.person.checkOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PERSON_CREATE:
|
||||
return this.repository.person.hasFaceOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PERSON_REASSIGN:
|
||||
return this.repository.person.hasFaceOwnerAccess(authUser.id, ids);
|
||||
|
||||
case Permission.PARTNER_UPDATE:
|
||||
return this.repository.partner.checkUpdateAccess(authUser.id, ids);
|
||||
return await this.repository.partner.checkUpdateAccess(authUser.id, ids);
|
||||
}
|
||||
|
||||
const allowedIds = new Set();
|
||||
|
@ -247,41 +283,6 @@ export class AccessCore {
|
|||
(await this.repository.activity.hasAlbumOwnerAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ASSET_READ:
|
||||
return (
|
||||
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
|
||||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
||||
);
|
||||
case Permission.ASSET_UPDATE:
|
||||
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ASSET_DELETE:
|
||||
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ASSET_RESTORE:
|
||||
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
||||
|
||||
case Permission.ASSET_SHARE:
|
||||
return (
|
||||
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ASSET_VIEW:
|
||||
return (
|
||||
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
|
||||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
case Permission.ASSET_DOWNLOAD:
|
||||
return (
|
||||
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
|
||||
(await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
|
||||
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
||||
);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -509,7 +509,7 @@ describe(AlbumService.name, () => {
|
|||
describe('addAssets', () => {
|
||||
it('should allow the owner to add assets', async () => {
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
|
@ -534,7 +534,7 @@ describe(AlbumService.name, () => {
|
|||
|
||||
it('should not set the thumbnail if the album has one already', async () => {
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
|
@ -552,7 +552,7 @@ describe(AlbumService.name, () => {
|
|||
|
||||
it('should allow a shared user to add assets', async () => {
|
||||
accessMock.album.checkSharedAlbumAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
|
@ -577,7 +577,7 @@ describe(AlbumService.name, () => {
|
|||
|
||||
it('should allow a shared link user to add assets', async () => {
|
||||
accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
|
@ -607,8 +607,7 @@ describe(AlbumService.name, () => {
|
|||
|
||||
it('should allow adding assets shared via partner sharing', async () => {
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
|
@ -621,12 +620,12 @@ describe(AlbumService.name, () => {
|
|||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should skip duplicate assets', async () => {
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-id']));
|
||||
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset));
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id']));
|
||||
|
||||
|
@ -639,8 +638,6 @@ describe(AlbumService.name, () => {
|
|||
|
||||
it('should skip assets not shared with user', async () => {
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
|
||||
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
|
||||
albumMock.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
|
@ -648,8 +645,8 @@ describe(AlbumService.name, () => {
|
|||
{ success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION },
|
||||
]);
|
||||
|
||||
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should not allow unauthorized access to the album', async () => {
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsString } from 'class-validator';
|
||||
import { Optional, ValidateUUID } from '../../domain.util';
|
||||
|
||||
export class CreateAlbumDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
albumName!: string;
|
||||
|
|
|
@ -457,19 +457,15 @@ describe(AssetService.name, () => {
|
|||
|
||||
describe('downloadFile', () => {
|
||||
it('should require the asset.download permission', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasAlbumAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not found', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getByIds.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
@ -480,7 +476,7 @@ describe(AssetService.name, () => {
|
|||
it('should download a file', async () => {
|
||||
const stream = new Readable();
|
||||
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
storageMock.createReadStream.mockResolvedValue({ stream });
|
||||
|
||||
|
@ -496,7 +492,7 @@ describe(AssetService.name, () => {
|
|||
stream: new Readable(),
|
||||
};
|
||||
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noWebpPath]);
|
||||
storageMock.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
|
@ -516,7 +512,7 @@ describe(AssetService.name, () => {
|
|||
stream: new Readable(),
|
||||
};
|
||||
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.noResizePath]);
|
||||
storageMock.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
|
@ -536,7 +532,7 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should return a list of archives (assetIds)', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.video]);
|
||||
|
||||
const assetIds = ['asset-1', 'asset-2'];
|
||||
|
@ -602,7 +598,9 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should include the video portion of a live photo', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
const assetIds = [assetStub.livePhotoStillAsset.id];
|
||||
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds));
|
||||
when(assetMock.getByIds)
|
||||
.calledWith([assetStub.livePhotoStillAsset.id])
|
||||
.mockResolvedValue([assetStub.livePhotoStillAsset]);
|
||||
|
@ -610,7 +608,6 @@ describe(AssetService.name, () => {
|
|||
.calledWith([assetStub.livePhotoMotionAsset.id])
|
||||
.mockResolvedValue([assetStub.livePhotoMotionAsset]);
|
||||
|
||||
const assetIds = [assetStub.livePhotoStillAsset.id];
|
||||
await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({
|
||||
totalSize: 125_000,
|
||||
archives: [
|
||||
|
@ -651,7 +648,6 @@ describe(AssetService.name, () => {
|
|||
|
||||
describe('update', () => {
|
||||
it('should require asset write access for the id', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
@ -659,14 +655,14 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should update the asset', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.save.mockResolvedValue(assetStub.image);
|
||||
await sut.update(authStub.admin, 'asset-1', { isFavorite: true });
|
||||
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
|
||||
});
|
||||
|
||||
it('should update the exif description', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.save.mockResolvedValue(assetStub.image);
|
||||
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
|
||||
expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
|
||||
|
@ -675,7 +671,6 @@ describe(AssetService.name, () => {
|
|||
|
||||
describe('updateAll', () => {
|
||||
it('should require asset write access for all ids', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.updateAll(authStub.admin, {
|
||||
ids: ['asset-1'],
|
||||
|
@ -685,7 +680,7 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should update all assets', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
|
||||
});
|
||||
|
@ -693,8 +688,7 @@ describe(AssetService.name, () => {
|
|||
/// Stack related
|
||||
|
||||
it('should require asset update access for parent', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await expect(
|
||||
sut.updateAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
|
@ -704,7 +698,7 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should update parent asset when children are added', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['parent']));
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: [],
|
||||
stackParentId: 'parent',
|
||||
|
@ -713,7 +707,7 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should update parent asset when children are removed', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1']));
|
||||
assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]);
|
||||
|
||||
await sut.updateAll(authStub.user1, {
|
||||
|
@ -724,7 +718,8 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('update parentId for new children', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1', 'child-2']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent']));
|
||||
await sut.updateAll(authStub.user1, {
|
||||
stackParentId: 'parent',
|
||||
ids: ['child-1', 'child-2'],
|
||||
|
@ -734,7 +729,7 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('nullify parentId for remove children', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['child-1', 'child-2']));
|
||||
await sut.updateAll(authStub.user1, {
|
||||
removeParent: true,
|
||||
ids: ['child-1', 'child-2'],
|
||||
|
@ -744,7 +739,8 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('merge stacks if new child has children', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['child-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent']));
|
||||
assetMock.getByIds.mockResolvedValue([
|
||||
{ id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity,
|
||||
]);
|
||||
|
@ -758,7 +754,9 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should send ws asset update event', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['asset-1']));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['parent']));
|
||||
|
||||
await sut.updateAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
stackParentId: 'parent',
|
||||
|
@ -772,7 +770,6 @@ describe(AssetService.name, () => {
|
|||
|
||||
describe('deleteAll', () => {
|
||||
it('should require asset delete access for all ids', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.deleteAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
|
@ -781,7 +778,7 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should force delete a batch of assets', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
|
||||
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
|
||||
|
||||
|
@ -792,7 +789,7 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should soft delete a batch of assets', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
|
||||
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false });
|
||||
|
||||
|
@ -810,7 +807,6 @@ describe(AssetService.name, () => {
|
|||
|
||||
describe('restoreAll', () => {
|
||||
it('should require asset restore access for all ids', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.deleteAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
|
@ -819,7 +815,7 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should restore a batch of assets', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
|
||||
await sut.restoreAll(authStub.user1, { ids: ['asset1', 'asset2'] });
|
||||
|
||||
|
@ -984,19 +980,19 @@ describe(AssetService.name, () => {
|
|||
|
||||
describe('run', () => {
|
||||
it('should run the refresh metadata job', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }),
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } });
|
||||
});
|
||||
|
||||
it('should run the refresh thumbnails job', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }),
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } });
|
||||
});
|
||||
|
||||
it('should run the transcode video', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }),
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } });
|
||||
});
|
||||
|
@ -1004,9 +1000,7 @@ describe(AssetService.name, () => {
|
|||
|
||||
describe('updateStackParent', () => {
|
||||
it('should require asset update access for new parent', async () => {
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true);
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['old']));
|
||||
await expect(
|
||||
sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: 'old',
|
||||
|
@ -1016,8 +1010,7 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('should require asset read access for old parent', async () => {
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false);
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['new']));
|
||||
await expect(
|
||||
sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: 'old',
|
||||
|
@ -1027,7 +1020,9 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('make old parent the child of new parent', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.image.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new']));
|
||||
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.image.id)
|
||||
.mockResolvedValue(assetStub.image as AssetEntity);
|
||||
|
@ -1041,7 +1036,9 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('remove stackParentId of new parent', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.primaryImage.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new']));
|
||||
|
||||
await sut.updateStackParent(authStub.user1, {
|
||||
oldParentId: assetStub.primaryImage.id,
|
||||
newParentId: 'new',
|
||||
|
@ -1051,7 +1048,8 @@ describe(AssetService.name, () => {
|
|||
});
|
||||
|
||||
it('update stackParentId of old parents children to new parent', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set([assetStub.primaryImage.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValueOnce(new Set(['new']));
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.primaryImage.id)
|
||||
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PersonResponseDto, mapFace } from '../../person/person.dto';
|
||||
import { PersonWithFacesResponseDto } from '../../person/person.dto';
|
||||
import { TagResponseDto, mapTag } from '../../tag';
|
||||
import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto';
|
||||
import { ExifResponseDto, mapExif } from './exif-response.dto';
|
||||
|
@ -39,7 +39,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|||
exifInfo?: ExifResponseDto;
|
||||
smartInfo?: SmartInfoResponseDto;
|
||||
tags?: TagResponseDto[];
|
||||
people?: PersonResponseDto[];
|
||||
people?: PersonWithFacesResponseDto[];
|
||||
/**base64 encoded sha1 hash */
|
||||
checksum!: string;
|
||||
stackParentId?: string | null;
|
||||
|
@ -53,6 +53,24 @@ export type AssetMapOptions = {
|
|||
withStack?: boolean;
|
||||
};
|
||||
|
||||
const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => {
|
||||
const result: PersonWithFacesResponseDto[] = [];
|
||||
if (faces) {
|
||||
faces.forEach((face) => {
|
||||
if (face.person) {
|
||||
const existingPersonEntry = result.find((item) => item.id === face.person!.id);
|
||||
if (existingPersonEntry) {
|
||||
existingPersonEntry.faces.push(face);
|
||||
} else {
|
||||
result.push({ ...face.person!, faces: [face] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto {
|
||||
const { stripMetadata = false, withStack = false } = options;
|
||||
|
||||
|
@ -96,16 +114,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
|
|||
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map(mapTag),
|
||||
people: entity.faces
|
||||
?.map(mapFace)
|
||||
.filter((person): person is PersonResponseDto => person !== null && !person.isHidden)
|
||||
.reduce((people, person) => {
|
||||
const existingPerson = people.find((p) => p.id === person.id);
|
||||
if (!existingPerson) {
|
||||
people.push(person);
|
||||
}
|
||||
return people;
|
||||
}, [] as PersonResponseDto[]),
|
||||
people: peopleWithFaces(entity.faces),
|
||||
checksum: entity.checksum.toString('base64'),
|
||||
stackParentId: entity.stackParentId,
|
||||
stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,
|
||||
|
|
|
@ -155,18 +155,35 @@ export function Optional({ nullable, ...validationOptions }: OptionalOptions = {
|
|||
// They should be replaced with native Set operations, when they are added to the language.
|
||||
// Proposal reference: https://github.com/tc39/proposal-set-methods
|
||||
|
||||
export const setUnion = <T>(setA: Set<T>, setB: Set<T>): Set<T> => {
|
||||
const union = new Set(setA);
|
||||
for (const elem of setB) {
|
||||
union.add(elem);
|
||||
export const setUnion = <T>(...sets: Set<T>[]): Set<T> => {
|
||||
const union = new Set(sets[0]);
|
||||
for (const set of sets.slice(1)) {
|
||||
for (const elem of set) {
|
||||
union.add(elem);
|
||||
}
|
||||
}
|
||||
return union;
|
||||
};
|
||||
|
||||
export const setDifference = <T>(setA: Set<T>, setB: Set<T>): Set<T> => {
|
||||
export const setDifference = <T>(setA: Set<T>, ...sets: Set<T>[]): Set<T> => {
|
||||
const difference = new Set(setA);
|
||||
for (const elem of setB) {
|
||||
difference.delete(elem);
|
||||
for (const set of sets) {
|
||||
for (const elem of set) {
|
||||
difference.delete(elem);
|
||||
}
|
||||
}
|
||||
return difference;
|
||||
};
|
||||
|
||||
export const setIsSuperset = <T>(set: Set<T>, subset: Set<T>): boolean => {
|
||||
for (const elem of subset) {
|
||||
if (!set.has(elem)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const setIsEqual = <T>(setA: Set<T>, setB: Set<T>): boolean => {
|
||||
return setA.size === setB.size && setIsSuperset(setA, setB);
|
||||
};
|
||||
|
|
|
@ -18,6 +18,7 @@ export enum JobCommand {
|
|||
PAUSE = 'pause',
|
||||
RESUME = 'resume',
|
||||
EMPTY = 'empty',
|
||||
CLEAR_FAILED = 'clear-failed',
|
||||
}
|
||||
|
||||
export enum JobName {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
ISystemConfigRepository,
|
||||
JobHandler,
|
||||
JobItem,
|
||||
QueueCleanType,
|
||||
} from '../repositories';
|
||||
import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { JobCommand, JobName, QueueName } from './job.constants';
|
||||
|
@ -49,6 +50,11 @@ export class JobService {
|
|||
case JobCommand.EMPTY:
|
||||
await this.jobRepository.empty(queueName);
|
||||
break;
|
||||
|
||||
case JobCommand.CLEAR_FAILED:
|
||||
const failedJobs = await this.jobRepository.clear(queueName, QueueCleanType.FAILED);
|
||||
this.logger.debug(`Cleared failed jobs: ${failedJobs}`);
|
||||
break;
|
||||
}
|
||||
|
||||
return this.getJobStatus(queueName);
|
||||
|
@ -195,7 +201,7 @@ export class JobService {
|
|||
const { id } = item.data;
|
||||
const person = await this.personRepository.getById(id);
|
||||
if (person) {
|
||||
this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, id);
|
||||
this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, person.id);
|
||||
}
|
||||
break;
|
||||
|
||||
|
@ -223,7 +229,9 @@ export class JobService {
|
|||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([item.data.id]);
|
||||
if (asset) {
|
||||
|
||||
// Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients
|
||||
if (asset && asset.isVisible) {
|
||||
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,7 +58,8 @@ describe(LibraryService.name, () => {
|
|||
ctime: new Date('2023-01-01'),
|
||||
} as Stats);
|
||||
|
||||
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id]));
|
||||
// Always validate owner access for library.
|
||||
accessMock.library.checkOwnerAccess.mockImplementation(async (_, libraryIds) => libraryIds);
|
||||
|
||||
sut = new LibraryService(
|
||||
accessMock,
|
||||
|
|
|
@ -3,13 +3,16 @@ import {
|
|||
assetStub,
|
||||
newAlbumRepositoryMock,
|
||||
newAssetRepositoryMock,
|
||||
newCommunicationRepositoryMock,
|
||||
newCryptoRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newMediaRepositoryMock,
|
||||
newMetadataRepositoryMock,
|
||||
newMoveRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
probeStub,
|
||||
} from '@test';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Stats } from 'fs';
|
||||
|
@ -17,10 +20,13 @@ import { constants } from 'fs/promises';
|
|||
import { when } from 'jest-when';
|
||||
import { JobName } from '../job';
|
||||
import {
|
||||
CommunicationEvent,
|
||||
IAlbumRepository,
|
||||
IAssetRepository,
|
||||
ICommunicationRepository,
|
||||
ICryptoRepository,
|
||||
IJobRepository,
|
||||
IMediaRepository,
|
||||
IMetadataRepository,
|
||||
IMoveRepository,
|
||||
IPersonRepository,
|
||||
|
@ -30,7 +36,7 @@ import {
|
|||
WithProperty,
|
||||
WithoutProperty,
|
||||
} from '../repositories';
|
||||
import { MetadataService } from './metadata.service';
|
||||
import { MetadataService, Orientation } from './metadata.service';
|
||||
|
||||
describe(MetadataService.name, () => {
|
||||
let albumMock: jest.Mocked<IAlbumRepository>;
|
||||
|
@ -40,8 +46,10 @@ describe(MetadataService.name, () => {
|
|||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let metadataMock: jest.Mocked<IMetadataRepository>;
|
||||
let moveMock: jest.Mocked<IMoveRepository>;
|
||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let sut: MetadataService;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
@ -53,7 +61,9 @@ describe(MetadataService.name, () => {
|
|||
metadataMock = newMetadataRepositoryMock();
|
||||
moveMock = newMoveRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
|
||||
sut = new MetadataService(
|
||||
albumMock,
|
||||
|
@ -63,7 +73,9 @@ describe(MetadataService.name, () => {
|
|||
metadataMock,
|
||||
storageMock,
|
||||
configMock,
|
||||
mediaMock,
|
||||
moveMock,
|
||||
communicationMock,
|
||||
personMock,
|
||||
);
|
||||
});
|
||||
|
@ -166,6 +178,23 @@ describe(MetadataService.name, () => {
|
|||
expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
|
||||
expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
|
||||
});
|
||||
|
||||
it('should notify clients on live photo link', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([
|
||||
{
|
||||
...assetStub.livePhotoStillAsset,
|
||||
exifInfo: { livePhotoCID: assetStub.livePhotoMotionAsset.id } as ExifEntity,
|
||||
},
|
||||
]);
|
||||
assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
|
||||
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true);
|
||||
expect(communicationMock.send).toHaveBeenCalledWith(
|
||||
CommunicationEvent.ASSET_HIDDEN,
|
||||
assetStub.livePhotoMotionAsset.ownerId,
|
||||
assetStub.livePhotoMotionAsset.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueMetadataExtraction', () => {
|
||||
|
@ -277,6 +306,7 @@ describe(MetadataService.name, () => {
|
|||
|
||||
it('should not apply motion photos if asset is video', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id });
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
|
||||
|
@ -287,6 +317,19 @@ describe(MetadataService.name, () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should extract the correct video orientation', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
metadataMock.readTags.mockResolvedValue(null);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.video.id });
|
||||
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]);
|
||||
expect(assetMock.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ orientation: Orientation.Rotate270CW }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply motion photos', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
|
||||
metadataMock.readTags.mockResolvedValue({
|
||||
|
|
|
@ -9,11 +9,14 @@ import { Subscription } from 'rxjs';
|
|||
import { usePagination } from '../domain.util';
|
||||
import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
||||
import {
|
||||
CommunicationEvent,
|
||||
ExifDuration,
|
||||
IAlbumRepository,
|
||||
IAssetRepository,
|
||||
ICommunicationRepository,
|
||||
ICryptoRepository,
|
||||
IJobRepository,
|
||||
IMediaRepository,
|
||||
IMetadataRepository,
|
||||
IMoveRepository,
|
||||
IPersonRepository,
|
||||
|
@ -49,6 +52,17 @@ interface DirectoryEntry {
|
|||
Item: DirectoryItem;
|
||||
}
|
||||
|
||||
export enum Orientation {
|
||||
Horizontal = '1',
|
||||
MirrorHorizontal = '2',
|
||||
Rotate180 = '3',
|
||||
MirrorVertical = '4',
|
||||
MirrorHorizontalRotate270CW = '5',
|
||||
Rotate90CW = '6',
|
||||
MirrorHorizontalRotate90CW = '7',
|
||||
Rotate270CW = '8',
|
||||
}
|
||||
|
||||
type ExifEntityWithoutGeocodeAndTypeOrm = Omit<
|
||||
ExifEntity,
|
||||
'city' | 'state' | 'country' | 'description' | 'exifTextSearchableColumn'
|
||||
|
@ -90,7 +104,9 @@ export class MetadataService {
|
|||
@Inject(IMetadataRepository) private repository: IMetadataRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(IPersonRepository) personRepository: IPersonRepository,
|
||||
) {
|
||||
this.configCore = SystemConfigCore.create(configRepository);
|
||||
|
@ -154,6 +170,9 @@ export class MetadataService {
|
|||
await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
|
||||
await this.albumRepository.removeAsset(motionAsset.id);
|
||||
|
||||
// Notify clients to hide the linked live photo asset
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -182,6 +201,27 @@ export class MetadataService {
|
|||
|
||||
const { exifData, tags } = await this.exifData(asset);
|
||||
|
||||
if (asset.type === AssetType.VIDEO) {
|
||||
const { videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
|
||||
if (videoStreams[0]) {
|
||||
switch (videoStreams[0].rotation) {
|
||||
case -90:
|
||||
exifData.orientation = Orientation.Rotate90CW;
|
||||
break;
|
||||
case 0:
|
||||
exifData.orientation = Orientation.Horizontal;
|
||||
break;
|
||||
case 90:
|
||||
exifData.orientation = Orientation.Rotate270CW;
|
||||
break;
|
||||
case 180:
|
||||
exifData.orientation = Orientation.Rotate180;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.applyMotionPhotos(asset, tags);
|
||||
await this.applyReverseGeocoding(asset, exifData);
|
||||
await this.assetRepository.upsertExif(exifData);
|
||||
|
|
|
@ -2,6 +2,7 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { Optional, ValidateUUID, toBoolean } from '../domain.util';
|
||||
|
||||
export class PersonUpdateDto {
|
||||
|
@ -73,6 +74,51 @@ export class PersonResponseDto {
|
|||
isHidden!: boolean;
|
||||
}
|
||||
|
||||
export class PersonWithFacesResponseDto extends PersonResponseDto {
|
||||
faces!: AssetFaceWithoutPersonResponseDto[];
|
||||
}
|
||||
|
||||
export class AssetFaceWithoutPersonResponseDto {
|
||||
@ValidateUUID()
|
||||
id!: string;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
imageHeight!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
imageWidth!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
boundingBoxX1!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
boundingBoxX2!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
boundingBoxY1!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
boundingBoxY2!: number;
|
||||
}
|
||||
|
||||
export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto {
|
||||
person!: PersonResponseDto | null;
|
||||
}
|
||||
|
||||
export class AssetFaceUpdateDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetFaceUpdateItem)
|
||||
data!: AssetFaceUpdateItem[];
|
||||
}
|
||||
|
||||
export class FaceDto {
|
||||
@ValidateUUID()
|
||||
id!: string;
|
||||
}
|
||||
|
||||
export class AssetFaceUpdateItem {
|
||||
@ValidateUUID()
|
||||
personId!: string;
|
||||
|
||||
@ValidateUUID()
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
export class PersonStatisticsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
assets!: number;
|
||||
|
@ -98,10 +144,15 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
|
|||
};
|
||||
}
|
||||
|
||||
export function mapFace(face: AssetFaceEntity): PersonResponseDto | null {
|
||||
if (face.person) {
|
||||
return mapPerson(face.person);
|
||||
}
|
||||
|
||||
return null;
|
||||
export function mapFaces(face: AssetFaceEntity, authUser: AuthUserDto): AssetFaceResponseDto {
|
||||
return {
|
||||
id: face.id,
|
||||
imageHeight: face.imageHeight,
|
||||
imageWidth: face.imageWidth,
|
||||
boundingBoxX1: face.boundingBoxX1,
|
||||
boundingBoxX2: face.boundingBoxX2,
|
||||
boundingBoxY1: face.boundingBoxY1,
|
||||
boundingBoxY2: face.boundingBoxY2,
|
||||
person: face.person?.ownerId === authUser.id ? mapPerson(face.person) : null,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ import {
|
|||
ISystemConfigRepository,
|
||||
WithoutProperty,
|
||||
} from '../repositories';
|
||||
import { PersonResponseDto } from './person.dto';
|
||||
import { PersonResponseDto, mapFaces } from './person.dto';
|
||||
import { PersonService } from './person.service';
|
||||
|
||||
const responseDto: PersonResponseDto = {
|
||||
|
@ -331,7 +331,7 @@ describe(PersonService.name, () => {
|
|||
personMock.getById.mockResolvedValue(personStub.withName);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
await expect(
|
||||
|
@ -339,7 +339,7 @@ describe(PersonService.name, () => {
|
|||
).resolves.toEqual(responseDto);
|
||||
|
||||
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.assetId });
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id });
|
||||
expect(personMock.getFacesByIds).toHaveBeenCalledWith([
|
||||
{
|
||||
assetId: faceStub.face1.assetId,
|
||||
|
@ -375,6 +375,139 @@ describe(PersonService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('reassignFaces', () => {
|
||||
it('should throw an error if user has no access to the person', async () => {
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
|
||||
await expect(
|
||||
sut.reassignFaces(authStub.admin, personStub.noName.id, {
|
||||
data: [{ personId: 'asset-face-1', assetId: '' }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(jobMock.queue).not.toHaveBeenCalledWith();
|
||||
});
|
||||
it('should reassign a face', async () => {
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id]));
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
personMock.reassignFace.mockResolvedValue(1);
|
||||
personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1);
|
||||
await expect(
|
||||
sut.reassignFaces(authStub.admin, personStub.noName.id, {
|
||||
data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }],
|
||||
}),
|
||||
).resolves.toEqual([personStub.noName]);
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePersonMigration', () => {
|
||||
it('should not move person files', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFacesById', () => {
|
||||
it('should get the bounding boxes for an asset', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId]));
|
||||
personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]);
|
||||
await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([
|
||||
mapFaces(faceStub.primaryFace1, authStub.admin),
|
||||
]);
|
||||
});
|
||||
it('should reject if the user has not access to the asset', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]);
|
||||
await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewFeaturePhoto', () => {
|
||||
it('should change person feature photo', async () => {
|
||||
personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1);
|
||||
await sut.createNewFeaturePhoto([personStub.newThumbnail.id]);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reassignFacesById', () => {
|
||||
it('should create a new person', async () => {
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
||||
personMock.reassignFace.mockResolvedValue(1);
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
personMock.getRandomFace.mockResolvedValue(null);
|
||||
await expect(
|
||||
sut.reassignFacesById(authStub.admin, personStub.noName.id, {
|
||||
id: faceStub.face1.id,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
birthDate: personStub.noName.birthDate,
|
||||
isHidden: personStub.noName.isHidden,
|
||||
id: personStub.noName.id,
|
||||
name: personStub.noName.name,
|
||||
thumbnailPath: personStub.noName.thumbnailPath,
|
||||
});
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should fail if user has not the correct permissions on the asset', async () => {
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set());
|
||||
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
||||
personMock.reassignFace.mockResolvedValue(1);
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
personMock.getRandomFace.mockResolvedValue(null);
|
||||
await expect(
|
||||
sut.reassignFacesById(authStub.admin, personStub.noName.id, {
|
||||
id: faceStub.face1.id,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(jobMock.queue).not.toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPerson', () => {
|
||||
it('should create a new person', async () => {
|
||||
personMock.create.mockResolvedValue(personStub.primaryPerson);
|
||||
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
||||
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||
|
||||
await expect(sut.createPerson(authStub.admin)).resolves.toBe(personStub.primaryPerson);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePersonDelete', () => {
|
||||
it('should stop if a person has not be found', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(false);
|
||||
expect(personMock.update).not.toHaveBeenCalled();
|
||||
expect(storageMock.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should delete a person', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.primaryPerson);
|
||||
|
||||
await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(true);
|
||||
expect(personMock.delete).toHaveBeenCalledWith(personStub.primaryPerson);
|
||||
expect(storageMock.unlink).toHaveBeenCalledWith(personStub.primaryPerson.thumbnailPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePersonCleanup', () => {
|
||||
it('should delete people without faces', async () => {
|
||||
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
|
||||
|
@ -515,6 +648,7 @@ describe(PersonService.name, () => {
|
|||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
|
||||
personMock.create.mockResolvedValue(personStub.noName);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
personMock.createFace.mockResolvedValue(faceStub.primaryFace1);
|
||||
|
||||
await sut.handleRecognizeFaces({ id: assetStub.image.id });
|
||||
|
||||
|
@ -557,16 +691,16 @@ describe(PersonService.name, () => {
|
|||
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip an person with a face asset id not found', async () => {
|
||||
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
it('should skip a person with a face asset id not found', async () => {
|
||||
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id });
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
|
||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip a person with a face asset id without a thumbnail', async () => {
|
||||
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
|
||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||
|
@ -574,7 +708,7 @@ describe(PersonService.name, () => {
|
|||
|
||||
it('should generate a thumbnail', async () => {
|
||||
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.middle]);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
|
||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||
|
@ -601,7 +735,7 @@ describe(PersonService.name, () => {
|
|||
|
||||
it('should generate a thumbnail without going negative', async () => {
|
||||
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId });
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.start]);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
|
||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||
|
@ -622,7 +756,7 @@ describe(PersonService.name, () => {
|
|||
|
||||
it('should generate a thumbnail without overflowing', async () => {
|
||||
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.end]);
|
||||
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]);
|
||||
|
||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||
|
@ -646,15 +780,12 @@ describe(PersonService.name, () => {
|
|||
it('should require person.write and person.merge permission', async () => {
|
||||
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
|
||||
personMock.prepareReassignFaces.mockResolvedValue([]);
|
||||
personMock.delete.mockResolvedValue(personStub.mergePerson);
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
|
||||
|
||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||
|
||||
expect(personMock.delete).not.toHaveBeenCalled();
|
||||
|
@ -664,7 +795,6 @@ describe(PersonService.name, () => {
|
|||
it('should merge two people', async () => {
|
||||
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
|
||||
personMock.prepareReassignFaces.mockResolvedValue([]);
|
||||
personMock.delete.mockResolvedValue(personStub.mergePerson);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
|
@ -673,11 +803,6 @@ describe(PersonService.name, () => {
|
|||
{ id: 'person-2', success: true },
|
||||
]);
|
||||
|
||||
expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
|
||||
newPersonId: personStub.primaryPerson.id,
|
||||
oldPersonId: personStub.mergePerson.id,
|
||||
});
|
||||
|
||||
expect(personMock.reassignFaces).toHaveBeenCalledWith({
|
||||
newPersonId: personStub.primaryPerson.id,
|
||||
oldPersonId: personStub.mergePerson.id,
|
||||
|
@ -690,29 +815,6 @@ describe(PersonService.name, () => {
|
|||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should delete conflicting faces before merging', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.primaryPerson);
|
||||
personMock.getById.mockResolvedValue(personStub.mergePerson);
|
||||
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||
{ id: 'person-2', success: true },
|
||||
]);
|
||||
|
||||
expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
|
||||
newPersonId: personStub.primaryPerson.id,
|
||||
oldPersonId: personStub.mergePerson.id,
|
||||
});
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.SEARCH_REMOVE_FACE,
|
||||
data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id },
|
||||
});
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should throw an error when the primary person is not found', async () => {
|
||||
personMock.getById.mockResolvedValue(null);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
@ -735,7 +837,6 @@ describe(PersonService.name, () => {
|
|||
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
|
||||
]);
|
||||
|
||||
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
|
||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||
expect(personMock.delete).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
|
||||
|
@ -744,7 +845,6 @@ describe(PersonService.name, () => {
|
|||
it('should handle an error reassigning faces', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.primaryPerson);
|
||||
personMock.getById.mockResolvedValue(personStub.mergePerson);
|
||||
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
|
||||
personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
|
|
|
@ -28,6 +28,9 @@ import {
|
|||
import { StorageCore } from '../storage';
|
||||
import { SystemConfigCore } from '../system-config';
|
||||
import {
|
||||
AssetFaceResponseDto,
|
||||
AssetFaceUpdateDto,
|
||||
FaceDto,
|
||||
MergePersonDto,
|
||||
PeopleResponseDto,
|
||||
PeopleUpdateDto,
|
||||
|
@ -35,6 +38,7 @@ import {
|
|||
PersonSearchDto,
|
||||
PersonStatisticsResponseDto,
|
||||
PersonUpdateDto,
|
||||
mapFaces,
|
||||
mapPerson,
|
||||
} from './person.dto';
|
||||
|
||||
|
@ -80,6 +84,86 @@ export class PersonService {
|
|||
};
|
||||
}
|
||||
|
||||
createPerson(authUser: AuthUserDto): Promise<PersonResponseDto> {
|
||||
return this.repository.create({ ownerId: authUser.id });
|
||||
}
|
||||
|
||||
async reassignFaces(authUser: AuthUserDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
|
||||
await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId);
|
||||
const person = await this.findOrFail(personId);
|
||||
const result: PersonResponseDto[] = [];
|
||||
const changeFeaturePhoto: string[] = [];
|
||||
for (const data of dto.data) {
|
||||
const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
|
||||
|
||||
for (const face of faces) {
|
||||
await this.access.requirePermission(authUser, Permission.PERSON_CREATE, face.id);
|
||||
if (person.faceAssetId === null) {
|
||||
changeFeaturePhoto.push(person.id);
|
||||
}
|
||||
if (face.person && face.person.faceAssetId === face.id) {
|
||||
changeFeaturePhoto.push(face.person.id);
|
||||
}
|
||||
|
||||
await this.repository.reassignFace(face.id, personId);
|
||||
}
|
||||
|
||||
result.push(person);
|
||||
}
|
||||
if (changeFeaturePhoto.length > 0) {
|
||||
// Remove duplicates
|
||||
await this.createNewFeaturePhoto(Array.from(new Set(changeFeaturePhoto)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async reassignFacesById(authUser: AuthUserDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
|
||||
await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId);
|
||||
|
||||
await this.access.requirePermission(authUser, Permission.PERSON_CREATE, dto.id);
|
||||
const face = await this.repository.getFaceById(dto.id);
|
||||
const person = await this.findOrFail(personId);
|
||||
|
||||
await this.repository.reassignFace(face.id, personId);
|
||||
if (person.faceAssetId === null) {
|
||||
await this.createNewFeaturePhoto([person.id]);
|
||||
}
|
||||
if (face.person && face.person.faceAssetId === face.id) {
|
||||
await this.createNewFeaturePhoto([face.person.id]);
|
||||
}
|
||||
|
||||
return await this.findOrFail(personId).then(mapPerson);
|
||||
}
|
||||
|
||||
async getFacesById(authUser: AuthUserDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_READ, dto.id);
|
||||
const faces = await this.repository.getFaces(dto.id);
|
||||
return faces.map((asset) => mapFaces(asset, authUser));
|
||||
}
|
||||
|
||||
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
|
||||
this.logger.debug(
|
||||
`Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`,
|
||||
);
|
||||
for (const personId of changeFeaturePhoto) {
|
||||
const assetFace = await this.repository.getRandomFace(personId);
|
||||
|
||||
if (assetFace !== null) {
|
||||
await this.repository.update({
|
||||
id: personId,
|
||||
faceAssetId: assetFace.id,
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||
data: {
|
||||
id: personId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
|
||||
await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
|
||||
return this.findOrFail(id).then(mapPerson);
|
||||
|
@ -128,7 +212,7 @@ export class PersonService {
|
|||
throw new BadRequestException('Invalid assetId for feature face');
|
||||
}
|
||||
|
||||
person = await this.repository.update({ id, faceAssetId: assetId });
|
||||
person = await this.repository.update({ id, faceAssetId: face.id });
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
||||
}
|
||||
|
||||
|
@ -255,9 +339,9 @@ export class PersonService {
|
|||
personId = newPerson.id;
|
||||
}
|
||||
|
||||
const faceId: AssetFaceId = { assetId: asset.id, personId };
|
||||
await this.repository.createFace({
|
||||
...faceId,
|
||||
const face = await this.repository.createFace({
|
||||
assetId: asset.id,
|
||||
personId,
|
||||
embedding,
|
||||
imageHeight: rest.imageHeight,
|
||||
imageWidth: rest.imageWidth,
|
||||
|
@ -266,10 +350,11 @@ export class PersonService {
|
|||
boundingBoxY1: rest.boundingBox.y1,
|
||||
boundingBoxY2: rest.boundingBox.y2,
|
||||
});
|
||||
const faceId: AssetFaceId = { assetId: asset.id, personId };
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
|
||||
|
||||
if (newPerson) {
|
||||
await this.repository.update({ id: personId, faceAssetId: asset.id });
|
||||
await this.repository.update({ id: personId, faceAssetId: face.id });
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
|
||||
}
|
||||
}
|
||||
|
@ -304,14 +389,13 @@ export class PersonService {
|
|||
return false;
|
||||
}
|
||||
|
||||
const [face] = await this.repository.getFacesByIds([{ personId: person.id, assetId: person.faceAssetId }]);
|
||||
if (!face) {
|
||||
const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId);
|
||||
if (face === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
assetId,
|
||||
personId,
|
||||
boundingBoxX1: x1,
|
||||
boundingBoxX2: x2,
|
||||
boundingBoxY1: y1,
|
||||
|
@ -324,8 +408,7 @@ export class PersonService {
|
|||
if (!asset?.resizePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.verbose(`Cropping face for person: ${personId}`);
|
||||
this.logger.verbose(`Cropping face for person: ${person.id}`);
|
||||
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
|
||||
this.storageCore.ensureFolders(thumbnailPath);
|
||||
|
||||
|
@ -395,10 +478,6 @@ export class PersonService {
|
|||
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
|
||||
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
|
||||
|
||||
const assetIds = await this.repository.prepareReassignFaces(mergeData);
|
||||
for (const assetId of assetIds) {
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId: mergeId } });
|
||||
}
|
||||
await this.repository.reassignFaces(mergeData);
|
||||
await this.jobRepository.queue({ name: JobName.PERSON_DELETE, data: { id: mergePerson.id } });
|
||||
|
||||
|
|
|
@ -6,11 +6,12 @@ export interface IAccessRepository {
|
|||
hasAlbumOwnerAccess(userId: string, activityId: string): Promise<boolean>;
|
||||
hasCreateAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
};
|
||||
|
||||
asset: {
|
||||
hasOwnerAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
hasAlbumAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
hasPartnerAccess(userId: string, assetId: string): Promise<boolean>;
|
||||
hasSharedLinkAccess(sharedLinkId: string, assetId: string): Promise<boolean>;
|
||||
checkOwnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>>;
|
||||
checkAlbumAccess(userId: string, assetIds: Set<string>): Promise<Set<string>>;
|
||||
checkPartnerAccess(userId: string, assetIds: Set<string>): Promise<Set<string>>;
|
||||
checkSharedLinkAccess(sharedLinkId: string, assetIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
authDevice: {
|
||||
|
@ -33,6 +34,7 @@ export interface IAccessRepository {
|
|||
};
|
||||
|
||||
person: {
|
||||
hasFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
|
||||
checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ export enum CommunicationEvent {
|
|||
ASSET_DELETE = 'on_asset_delete',
|
||||
ASSET_TRASH = 'on_asset_trash',
|
||||
ASSET_UPDATE = 'on_asset_update',
|
||||
ASSET_HIDDEN = 'on_asset_hidden',
|
||||
ASSET_RESTORE = 'on_asset_restore',
|
||||
PERSON_THUMBNAIL = 'on_person_thumbnail',
|
||||
SERVER_VERSION = 'on_server_version',
|
||||
|
|
|
@ -26,6 +26,10 @@ export interface QueueStatus {
|
|||
isPaused: boolean;
|
||||
}
|
||||
|
||||
export enum QueueCleanType {
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
export type JobItem =
|
||||
// Transcoding
|
||||
| { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob }
|
||||
|
@ -120,6 +124,7 @@ export interface IJobRepository {
|
|||
pause(name: QueueName): Promise<void>;
|
||||
resume(name: QueueName): Promise<void>;
|
||||
empty(name: QueueName): Promise<void>;
|
||||
clear(name: QueueName, type: QueueCleanType): Promise<string[]>;
|
||||
getQueueStatus(name: QueueName): Promise<QueueStatus>;
|
||||
getJobCounts(name: QueueName): Promise<JobCounts>;
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ export interface IPersonRepository {
|
|||
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
|
||||
|
||||
getAssets(personId: string): Promise<AssetEntity[]>;
|
||||
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
|
||||
|
||||
reassignFaces(data: UpdateFacesData): Promise<number>;
|
||||
|
||||
create(entity: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||
|
@ -48,4 +48,8 @@ export interface IPersonRepository {
|
|||
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
|
||||
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
|
||||
createFace(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity>;
|
||||
getFaces(assetId: string): Promise<AssetFaceEntity[]>;
|
||||
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
|
||||
getFaceById(id: string): Promise<AssetFaceEntity>;
|
||||
getFaceByIdWithAssets(id: string): Promise<AssetFaceEntity | null>;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import {
|
|||
sharedLinkResponseStub,
|
||||
sharedLinkStub,
|
||||
} from '@test';
|
||||
import { when } from 'jest-when';
|
||||
import _ from 'lodash';
|
||||
import { AssetIdErrorReason } from '../asset';
|
||||
import { ICryptoRepository, ISharedLinkRepository } from '../repositories';
|
||||
|
@ -109,7 +108,6 @@ describe(SharedLinkService.name, () => {
|
|||
});
|
||||
|
||||
it('should require asset ownership to make an individual shared link', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(
|
||||
sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
@ -140,7 +138,7 @@ describe(SharedLinkService.name, () => {
|
|||
});
|
||||
|
||||
it('should create an individual shared link', async () => {
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
|
@ -151,7 +149,7 @@ describe(SharedLinkService.name, () => {
|
|||
allowUpload: true,
|
||||
});
|
||||
|
||||
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id);
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set([assetStub.image.id]));
|
||||
expect(shareMock.create).toHaveBeenCalledWith({
|
||||
type: SharedLinkType.INDIVIDUAL,
|
||||
userId: authStub.admin.id,
|
||||
|
@ -215,9 +213,7 @@ describe(SharedLinkService.name, () => {
|
|||
it('should add assets to a shared link', async () => {
|
||||
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
|
||||
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
|
||||
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false);
|
||||
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3']));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }),
|
||||
|
@ -227,7 +223,7 @@ describe(SharedLinkService.name, () => {
|
|||
{ assetId: 'asset-3', success: true },
|
||||
]);
|
||||
|
||||
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledTimes(2);
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1);
|
||||
expect(shareMock.update).toHaveBeenCalledWith({
|
||||
...sharedLinkStub.individual,
|
||||
assets: [assetStub.image, { id: 'asset-3' }],
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue