Compare commits
22 Commits
v1.139.4
...
feat/share
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e0005acfd | ||
|
|
55a196bfa0 | ||
|
|
d04675fb41 | ||
|
|
acfd40b77a | ||
|
|
840e43430c | ||
|
|
a3e0c6cef5 | ||
|
|
63088b22e0 | ||
|
|
d9d8beb92f | ||
|
|
38a8a67be9 | ||
|
|
7531ffcbfb | ||
|
|
d5f3629c49 | ||
|
|
be5b4cb1d1 | ||
|
|
5fb8d651ec | ||
|
|
c2313f7a99 | ||
|
|
59627e2b4c | ||
|
|
530bf059ad | ||
|
|
d0b49846dc | ||
|
|
8896b2dbf5 | ||
|
|
251e644b2a | ||
|
|
a02635f9a5 | ||
|
|
104f3dfcc3 | ||
|
|
b7e3b48a44 |
@@ -12,8 +12,7 @@ services:
|
||||
- ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/data/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node-modules:/usr/src/app/server/node_modules
|
||||
- server-dist:/usr/src/app/server/dist
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
@@ -25,6 +24,9 @@ services:
|
||||
- coverage:/usr/src/app/web/coverage
|
||||
immich-web:
|
||||
env_file: !reset []
|
||||
init:
|
||||
env_file: !reset []
|
||||
command: sh -c 'for path in /data /data/upload /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
||||
immich-machine-learning:
|
||||
env_file: !reset []
|
||||
database:
|
||||
|
||||
@@ -33,8 +33,7 @@ services:
|
||||
- ${UPLOAD_LOCATION}/photos/upload:/data/upload
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node-modules:/usr/src/app/server/node_modules
|
||||
- server-dist:/usr/src/app/server/dist
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
@@ -97,8 +96,7 @@ services:
|
||||
volumes:
|
||||
- ..:/usr/src/app
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node-modules:/usr/src/app/server/node_modules
|
||||
- server-dist:/usr/src/app/server/dist
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
@@ -194,8 +192,7 @@ services:
|
||||
command: sh -c 'for path in /usr/src/app/.pnpm-store /usr/src/app/server/node_modules /usr/src/app/server/dist /usr/src/app/.github/node_modules /usr/src/app/cli/node_modules /usr/src/app/docs/node_modules /usr/src/app/e2e/node_modules /usr/src/app/open-api/typescript-sdk/node_modules /usr/src/app/web/.svelte-kit /usr/src/app/web/coverage /usr/src/app/node_modules /usr/src/app/web/node_modules; do [ -e "$$path" ] && chown -R ${UID:-1000}:${GID:-1000} "$$path" || true; done'
|
||||
volumes:
|
||||
- pnpm-store:/usr/src/app/.pnpm-store
|
||||
- server-node-modules:/usr/src/app/server/node_modules
|
||||
- server-dist:/usr/src/app/server/dist
|
||||
- server-node_modules:/usr/src/app/server/node_modules
|
||||
- web-node_modules:/usr/src/app/web/node_modules
|
||||
- github-node_modules:/usr/src/app/.github/node_modules
|
||||
- cli-node_modules:/usr/src/app/cli/node_modules
|
||||
@@ -210,8 +207,7 @@ volumes:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
pnpm-store:
|
||||
server-node-modules:
|
||||
server-dist:
|
||||
server-node_modules:
|
||||
web-node_modules:
|
||||
github-node_modules:
|
||||
cli-node_modules:
|
||||
|
||||
@@ -88,7 +88,7 @@ The `.well-known/openid-configuration` part of the url is optional and will be a
|
||||
## Auto Launch
|
||||
|
||||
When Auto Launch is enabled, the login page will automatically redirect the user to the OAuth authorization url, to login with OAuth. To access the login screen again, use the browser's back button, or navigate directly to `/auth/login?autoLaunch=0`.
|
||||
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?authLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
|
||||
Auto Launch can also be enabled on a per-request basis by navigating to `/auth/login?autoLaunch=1`, this can be useful in situations where Immich is called from e.g. Nextcloud using the _External sites_ app and the _oidc_ app so as to enable users to directly interact with a logged-in instance of Immich.
|
||||
|
||||
## Mobile Redirect URI
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
||||
case 'AssetResponseDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'visibility', 'timeline');
|
||||
addDefault(value, 'createdAt', DateTime.now().toIso8601String());
|
||||
}
|
||||
break;
|
||||
case 'UserAdminResponseDto':
|
||||
|
||||
11
mobile/openapi/lib/model/asset_response_dto.dart
generated
11
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -14,6 +14,7 @@ class AssetResponseDto {
|
||||
/// Returns a new [AssetResponseDto] instance.
|
||||
AssetResponseDto({
|
||||
required this.checksum,
|
||||
required this.createdAt,
|
||||
required this.deviceAssetId,
|
||||
required this.deviceId,
|
||||
this.duplicateId,
|
||||
@@ -49,6 +50,9 @@ class AssetResponseDto {
|
||||
/// base64 encoded sha1 hash
|
||||
String checksum;
|
||||
|
||||
/// The UTC timestamp when the asset was originally uploaded to Immich.
|
||||
DateTime createdAt;
|
||||
|
||||
String deviceAssetId;
|
||||
|
||||
String deviceId;
|
||||
@@ -142,6 +146,7 @@ class AssetResponseDto {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||
other.checksum == checksum &&
|
||||
other.createdAt == createdAt &&
|
||||
other.deviceAssetId == deviceAssetId &&
|
||||
other.deviceId == deviceId &&
|
||||
other.duplicateId == duplicateId &&
|
||||
@@ -177,6 +182,7 @@ class AssetResponseDto {
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(checksum.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(deviceAssetId.hashCode) +
|
||||
(deviceId.hashCode) +
|
||||
(duplicateId == null ? 0 : duplicateId!.hashCode) +
|
||||
@@ -209,11 +215,12 @@ class AssetResponseDto {
|
||||
(visibility.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
|
||||
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'checksum'] = this.checksum;
|
||||
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
|
||||
json[r'deviceAssetId'] = this.deviceAssetId;
|
||||
json[r'deviceId'] = this.deviceId;
|
||||
if (this.duplicateId != null) {
|
||||
@@ -293,6 +300,7 @@ class AssetResponseDto {
|
||||
|
||||
return AssetResponseDto(
|
||||
checksum: mapValueOfType<String>(json, r'checksum')!,
|
||||
createdAt: mapDateTime(json, r'createdAt', r'')!,
|
||||
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
|
||||
deviceId: mapValueOfType<String>(json, r'deviceId')!,
|
||||
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
||||
@@ -371,6 +379,7 @@ class AssetResponseDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'checksum',
|
||||
'createdAt',
|
||||
'deviceAssetId',
|
||||
'deviceId',
|
||||
'duration',
|
||||
|
||||
@@ -10720,6 +10720,12 @@
|
||||
"description": "base64 encoded sha1 hash",
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"description": "The UTC timestamp when the asset was originally uploaded to Immich.",
|
||||
"example": "2024-01-15T20:30:00.000Z",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"deviceAssetId": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -10855,6 +10861,7 @@
|
||||
},
|
||||
"required": [
|
||||
"checksum",
|
||||
"createdAt",
|
||||
"deviceAssetId",
|
||||
"deviceId",
|
||||
"duration",
|
||||
|
||||
@@ -317,6 +317,8 @@ export type TagResponseDto = {
|
||||
export type AssetResponseDto = {
|
||||
/** base64 encoded sha1 hash */
|
||||
checksum: string;
|
||||
/** The UTC timestamp when the asset was originally uploaded to Immich. */
|
||||
createdAt: string;
|
||||
deviceAssetId: string;
|
||||
deviceId: string;
|
||||
duplicateId?: string | null;
|
||||
|
||||
@@ -6,11 +6,15 @@ export * from './fetch-errors.js';
|
||||
export interface InitOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const init = ({ baseUrl, apiKey }: InitOptions) => {
|
||||
export const init = ({ baseUrl, apiKey, headers }: InitOptions) => {
|
||||
setBaseUrl(baseUrl);
|
||||
setApiKey(apiKey);
|
||||
if (headers) {
|
||||
setHeaders(headers);
|
||||
}
|
||||
};
|
||||
|
||||
export const getBaseUrl = () => defaults.baseUrl;
|
||||
@@ -24,6 +28,26 @@ export const setApiKey = (apiKey: string) => {
|
||||
defaults.headers['x-api-key'] = apiKey;
|
||||
};
|
||||
|
||||
export const setHeader = (key: string, value: string) => {
|
||||
assertNoApiKey(key);
|
||||
defaults.headers = defaults.headers || {};
|
||||
defaults.headers[key] = value;
|
||||
};
|
||||
|
||||
export const setHeaders = (headers: Record<string, string>) => {
|
||||
defaults.headers = defaults.headers || {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
assertNoApiKey(key);
|
||||
defaults.headers[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
const assertNoApiKey = (headerKey: string) => {
|
||||
if (headerKey.toLowerCase() === 'x-api-key') {
|
||||
throw new Error('The API key header can only be set using setApiKey().');
|
||||
}
|
||||
};
|
||||
|
||||
export const getAssetOriginalPath = (id: string) => `/assets/${id}/original`;
|
||||
|
||||
export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`;
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<p align="center">
|
||||
<br/>
|
||||
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="License: AGPLv3"></a>
|
||||
<br/>
|
||||
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Licenza: AGPLv3"></a>
|
||||
<a href="https://discord.immich.app">
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/>
|
||||
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
|
||||
</a>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
|
||||
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Accedi con url personalizzato">
|
||||
</p>
|
||||
<h3 align="center">Immich - Soluzione self-hosted ad alte prestazioni per backup di foto e video</h3>
|
||||
<h3 align="center">Soluzione ad alte prestazioni per la gestione self-hosted di foto e video</h3>
|
||||
<br/>
|
||||
<a href="https://immich.app">
|
||||
<img src="../design/immich-screenshots.png" title="Main Screenshot">
|
||||
<img src="../design/immich-screenshots.png" title="Screenshot Principale">
|
||||
</a>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<a href="../README.md">English</a>
|
||||
<a href="README_ca_ES.md">Català</a>
|
||||
@@ -36,64 +37,97 @@
|
||||
<a href="README_th_TH.md">ภาษาไทย</a>
|
||||
</p>
|
||||
|
||||
## Declino di responsabilità
|
||||
## Avvertenze
|
||||
|
||||
- ⚠️ Il progetto è in una fase **molto intensa** di sviluppo.
|
||||
- ⚠️ Possibilità di bug e cambiamenti rilevanti.
|
||||
- ⚠️ **Non utilizzare l'app come unico salvataggio delle tue foto e dei tuoi video.**
|
||||
- ⚠️ Utilizza sempre una tecnica [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) di backup per le foto e i video a cui tieni!
|
||||
- ⚠️ Il progetto è in fase di sviluppo **molto attivo**.
|
||||
- ⚠️ Possono esserci bug o cambiamenti radicali, che possono non essere retrocompatibili (breaking changes).
|
||||
- ⚠️ **Non usare l’app come unico modo per archiviare le tue foto e i tuoi video.**
|
||||
- ⚠️ Segui sempre la regola di backup [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per proteggere i tuoi ricordi e le foto a cui tieni!
|
||||
|
||||
## Contenuto
|
||||
> [!NOTE]
|
||||
> La documentazione principale, comprese le guide all’installazione, si trova su https://immich.app/.
|
||||
|
||||
- [Documentazione Ufficiale](https://immich.app/docs)
|
||||
- [Roadmap](https://github.com/orgs/immich-app/projects/1)
|
||||
- [Demo](#demo)
|
||||
- [Funzionalità](#features)
|
||||
- [Introduzione](https://immich.app/docs/overview/introduction)
|
||||
- [Installazione](https://immich.app/docs/install/requirements)
|
||||
- [Linee Guida per Contribuire](https://immich.app/docs/overview/support-the-project)
|
||||
## Link utili
|
||||
|
||||
## Documentazione
|
||||
|
||||
La documentazione ufficiale, inclusa la guida all'installazione, è disponibile qui: https://immich.app/.
|
||||
- [Documentazione](https://immich.app/docs)
|
||||
- [Informazioni](https://immich.app/docs/overview/introduction)
|
||||
- [Installazione](https://immich.app/docs/install/requirements)
|
||||
- [Roadmap](https://immich.app/roadmap)
|
||||
- [Demo](#demo)
|
||||
- [Funzionalità](#funzionalità)
|
||||
- [Traduzioni](https://immich.app/docs/developer/translations)
|
||||
- [Contribuire](https://immich.app/docs/overview/support-the-project)
|
||||
|
||||
## Demo
|
||||
|
||||
Prova la demo del progetto https://demo.immich.app. Sull'app mobile, imposta `https://demo.immich.app` come `Server Endpoint URL`
|
||||
Accedi alla demo [qui](https://demo.immich.app).
|
||||
Per l’app mobile puoi usare `https://demo.immich.app` come `Server Endpoint URL`.
|
||||
|
||||
```bash title="Demo Credential"
|
||||
Credenziali di accesso
|
||||
email: demo@immich.app
|
||||
password: demo
|
||||
```
|
||||
### Credenziali di accesso
|
||||
|
||||
# Funzionalità
|
||||
| Email | Password |
|
||||
| --------------- | -------- |
|
||||
| demo@immich.app | demo |
|
||||
|
||||
| Funzionalità | Mobile | Web |
|
||||
| ---------------------------------------------- | ------ | --- |
|
||||
| Caricamento e visualizzazione di foto e video | Sì | Sì |
|
||||
| Backup automatico quando l'app è in esecuzione | Sì | N/D |
|
||||
| Selezione degli album per backup | Sì | N/D |
|
||||
| Download foto e video sul dispositivo | Sì | Sì |
|
||||
| Supporto multi utente | Sì | Sì |
|
||||
| Album e album condivisi | Sì | Sì |
|
||||
| Barra di scorrimento con trascinamento | Sì | Sì |
|
||||
| Supporto formati raw | Sì | Sì |
|
||||
| Visualizzazione metadata (EXIF, map) | Sì | Sì |
|
||||
| Ricerca per metadata, oggetti, volti e CLIP | Sì | Sì |
|
||||
| Funzioni di amministrazione degli utenti | No | Sì |
|
||||
| Backup in background | Sì | N/D |
|
||||
| Scroll virtuale | Sì | Sì |
|
||||
| Supporto OAuth | Sì | Sì |
|
||||
| API Keys | N/D | Sì |
|
||||
| Backup e riproduzione di LivePhoto | iOS | Sì |
|
||||
| Archiviazione impostata dall'utente | Sì | Sì |
|
||||
| Condivisione pubblica | No | Sì |
|
||||
| Archivio e Preferiti | Sì | Sì |
|
||||
| Mappa globale | Sì | Sì |
|
||||
| Collaborazione con utenti | Sì | Sì |
|
||||
| Riconoscimento facciale e categorizzazione | Sì | Sì |
|
||||
| Ricordi (x anni fa) | Sì | Sì |
|
||||
| Supporto offline | Sì | No |
|
||||
| Galleria sola lettura | Sì | Sì |
|
||||
| Foto raggruppate | Sì | Sì |
|
||||
## Funzionalità
|
||||
|
||||
| Funzionalità | Mobile | Web |
|
||||
| :------------------------------------------ | ------ | --- |
|
||||
| Caricare e visualizzare foto e video | Sì | Sì |
|
||||
| Backup automatico all’apertura dell’app | Sì | N/D |
|
||||
| Evita la duplicazione dei file | Sì | Sì |
|
||||
| Backup selettivo di album | Sì | N/D |
|
||||
| Scaricare foto e video sul dispositivo | Sì | Sì |
|
||||
| Supporto multi-utente | Sì | Sì |
|
||||
| Album e album condivisi | Sì | Sì |
|
||||
| Barra di scorrimento trascinabile | Sì | Sì |
|
||||
| Supporto ai formati RAW | Sì | Sì |
|
||||
| Visualizzazione metadati (EXIF, mappa) | Sì | Sì |
|
||||
| Ricerca per metadati, oggetti, volti, CLIP | Sì | Sì |
|
||||
| Funzioni amministrative (gestione utenti) | No | Sì |
|
||||
| Backup in background | Sì | N/D |
|
||||
| Scorrimento virtuale | Sì | Sì |
|
||||
| Supporto OAuth | Sì | Sì |
|
||||
| Chiavi API | N/D | Sì |
|
||||
| Backup e riproduzione LivePhoto/MotionPhoto | Sì | Sì |
|
||||
| Supporto immagini a 360° | No | Sì |
|
||||
| Struttura di archiviazione personalizzata | Sì | Sì |
|
||||
| Condivisione pubblica | Sì | Sì |
|
||||
| Archivio e preferiti | Sì | Sì |
|
||||
| Mappa globale | Sì | Sì |
|
||||
| Condivisione con partner | Sì | Sì |
|
||||
| Riconoscimento e raggruppamento facciale | Sì | Sì |
|
||||
| Ricordi (anni fa) | Sì | Sì |
|
||||
| Supporto offline | Sì | No |
|
||||
| Galleria in sola lettura | Sì | Sì |
|
||||
| Foto impilate | Sì | Sì |
|
||||
| Tag | No | Sì |
|
||||
| Vista per cartelle | Sì | Sì |
|
||||
|
||||
## Traduzioni
|
||||
|
||||
Scopri di più sulle traduzioni [qui](https://immich.app/docs/developer/translations).
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/immich/">
|
||||
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Stato traduzioni" />
|
||||
</a>
|
||||
|
||||
## Attività del repository
|
||||
|
||||

|
||||
|
||||
## Cronologia delle stelle
|
||||
|
||||
<a href="https://star-history.com/#immich-app/immich&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
|
||||
<img alt="Grafico storico delle stelle" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contributori
|
||||
|
||||
<a href="https://github.com/alextran1502/immich/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
|
||||
</a>
|
||||
|
||||
@@ -37,6 +37,13 @@ export class SanitizedAssetResponseDto {
|
||||
}
|
||||
|
||||
export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
description: 'The UTC timestamp when the asset was originally uploaded to Immich.',
|
||||
example: '2024-01-15T20:30:00.000Z',
|
||||
})
|
||||
createdAt!: Date;
|
||||
deviceAssetId!: string;
|
||||
deviceId!: string;
|
||||
ownerId!: string;
|
||||
@@ -190,6 +197,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
createdAt: entity.createdAt,
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.ownerId,
|
||||
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
||||
|
||||
@@ -38,7 +38,11 @@ from
|
||||
select
|
||||
"album".*,
|
||||
coalesce(
|
||||
json_agg("assets") filter (
|
||||
json_agg(
|
||||
"assets"
|
||||
order by
|
||||
"assets"."fileCreatedAt" asc
|
||||
) filter (
|
||||
where
|
||||
"assets"."id" is not null
|
||||
),
|
||||
|
||||
@@ -86,7 +86,16 @@ export class SharedLinkRepository {
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select((eb) =>
|
||||
eb.fn.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`).as('assets'),
|
||||
eb.fn
|
||||
.coalesce(
|
||||
eb.fn
|
||||
.jsonAgg('assets')
|
||||
.orderBy('assets.fileCreatedAt', 'asc')
|
||||
.filterWhere('assets.id', 'is not', null),
|
||||
|
||||
sql`'[]'`,
|
||||
)
|
||||
.as('assets'),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson('owner').as('owner'))
|
||||
.groupBy(['album.id', sql`"owner".*`])
|
||||
|
||||
1
server/test/fixtures/shared-link.stub.ts
vendored
1
server/test/fixtures/shared-link.stub.ts
vendored
@@ -46,6 +46,7 @@ const assetInfo: ExifResponseDto = {
|
||||
|
||||
const assetResponse: AssetResponseDto = {
|
||||
id: 'id_1',
|
||||
createdAt: today,
|
||||
deviceAssetId: 'device_asset_id_1',
|
||||
ownerId: 'user_id_1',
|
||||
deviceId: 'device_id_1',
|
||||
|
||||
@@ -33,6 +33,7 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { SearchRepository } from 'src/repositories/search.repository';
|
||||
import { SessionRepository } from 'src/repositories/session.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { StackRepository } from 'src/repositories/stack.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
|
||||
@@ -286,6 +287,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
case PersonRepository:
|
||||
case SearchRepository:
|
||||
case SessionRepository:
|
||||
case SharedLinkRepository:
|
||||
case StackRepository:
|
||||
case SyncRepository:
|
||||
case SyncCheckpointRepository:
|
||||
@@ -391,7 +393,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
|
||||
checksum: randomBytes(32),
|
||||
type: AssetType.Image,
|
||||
originalPath: '/path/to/something.jpg',
|
||||
ownerId: '@immich.cloud',
|
||||
ownerId: 'not-a-valid-uuid',
|
||||
isFavorite: false,
|
||||
fileCreatedAt: now,
|
||||
fileModifiedAt: now,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(SharedLinkService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [AccessRepository, DatabaseRepository, SharedLinkRepository],
|
||||
mock: [LoggingRepository, StorageRepository],
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(SharedLinkService.name, () => {
|
||||
describe('get', () => {
|
||||
it('should return the correct dates on the shared link album', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const dates = ['2021-01-01T00:00:00.000Z', '2022-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z'];
|
||||
|
||||
for (const date of dates) {
|
||||
const { asset } = await ctx.newAsset({ fileCreatedAt: date, localDateTime: date, ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
}
|
||||
|
||||
const sharedLinkRepo = ctx.get(SharedLinkRepository);
|
||||
|
||||
const sharedLink = await sharedLinkRepo.create({
|
||||
key: randomBytes(16),
|
||||
id: factory.uuid(),
|
||||
userId: user.id,
|
||||
albumId: album.id,
|
||||
allowUpload: true,
|
||||
type: SharedLinkType.Album,
|
||||
});
|
||||
|
||||
await expect(sut.get(auth, sharedLink.id)).resolves.toMatchObject({
|
||||
album: expect.objectContaining({
|
||||
startDate: '2020-01-01T00:00:00+00:00',
|
||||
endDate: '2022-01-01T00:00:00+00:00',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { timeToSeconds } from '$lib/utils/date-time';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize, AssetVisibility } from '@immich/sdk';
|
||||
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiCameraBurst,
|
||||
@@ -45,6 +45,7 @@
|
||||
imageClass?: ClassValue;
|
||||
brokenAssetClass?: ClassValue;
|
||||
dimmed?: boolean;
|
||||
albumUsers?: UserResponseDto[];
|
||||
onClick?: (asset: TimelineAsset) => void;
|
||||
onSelect?: (asset: TimelineAsset) => void;
|
||||
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
|
||||
@@ -63,6 +64,7 @@
|
||||
readonly = false,
|
||||
showArchiveIcon = false,
|
||||
showStackedIcon = true,
|
||||
albumUsers = [],
|
||||
onClick = undefined,
|
||||
onSelect = undefined,
|
||||
onMouseEvent = undefined,
|
||||
@@ -84,6 +86,8 @@
|
||||
let width = $derived(thumbnailSize || thumbnailWidth || 235);
|
||||
let height = $derived(thumbnailSize || thumbnailHeight || 235);
|
||||
|
||||
let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null);
|
||||
|
||||
const onIconClickedHandler = (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
@@ -267,6 +271,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !!assetOwner}
|
||||
<div class="absolute bottom-0 end-1">
|
||||
<span class="text-white font-light text-sm">{assetOwner.name}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
||||
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import type { UserResponseDto } from '@immich/sdk';
|
||||
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
|
||||
@@ -25,7 +26,7 @@
|
||||
monthGroup: MonthGroup;
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
|
||||
albumUsers?: UserResponseDto[];
|
||||
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||
onSelectAssets: (asset: TimelineAsset) => void;
|
||||
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
||||
@@ -40,6 +41,7 @@
|
||||
monthGroup = $bindable(),
|
||||
assetInteraction,
|
||||
timelineManager,
|
||||
albumUsers = [],
|
||||
onSelect,
|
||||
onSelectAssets,
|
||||
onSelectAssetCandidates,
|
||||
@@ -189,6 +191,7 @@
|
||||
showStackedIcon={withStacked}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset)}
|
||||
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
||||
|
||||
@@ -30,7 +30,13 @@
|
||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
AssetVisibility,
|
||||
getAssetInfo,
|
||||
type AlbumResponseDto,
|
||||
type PersonResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
@@ -59,6 +65,7 @@
|
||||
showArchiveIcon?: boolean;
|
||||
isShared?: boolean;
|
||||
album?: AlbumResponseDto | null;
|
||||
albumUsers?: UserResponseDto[];
|
||||
person?: PersonResponseDto | null;
|
||||
isShowDeleteConfirmation?: boolean;
|
||||
onSelect?: (asset: TimelineAsset) => void;
|
||||
@@ -78,6 +85,7 @@
|
||||
showArchiveIcon = false,
|
||||
isShared = false,
|
||||
album = null,
|
||||
albumUsers = [],
|
||||
person = null,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
onSelect = () => {},
|
||||
@@ -88,6 +96,12 @@
|
||||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, preloadAssets, gridScrollTarget, mutex } = assetViewingStore;
|
||||
|
||||
// const albumUsers = $derived(
|
||||
// album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)
|
||||
// ? [album.owner, ...album.albumUsers.map(({ user }) => user)]
|
||||
// : [],
|
||||
// );
|
||||
|
||||
let element: HTMLElement | undefined = $state();
|
||||
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
@@ -936,6 +950,7 @@
|
||||
{isSelectionMode}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
{albumUsers}
|
||||
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
|
||||
onSelectAssetCandidates={handleSelectAssetCandidates}
|
||||
onSelectAssets={handleSelectAssets}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
}: Props = $props();
|
||||
|
||||
const oninput = () => {
|
||||
if (!value) {
|
||||
// value can be 0
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
await onEnter();
|
||||
break;
|
||||
}
|
||||
case 'm': {
|
||||
case 'Control': {
|
||||
e.preventDefault();
|
||||
handleMultiSelect();
|
||||
break;
|
||||
|
||||
@@ -74,6 +74,10 @@ class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
||||
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
|
||||
const { onUploadProgress: onProgress, data, url } = options;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { downloadRequest, withError } from '$lib/utils';
|
||||
import { downloadRequest, sleep, withError } from '$lib/utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
@@ -278,7 +278,12 @@ export const downloadFile = async (asset: AssetResponseDto) => {
|
||||
|
||||
const queryParams = asQueryString(authManager.params);
|
||||
|
||||
for (const { filename, id } of assets) {
|
||||
for (const [i, { filename, id }] of assets.entries()) {
|
||||
if (i !== 0) {
|
||||
// play nice with Safari
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
try {
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
} from '@immich/sdk';
|
||||
import { Button, IconButton, modalManager } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountEyeOutline,
|
||||
mdiArrowLeft,
|
||||
mdiCogOutline,
|
||||
mdiDeleteOutline,
|
||||
@@ -104,6 +105,7 @@
|
||||
let isCreatingSharedAlbum = $state(false);
|
||||
let isShowActivity = $state(false);
|
||||
let albumOrder: AssetOrder | undefined = $state(data.album.order);
|
||||
let showAlbumUsers = $state(false);
|
||||
|
||||
const assetInteraction = new AssetInteraction();
|
||||
const timelineInteraction = new AssetInteraction();
|
||||
@@ -321,6 +323,11 @@
|
||||
|
||||
let album = $derived(data.album);
|
||||
let albumId = $derived(album.id);
|
||||
const albumUsers = $derived(
|
||||
showAlbumUsers && album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor)
|
||||
? [album.owner, ...album.albumUsers.map(({ user }) => user)]
|
||||
: [],
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||
@@ -445,6 +452,7 @@
|
||||
<AssetGrid
|
||||
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
|
||||
{album}
|
||||
{albumUsers}
|
||||
{timelineManager}
|
||||
assetInteraction={currentAssetIntersection}
|
||||
{isShared}
|
||||
@@ -615,6 +623,15 @@
|
||||
{#snippet trailing()}
|
||||
<CastButton />
|
||||
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
aria-label="view asset owners"
|
||||
icon={mdiAccountEyeOutline}
|
||||
onclick={() => (showAlbumUsers = !showAlbumUsers)}
|
||||
/>
|
||||
|
||||
{#if isEditor}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import DuplicatesInformationModal from '$lib/modals/DuplicatesInformationModal.svelte';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { stackAssets } from '$lib/utils/asset-utils';
|
||||
@@ -60,6 +61,7 @@
|
||||
};
|
||||
|
||||
let duplicates = $state(data.duplicates);
|
||||
const { isViewing: showAssetViewer } = assetViewingStore;
|
||||
|
||||
const correctDuplicatesIndex = (index: number) => {
|
||||
return Math.max(0, Math.min(index, duplicates.length - 1));
|
||||
@@ -189,9 +191,21 @@
|
||||
const handlePrevious = async () => {
|
||||
await correctDuplicatesIndexAndGo(Math.max(duplicatesIndex - 1, 0));
|
||||
};
|
||||
const handlePreviousShortcut = async () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
}
|
||||
await handlePrevious();
|
||||
};
|
||||
const handleNext = async () => {
|
||||
await correctDuplicatesIndexAndGo(Math.min(duplicatesIndex + 1, duplicates.length - 1));
|
||||
};
|
||||
const handleNextShortcut = async () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
}
|
||||
await handleNext();
|
||||
};
|
||||
const handleLast = async () => {
|
||||
await correctDuplicatesIndexAndGo(duplicates.length - 1);
|
||||
};
|
||||
@@ -203,8 +217,8 @@
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePrevious },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNext },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: handlePreviousShortcut },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: handleNextShortcut },
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cancelLoad, getCachedOrFetch } from './fetch-event';
|
||||
import { handleCancel, handlePreload } from './request';
|
||||
|
||||
export const installBroadcastChannelListener = () => {
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
@@ -7,12 +7,19 @@ export const installBroadcastChannelListener = () => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
const urlstring = event.data.url;
|
||||
const url = new URL(urlstring, event.origin);
|
||||
if (event.data.type === 'cancel') {
|
||||
cancelLoad(url.toString());
|
||||
} else if (event.data.type === 'preload') {
|
||||
getCachedOrFetch(url);
|
||||
|
||||
const url = new URL(event.data.url, event.origin);
|
||||
|
||||
switch (event.data.type) {
|
||||
case 'preload': {
|
||||
handlePreload(url);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cancel': {
|
||||
handleCancel(url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,104 +1,42 @@
|
||||
import { build, files, version } from '$service-worker';
|
||||
import { version } from '$service-worker';
|
||||
|
||||
const useCache = true;
|
||||
const CACHE = `cache-${version}`;
|
||||
|
||||
export const APP_RESOURCES = [
|
||||
...build, // the app itself
|
||||
...files, // everything in `static`
|
||||
];
|
||||
|
||||
let cache: Cache | undefined;
|
||||
export async function getCache() {
|
||||
if (cache) {
|
||||
return cache;
|
||||
let _cache: Cache | undefined;
|
||||
const getCache = async () => {
|
||||
if (_cache) {
|
||||
return _cache;
|
||||
}
|
||||
cache = await caches.open(CACHE);
|
||||
return cache;
|
||||
}
|
||||
_cache = await caches.open(CACHE);
|
||||
return _cache;
|
||||
};
|
||||
|
||||
export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
||||
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
||||
export const get = async (key: string) => {
|
||||
const cache = await getCache();
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
export async function deleteOldCaches() {
|
||||
return cache.match(key);
|
||||
};
|
||||
|
||||
export const put = async (key: string, response: Response) => {
|
||||
if (response.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = await getCache();
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.put(key, response.clone());
|
||||
};
|
||||
|
||||
export const prune = async () => {
|
||||
for (const key of await caches.keys()) {
|
||||
if (key !== CACHE) {
|
||||
await caches.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pendingRequests = new Map<string, AbortController>();
|
||||
const canceledRequests = new Set<string>();
|
||||
|
||||
export async function cancelLoad(urlString: string) {
|
||||
const pending = pendingRequests.get(urlString);
|
||||
if (pending) {
|
||||
canceledRequests.add(urlString);
|
||||
pending.abort();
|
||||
pendingRequests.delete(urlString);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedOrFetch(request: URL | Request | string) {
|
||||
const response = await checkCache(request);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const urlString = getCacheKey(request);
|
||||
const cancelToken = new AbortController();
|
||||
|
||||
try {
|
||||
pendingRequests.set(urlString, cancelToken);
|
||||
const response = await fetch(request, {
|
||||
signal: cancelToken.signal,
|
||||
});
|
||||
|
||||
checkResponse(response);
|
||||
await setCached(response, urlString);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (canceledRequests.has(urlString)) {
|
||||
canceledRequests.delete(urlString);
|
||||
return new Response(undefined, {
|
||||
status: 499,
|
||||
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
pendingRequests.delete(urlString);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkCache(url: URL | Request | string) {
|
||||
if (!useCache) {
|
||||
return;
|
||||
}
|
||||
const cache = await getCache();
|
||||
return await cache.match(url);
|
||||
}
|
||||
|
||||
export async function setCached(response: Response, cacheKey: URL | Request | string) {
|
||||
if (cache && response.status === 200) {
|
||||
const cache = await getCache();
|
||||
cache.put(cacheKey, response.clone());
|
||||
}
|
||||
}
|
||||
|
||||
function checkResponse(response: Response) {
|
||||
if (!(response instanceof Response)) {
|
||||
throw new TypeError('Fetch did not return a valid Response object');
|
||||
}
|
||||
}
|
||||
|
||||
export function getCacheKey(request: URL | Request | string) {
|
||||
if (isURL(request)) {
|
||||
return request.toString();
|
||||
} else if (isRequest(request)) {
|
||||
return request.url;
|
||||
} else {
|
||||
return request;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { version } from '$service-worker';
|
||||
import { APP_RESOURCES, checkCache, getCacheKey, setCached } from './cache';
|
||||
|
||||
const CACHE = `cache-${version}`;
|
||||
|
||||
export const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
||||
export const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
||||
|
||||
export async function deleteOldCaches() {
|
||||
for (const key of await caches.keys()) {
|
||||
if (key !== CACHE) {
|
||||
await caches.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pendingLoads = new Map<string, AbortController>();
|
||||
|
||||
export async function cancelLoad(urlString: string) {
|
||||
const pending = pendingLoads.get(urlString);
|
||||
if (pending) {
|
||||
pending.abort();
|
||||
pendingLoads.delete(urlString);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedOrFetch(request: URL | Request | string) {
|
||||
const response = await checkCache(request);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetchWithCancellation(request);
|
||||
} catch {
|
||||
return new Response(undefined, {
|
||||
status: 499,
|
||||
statusText: 'Request canceled: Instructions unclear, accidentally interrupted myself',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithCancellation(request: URL | Request | string) {
|
||||
const cacheKey = getCacheKey(request);
|
||||
const cancelToken = new AbortController();
|
||||
|
||||
try {
|
||||
pendingLoads.set(cacheKey, cancelToken);
|
||||
const response = await fetch(request, {
|
||||
signal: cancelToken.signal,
|
||||
});
|
||||
|
||||
checkResponse(response);
|
||||
setCached(response, cacheKey);
|
||||
return response;
|
||||
} finally {
|
||||
pendingLoads.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
function checkResponse(response: Response) {
|
||||
if (!(response instanceof Response)) {
|
||||
throw new TypeError('Fetch did not return a valid Response object');
|
||||
}
|
||||
}
|
||||
|
||||
function isIgnoredFileType(pathname: string): boolean {
|
||||
return /\.(png|ico|txt|json|ts|ttf|css|js|svelte)$/.test(pathname);
|
||||
}
|
||||
|
||||
function isIgnoredPath(pathname: string): boolean {
|
||||
return (
|
||||
/^\/(src|api)(\/.*)?$/.test(pathname) || /node_modules/.test(pathname) || /^\/@(vite|id)(\/.*)?$/.test(pathname)
|
||||
);
|
||||
}
|
||||
|
||||
function isAssetRequest(pathname: string): boolean {
|
||||
return /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(pathname);
|
||||
}
|
||||
|
||||
export function handleFetchEvent(event: FetchEvent): void {
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Only handle requests to the same origin
|
||||
if (url.origin !== self.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not cache app resources
|
||||
if (APP_RESOURCES.includes(url.pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache requests for thumbnails
|
||||
if (isAssetRequest(url.pathname)) {
|
||||
event.respondWith(getCachedOrFetch(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not cache ignored file types or paths
|
||||
if (isIgnoredFileType(url.pathname) || isIgnoredPath(url.pathname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// At this point, the only remaining requests for top level routes
|
||||
// so serve the Svelte SPA fallback page
|
||||
const slash = new URL('/', url.origin);
|
||||
event.respondWith(getCachedOrFetch(slash));
|
||||
}
|
||||
@@ -3,14 +3,16 @@
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
import { installBroadcastChannelListener } from './broadcast-channel';
|
||||
import { deleteOldCaches } from './cache';
|
||||
import { handleFetchEvent } from './fetch-event';
|
||||
import { prune } from './cache';
|
||||
import { handleRequest } from './request';
|
||||
|
||||
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
|
||||
|
||||
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
const handleActivate = (event: ExtendableEvent) => {
|
||||
event.waitUntil(sw.clients.claim());
|
||||
event.waitUntil(deleteOldCaches());
|
||||
event.waitUntil(prune());
|
||||
};
|
||||
|
||||
const handleInstall = (event: ExtendableEvent) => {
|
||||
@@ -18,7 +20,20 @@ const handleInstall = (event: ExtendableEvent) => {
|
||||
// do not preload app resources
|
||||
};
|
||||
|
||||
const handleFetch = (event: FetchEvent): void => {
|
||||
if (event.request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache requests for thumbnails
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
|
||||
event.respondWith(handleRequest(event.request));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
sw.addEventListener('install', handleInstall, { passive: true });
|
||||
sw.addEventListener('activate', handleActivate, { passive: true });
|
||||
sw.addEventListener('fetch', handleFetchEvent, { passive: true });
|
||||
sw.addEventListener('fetch', handleFetch, { passive: true });
|
||||
installBroadcastChannelListener();
|
||||
|
||||
73
web/src/service-worker/request.ts
Normal file
73
web/src/service-worker/request.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { get, put } from './cache';
|
||||
|
||||
const pendingRequests = new Map<string, AbortController>();
|
||||
|
||||
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
||||
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
||||
|
||||
const assertResponse = (response: Response) => {
|
||||
if (!(response instanceof Response)) {
|
||||
throw new TypeError('Fetch did not return a valid Response object');
|
||||
}
|
||||
};
|
||||
|
||||
const getCacheKey = (request: URL | Request) => {
|
||||
if (isURL(request)) {
|
||||
return request.toString();
|
||||
}
|
||||
|
||||
if (isRequest(request)) {
|
||||
return request.url;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid request: ${request}`);
|
||||
};
|
||||
|
||||
export const handlePreload = async (request: URL | Request) => {
|
||||
try {
|
||||
return await handleRequest(request);
|
||||
} catch (error) {
|
||||
console.error(`Preload failed: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleRequest = async (request: URL | Request) => {
|
||||
const cacheKey = getCacheKey(request);
|
||||
const cachedResponse = await get(cacheKey);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const cancelToken = new AbortController();
|
||||
pendingRequests.set(cacheKey, cancelToken);
|
||||
const response = await fetch(request, { signal: cancelToken.signal });
|
||||
|
||||
assertResponse(response);
|
||||
put(cacheKey, response);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// dummy response avoids network errors in the console for these requests
|
||||
return new Response(undefined, { status: 204 });
|
||||
}
|
||||
|
||||
console.log('Not an abort error', error);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
pendingRequests.delete(cacheKey);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleCancel = (url: URL) => {
|
||||
const cacheKey = getCacheKey(url);
|
||||
const pendingRequest = pendingRequests.get(cacheKey);
|
||||
if (!pendingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequest.abort();
|
||||
pendingRequests.delete(cacheKey);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { Sync } from 'factory.ts';
|
||||
|
||||
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
||||
id: Sync.each(() => faker.string.uuid()),
|
||||
createdAt: Sync.each(() => faker.date.past().toISOString()),
|
||||
deviceAssetId: Sync.each(() => faker.string.uuid()),
|
||||
ownerId: Sync.each(() => faker.string.uuid()),
|
||||
deviceId: '',
|
||||
|
||||
Reference in New Issue
Block a user