Merge remote-tracking branch 'origin/main' into lighter_buckets_web

This commit is contained in:
Min Idzelis 2025-04-29 01:35:20 +00:00
commit ffda7364dd
294 changed files with 5446 additions and 1625 deletions

80
.vscode/settings.json vendored
View File

@ -1,45 +1,63 @@
{
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.tabSize": 2,
"editor.formatOnSave": true
},
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"svelte.enable-ts-plugin": true,
"eslint.validate": [
"javascript",
"svelte"
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"[dart]": {
"editor.defaultFormatter": "Dart-Code.dart-code",
"editor.formatOnSave": true,
"editor.selectionHighlight": false,
"editor.suggest.snippetsPreventQuickSuggestions": false,
"editor.suggestSelection": "first",
"editor.tabCompletion": "onlySnippets",
"editor.wordBasedSuggestions": "off",
"editor.defaultFormatter": "Dart-Code.dart-code"
"editor.wordBasedSuggestions": "off"
},
"cSpell.words": [
"immich"
],
"[javascript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[svelte]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"[typescript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.removeUnusedImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2
},
"cSpell.words": ["immich"],
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart"
}
}
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts"
},
"svelte.enable-ts-plugin": true,
"typescript.preferences.importModuleSpecifier": "non-relative"
}

View File

@ -17,6 +17,9 @@ e2e:
prod:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
prod-scale:
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans

6
cli/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.63",
"version": "2.2.65",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.63",
"version": "2.2.65",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"chokidar": "^4.0.3",
@ -54,7 +54,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.132.1",
"version": "1.132.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.63",
"version": "2.2.65",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@ -14,14 +14,14 @@ online generators you can use.
2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.)
3. Save your selections. Reload the map, and enjoy your custom map style!
## Use Maptiler to build a custom style
## Use MapTiler to build a custom style
Customizing the map style can be done easily using Maptiler, if you do not want to write an entire JSON document by hand.
Customizing the map style can be done easily using MapTiler, if you do not want to write an entire JSON document by hand.
1. Create a free account at https://cloud.maptiler.com
2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there.
3. The **editor** interface is self-explanatory. You can change colors, remove visible layers, or add optional layers (e.g., administrative, topo, hydro, etc.) in the composer.
4. Once you have your map composed, click on **Save** at the top right. Give it a unique name to save it to your account.
5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. Maptiler will present an interactive side-by-side map with the original and your changes prior to publication.<br/>![Maptiler Publication Settings](img/immich_map_styles_publish.webp)
6. Maptiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay.
7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to Maptiler.
5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. MapTiler will present an interactive side-by-side map with the original and your changes prior to publication.<br/>![MapTiler Publication Settings](img/immich_map_styles_publish.webp)
6. MapTiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay.
7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to MapTiler.

View File

@ -1,7 +1,7 @@
# Database Queries
:::danger
Keep in mind that mucking around in the database might set the moon on fire. Avoid modifying the database directly when possible, and always have current backups.
Keep in mind that mucking around in the database might set the Moon on fire. Avoid modifying the database directly when possible, and always have current backups.
:::
:::tip

View File

@ -252,6 +252,13 @@ const milestones: Item[] = [
description: 'Browse your photos and videos in their folder structure inside the mobile app',
release: 'v1.130.0',
}),
{
icon: mdiStar,
iconColor: 'gold',
title: '60,000 Stars',
description: 'Reached 60K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 2, 4)),
},
withRelease({
icon: mdiTagFaces,
iconColor: 'teal',
@ -260,13 +267,6 @@ const milestones: Item[] = [
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
release: 'v1.127.0',
}),
{
icon: mdiStar,
iconColor: 'gold',
title: '60,000 Stars',
description: 'Reached 60K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 2, 4)),
},
withRelease({
icon: mdiLinkEdit,
iconColor: 'crimson',

View File

@ -1,4 +1,12 @@
[
{
"label": "v1.132.3",
"url": "https://v1.132.3.archive.immich.app"
},
{
"label": "v1.132.2",
"url": "https://v1.132.2.archive.immich.app"
},
{
"label": "v1.132.1",
"url": "https://v1.132.1.archive.immich.app"

8
e2e/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.132.1",
"version": "1.132.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.132.1",
"version": "1.132.3",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@ -44,7 +44,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.63",
"version": "2.2.65",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@ -93,7 +93,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.132.1",
"version": "1.132.3",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.132.1",
"version": "1.132.3",
"description": "",
"main": "index.js",
"type": "module",

View File

@ -215,6 +215,19 @@ describe('/admin/users', () => {
const user = await getMyUser({ headers: asBearerAuth(token.accessToken) });
expect(user).toMatchObject({ email: nonAdmin.userEmail });
});
it('should update the avatar color', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}`)
.send({ avatarColor: 'orange' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ avatarColor: 'orange' });
const after = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ avatarColor: 'orange' });
});
});
describe('PUT /admin/users/:id/preferences', () => {
@ -240,19 +253,6 @@ describe('/admin/users', () => {
expect(after).toMatchObject({ memories: { enabled: false } });
});
it('should update the avatar color', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}/preferences`)
.send({ avatar: { color: 'orange' } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ avatar: { color: 'orange' } });
const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ avatar: { color: 'orange' } });
});
it('should update download archive size', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}/preferences`)

View File

@ -139,6 +139,19 @@ describe('/users', () => {
profileChangedAt: expect.anything(),
});
});
it('should update avatar color', async () => {
const { status, body } = await request(app)
.put(`/users/me`)
.send({ avatarColor: 'blue' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ avatarColor: 'blue' });
const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ avatarColor: 'blue' });
});
});
describe('PUT /users/me/preferences', () => {
@ -158,19 +171,6 @@ describe('/users', () => {
expect(after).toMatchObject({ memories: { enabled: false } });
});
it('should update avatar color', async () => {
const { status, body } = await request(app)
.put(`/users/me/preferences`)
.send({ avatar: { color: 'blue' } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ avatar: { color: 'blue' } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ avatar: { color: 'blue' } });
});
it('should require an integer for download archive size', async () => {
const { status, body } = await request(app)
.put(`/users/me/preferences`)

View File

@ -25,7 +25,7 @@ test.describe('Registration', () => {
// login
await expect(page).toHaveTitle(/Login/);
await page.goto('/auth/login');
await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('admin@immich.app');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click();
@ -59,7 +59,7 @@ test.describe('Registration', () => {
await context.clearCookies();
// login
await page.goto('/auth/login');
await page.goto('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Login' }).click();
@ -72,7 +72,7 @@ test.describe('Registration', () => {
await page.getByRole('button', { name: 'Change password' }).click();
// login with new password
await expect(page).toHaveURL('/auth/login');
await expect(page).toHaveURL('/auth/login?autoLaunch=0');
await page.getByLabel('Email').fill('user@immich.cloud');
await page.getByLabel('Password').fill('new-password');
await page.getByRole('button', { name: 'Login' }).click();

View File

@ -21,23 +21,9 @@ test.describe('Photo Viewer', () => {
test.beforeEach(async ({ context, page }) => {
// before each test, login as user
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/photos');
await page.waitForLoadState('networkidle');
});
test('initially shows a loading spinner', async ({ page }) => {
await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => {
// slow down the request for thumbnail, so spinner has chance to show up
await new Promise((f) => setTimeout(f, 2000));
await route.continue();
});
await page.goto(`/photos/${asset.id}`);
await page.waitForLoadState('load');
// this is the spinner
await page.waitForSelector('svg[role=status]');
await expect(page.getByTestId('loading-spinner')).toBeVisible();
});
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');

View File

@ -853,10 +853,12 @@
"failed_to_keep_this_delete_others": "Failed to keep this asset and delete the other assets",
"failed_to_load_asset": "Failed to load asset",
"failed_to_load_assets": "Failed to load assets",
"failed_to_load_notifications": "Failed to load notifications",
"failed_to_load_people": "Failed to load people",
"failed_to_remove_product_key": "Failed to remove product key",
"failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets",
"failed_to_update_notification_status": "Failed to update notification status",
"import_path_already_exists": "This import path already exists.",
"incorrect_email_or_password": "Incorrect email or password",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
@ -1199,6 +1201,9 @@
"map_settings_only_show_favorites": "Show Favorite Only",
"map_settings_theme_settings": "Map Theme",
"map_zoom_to_see_photos": "Zoom out to see photos",
"mark_as_read": "Mark as read",
"mark_all_as_read": "Mark all as read",
"marked_all_as_read": "Marked all as read",
"matches": "Matches",
"media_type": "Media type",
"memories": "Memories",
@ -1260,6 +1265,7 @@
"no_places": "No places",
"no_results": "No results",
"no_results_description": "Try a synonym or more general keyword",
"no_notifications": "No notifications",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"not_in_any_album": "Not in any album",
"not_selected": "Not selected",

View File

@ -1,25 +1,42 @@
package app.alextran.immich
import android.app.Activity
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log
import androidx.annotation.RequiresApi
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import java.security.MessageDigest
import java.io.FileInputStream
import kotlinx.coroutines.*
import androidx.core.net.toUri
/**
* Android plugin for Dart `BackgroundService`
*
* Receives messages/method calls from the foreground Dart side to manage
* the background service, e.g. start (enqueue), stop (cancel)
* Android plugin for Dart `BackgroundService` and file trash operations
*/
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
private var methodChannel: MethodChannel? = null
private var fileTrashChannel: MethodChannel? = null
private var context: Context? = null
private var pendingResult: Result? = null
private val permissionRequestCode = 1001
private val trashRequestCode = 1002
private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@ -29,6 +46,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this)
// Add file trash channel
fileTrashChannel = MethodChannel(messenger, "file_trash")
fileTrashChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@ -38,11 +59,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
fileTrashChannel?.setMethodCallHandler(null)
fileTrashChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
override fun onMethodCall(call: MethodCall, result: Result) {
val ctx = context!!
when (call.method) {
// Existing BackgroundService methods
"enable" -> {
val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@ -114,10 +138,184 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
// File Trash methods moved from MainActivity
"moveToTrash" -> {
val mediaUrls = call.argument<List<String>>("mediaUrls")
if (mediaUrls != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
moveToTrash(mediaUrls, result)
} else {
result.error("PERMISSION_DENIED", "Media permission required", null)
}
} else {
result.error("INVALID_NAME", "The mediaUrls is not specified.", null)
}
}
"restoreFromTrash" -> {
val fileName = call.argument<String>("fileName")
val type = call.argument<Int>("type")
if (fileName != null && type != null) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
restoreFromTrash(fileName, type, result)
} else {
result.error("PERMISSION_DENIED", "Media permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"requestManageMediaPermission" -> {
if (!hasManageMediaPermission()) {
requestManageMediaPermission(result)
} else {
Log.e("Manage storage permission", "Permission already granted")
result.success(true)
}
}
else -> result.notImplemented()
}
}
private fun hasManageMediaPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaStore.canManageMedia(context!!);
} else {
false
}
}
private fun requestManageMediaPermission(result: Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
pendingResult = result // Store the result callback
val activity = activityBinding?.activity ?: return
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA)
intent.data = "package:${activity.packageName}".toUri()
activity.startActivityForResult(intent, permissionRequestCode)
} else {
result.success(false)
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun moveToTrash(mediaUrls: List<String>, result: Result) {
val urisToTrash = mediaUrls.map { it.toUri() }
if (urisToTrash.isEmpty()) {
result.error("INVALID_ARGS", "No valid URIs provided", null)
return
}
toggleTrash(urisToTrash, true, result);
}
@RequiresApi(Build.VERSION_CODES.R)
private fun restoreFromTrash(name: String, type: Int, result: Result) {
val uri = getTrashedFileUri(name, type)
if (uri == null) {
Log.e("TrashError", "Asset Uri cannot be found obtained")
result.error("TrashError", "Asset Uri cannot be found obtained", null)
return
}
Log.e("FILE_URI", uri.toString())
uri.let { toggleTrash(listOf(it), false, result) }
}
@RequiresApi(Build.VERSION_CODES.R)
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
val activity = activityBinding?.activity
val contentResolver = context?.contentResolver
if (activity == null || contentResolver == null) {
result.error("TrashError", "Activity or ContentResolver not available", null)
return
}
try {
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
pendingResult = result // Store for onActivityResult
activity.startIntentSenderForResult(
pendingIntent.intentSender,
trashRequestCode,
null, 0, 0, 0
)
} catch (e: Exception) {
Log.e("TrashError", "Error creating or starting trash request", e)
result.error("TrashError", "Error creating or starting trash request", null)
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun getTrashedFileUri(fileName: String, type: Int): Uri? {
val contentResolver = context?.contentResolver ?: return null
val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
val queryArgs = Bundle().apply {
putString(
ContentResolver.QUERY_ARG_SQL_SELECTION,
"${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
)
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
}
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
// same order as AssetType from dart
val contentUri = when (type) {
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> queryUri
}
return ContentUris.withAppendedId(contentUri, id)
}
}
return null
}
// ActivityAware implementation
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivity() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
// ActivityResultListener implementation
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == permissionRequestCode) {
val granted = hasManageMediaPermission()
pendingResult?.success(granted)
pendingResult = null
return true
}
if (requestCode == trashRequestCode) {
val approved = resultCode == Activity.RESULT_OK
pendingResult?.success(approved)
pendingResult = null
return true
}
return false
}
}
private const val TAG = "BackgroundServicePlugin"
private const val BUFFER_SIZE = 2 * 1024 * 1024;
private const val BUFFER_SIZE = 2 * 1024 * 1024

View File

@ -2,14 +2,12 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle
import android.content.Intent
import androidx.annotation.NonNull
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
// No need to set up method channel here as it's now handled in the plugin
}
}

View File

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 195,
"android.injected.version.name" => "1.132.1",
"android.injected.version.code" => 197,
"android.injected.version.name" => "1.132.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@ -261,9 +261,11 @@
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
FAC6F88F2D287C890078CB2F = {
CreatedOnToolsVersion = 16.0;
ProvisioningStyle = Automatic;
};
};
};
@ -541,7 +543,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202;
CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -685,7 +687,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202;
CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -715,7 +717,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202;
CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -748,7 +750,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202;
CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -769,6 +771,7 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -791,7 +794,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202;
CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -811,6 +814,7 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -831,7 +835,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 202;
CURRENT_PROJECT_VERSION = 205;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -851,6 +855,7 @@
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;

View File

@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.132.0</string>
<string>1.132.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -93,7 +93,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>202</string>
<string>205</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@ -18,8 +18,11 @@ default_platform(:ios)
platform :ios do
desc "iOS Release"
lane :release do
enable_automatic_code_signing(
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.132.1"
version_number: "1.132.3"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@ -65,6 +65,7 @@ enum StoreKey<T> {
// Video settings
loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000);

View File

@ -0,0 +1,5 @@
abstract interface class ILocalFilesManager {
Future<bool> moveToTrash(List<String> mediaUrls);
Future<bool> restoreFromTrash(String fileName, int type);
Future<bool> requestManageMediaPermission();
}

View File

@ -1,7 +1,6 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
@ -13,7 +12,6 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
@ -357,66 +355,51 @@ class PlacesCollectionCard extends StatelessWidget {
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return FutureBuilder<(Position?, LocationPermission?)>(
future: MapUtils.checkPermAndGetLocation(
context: context,
silent: true,
return GestureDetector(
onTap: () => context.pushRoute(
PlacesCollectionRoute(
currentLocation: null,
),
),
builder: (context, snapshot) {
var position = snapshot.data?.$1;
return GestureDetector(
onTap: () => context.pushRoute(
PlacesCollectionRoute(
currentLocation: position != null
? LatLng(position.latitude, position.longitude)
: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: size,
width: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
color:
context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(
21.44950,
-157.91959,
),
showAttribution: false,
themeMode: context.isDarkTheme
? ThemeMode.dark
: ThemeMode.light,
),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: size,
width: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(20)),
color: context.colorScheme.secondaryContainer
.withAlpha(100),
),
child: IgnorePointer(
child: snapshot.connectionState ==
ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: MapThumbnail(
zoom: 8,
centre: LatLng(
position?.latitude ?? 21.44950,
position?.longitude ?? -157.91959,
),
showAttribution: false,
themeMode: context.isDarkTheme
? ThemeMode.dark
: ThemeMode.light,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'places'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'places'.tr(),
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
},
],
),
);
},
);

View File

@ -44,7 +44,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
}
}),
child: const Text(
'grant_permission',
'continue',
).tr(),
),
],

View File

@ -23,6 +23,7 @@ enum PendingAction {
assetDelete,
assetUploaded,
assetHidden,
assetTrash,
}
class PendingChange {
@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates);
@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_debounce.run(handlePendingChanges);
}
Future<void> _handlePendingTrashes() async {
final trashChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetTrash)
.toList();
if (trashChanges.isNotEmpty) {
List<String> remoteIds = trashChanges
.expand((a) => (a.value as List).map((e) => e.toString()))
.toList();
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
await _ref.read(assetProvider.notifier).getAllAsset();
state = state.copyWith(
pendingChanges: state.pendingChanges
.whereNot((c) => trashChanges.contains(c))
.toList(),
);
}
}
Future<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete)
@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
await _handlePendingUploaded();
await _handlePendingDeletes();
await _handlingPendingHidden();
await _handlePendingTrashes();
}
void _handleOnConfigUpdate(dynamic _) {
@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data);
void _handleOnAssetTrash(dynamic data) {
addPendingChange(PendingAction.assetTrash, data);
}
void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data);

View File

@ -0,0 +1,25 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/utils/local_files_manager.dart';
final localFilesManagerRepositoryProvider =
Provider((ref) => const LocalFilesManagerRepository());
class LocalFilesManagerRepository implements ILocalFilesManager {
const LocalFilesManagerRepository();
@override
Future<bool> moveToTrash(List<String> mediaUrls) async {
return await LocalFilesManager.moveToTrash(mediaUrls);
}
@override
Future<bool> restoreFromTrash(String fileName, int type) async {
return await LocalFilesManager.restoreFromTrash(fileName, type);
}
@override
Future<bool> requestManageMediaPermission() async {
return await LocalFilesManager.requestManageMediaPermission();
}
}

View File

@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
0,
),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -16,8 +17,10 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
@ -25,8 +28,10 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
ref.watch(userRepositoryProvider),
ref.watch(userServiceProvider),
ref.watch(etagRepositoryProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(localFilesManagerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider),
),
@ -69,6 +76,8 @@ class SyncService {
final IUserApiRepository _userApiRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
final AppSettingsService _appSettingsService;
final ILocalFilesManager _localFilesManager;
SyncService(
this._hashService,
@ -82,6 +91,8 @@ class SyncService {
this._userRepository,
this._userService,
this._eTagRepository,
this._appSettingsService,
this._localFilesManager,
this._partnerApiRepository,
this._userApiRepository,
);
@ -238,8 +249,22 @@ class SyncService {
return null;
}
Future<void> _moveToTrashMatchedAssets(Iterable<String> idsToDelete) async {
final List<Asset> localAssets = await _assetRepository.getAllLocal();
final List<Asset> matchedAssets = localAssets
.where((asset) => idsToDelete.contains(asset.remoteId))
.toList();
final mediaUrls = await Future.wait(
matchedAssets
.map((asset) => asset.local?.getMediaUrl() ?? Future.value(null)),
);
await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
}
/// Deletes remote-only assets, updates merged assets to be local-only
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) async {
return _assetRepository.transaction(() async {
await _assetRepository.deleteAllByRemoteId(
idsToDelete,
@ -249,6 +274,12 @@ class SyncService {
idsToDelete,
state: AssetState.merged,
);
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
await _moveToTrashMatchedAssets(idsToDelete);
}
if (merged.isEmpty) return;
for (final Asset asset in merged) {
asset.remoteId = null;
@ -790,10 +821,43 @@ class SyncService {
return (existing, toUpsert);
}
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
final trashMediaUrls = <String>[];
for (final asset in assetsList) {
if (asset.isTrashed) {
final mediaUrl = await asset.local?.getMediaUrl();
if (mediaUrl == null) {
_log.warning(
"Failed to get media URL for asset ${asset.name} while moving to trash",
);
continue;
}
trashMediaUrls.add(mediaUrl);
} else {
await _localFilesManager.restoreFromTrash(
asset.fileName,
asset.type.index,
);
}
}
if (trashMediaUrls.isNotEmpty) {
await _localFilesManager.moveToTrash(trashMediaUrls);
}
}
/// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) return;
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
_toggleTrashStatusForAssets(assets);
}
try {
await _assetRepository.transaction(() async {
await _assetRepository.updateAll(assets);

View File

@ -0,0 +1,38 @@
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
abstract final class LocalFilesManager {
static final Logger _logger = Logger('LocalFilesManager');
static const MethodChannel _channel = MethodChannel('file_trash');
static Future<bool> moveToTrash(List<String> mediaUrls) async {
try {
return await _channel
.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
} catch (e, s) {
_logger.warning('Error moving file to trash', e, s);
return false;
}
}
static Future<bool> restoreFromTrash(String fileName, int type) async {
try {
return await _channel.invokeMethod(
'restoreFromTrash',
{'fileName': fileName, 'type': type},
);
} catch (e, s) {
_logger.warning('Error restore file from trash', e, s);
return false;
}
}
static Future<bool> requestManageMediaPermission() async {
try {
return await _channel.invokeMethod('requestManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission', e, s);
return false;
}
}
}

View File

@ -3,7 +3,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
@ -17,6 +17,8 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 10;
@ -69,14 +71,45 @@ Future<void> _migrateDeviceAsset(Isar db) async {
: (await db.iOSDeviceAssets.where().findAll())
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
.toList();
final localAssets = (await db.assets
.where()
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
.findAll())
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
.toList();
debugPrint("Device Asset Ids length - ${ids.length}");
debugPrint("Local Asset Ids length - ${localAssets.length}");
final PermissionState ps = await PhotoManager.requestPermissionExtend();
if (!ps.hasAccess) {
if (kDebugMode) {
debugPrint(
"[MIGRATION] Photo library permission not granted. Skipping device asset migration.",
);
}
return;
}
List<_DeviceAsset> localAssets = [];
final List<AssetPathEntity> paths =
await PhotoManager.getAssetPathList(onlyAll: true);
if (paths.isEmpty) {
localAssets = (await db.assets
.where()
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
.findAll())
.map(
(a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt),
)
.toList();
} else {
final AssetPathEntity albumWithAll = paths.first;
final int assetCount = await albumWithAll.assetCountAsync;
final List<AssetEntity> allDeviceAssets =
await albumWithAll.getAssetListRange(start: 0, end: assetCount);
localAssets = allDeviceAssets
.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime))
.toList();
}
debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}");
debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}");
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
final List<DeviceAssetEntity> toAdd = [];
@ -95,15 +128,27 @@ Future<void> _migrateDeviceAsset(Isar db) async {
return false;
},
onlyFirst: (deviceAsset) {
debugPrint(
'DeviceAsset not found in local assets: ${deviceAsset.assetId}',
);
if (kDebugMode) {
debugPrint(
'[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}',
);
}
},
onlySecond: (asset) {
debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}');
if (kDebugMode) {
debugPrint(
'[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}',
);
}
},
);
debugPrint("Total number of device assets migrated - ${toAdd.length}");
if (kDebugMode) {
debugPrint(
"[MIGRATION] Total number of device assets migrated - ${toAdd.length}",
);
}
await db.writeTxn(() async {
await db.deviceAssetEntitys.putAll(toAdd);
});

View File

@ -97,6 +97,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
);
if (7 - scaleFactor.value.toInt() != perRow.value) {
perRow.value = 7 - scaleFactor.value.toInt();
settings.setSetting(AppSettingsEnum.tilesPerRow, perRow.value);
}
};
}),

View File

@ -755,7 +755,7 @@ class _MonthTitle extends StatelessWidget {
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 24.0),
child: Text(
title,
toBeginningOfSentenceCase(title, context.locale.languageCode),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w500,
@ -786,7 +786,7 @@ class _Title extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GroupDividerTitle(
text: title,
text: toBeginningOfSentenceCase(title, context.locale.languageCode),
multiselectEnabled: selectionActive,
onSelect: () => selectAssets(assets),
onDeselect: () => deselectAssets(assets),

View File

@ -207,9 +207,27 @@ class LoginForm extends HookConsumerWidget {
}
String generateRandomString(int length) {
const chars =
'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure();
return base64Url
.encode(List<int>.generate(32, (i) => random.nextInt(256)));
return String.fromCharCodes(
Iterable.generate(
length,
(_) => chars.codeUnitAt(random.nextInt(chars.length)),
),
);
}
List<int> randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (i) => random.nextInt(256));
}
/// Per specification, the code verifier must be 43-128 characters long
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
String randomCodeVerifier() {
return base64Url.encode(randomBytes(42));
}
Future<String> generatePKCECodeChallenge(String codeVerifier) async {
@ -223,7 +241,8 @@ class LoginForm extends HookConsumerWidget {
String? oAuthServerUrl;
final state = generateRandomString(32);
final codeVerifier = generateRandomString(64);
final codeVerifier = randomCodeVerifier();
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
try {

View File

@ -1,11 +1,13 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid =
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert =
@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
);
Future<bool> checkAndroidVersion() async {
if (Platform.isAndroid) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
int sdkVersion = androidInfo.version.sdkInt;
return sdkVersion >= 31;
}
return false;
}
final advancedSettings = [
SettingsSwitchListTile(
enabled: true,
@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
FutureBuilder<bool>(
future: checkAndroidVersion(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref
.read(localFilesManagerRepositoryProvider)
.requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
}
},
);
} else {
return const SizedBox.shrink();
}
},
),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId,

View File

@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.132.1
- API version: 1.132.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@ -145,8 +145,15 @@ Class | Method | HTTP request | Description
*MemoriesApi* | [**removeMemoryAssets**](doc//MemoriesApi.md#removememoryassets) | **DELETE** /memories/{id}/assets |
*MemoriesApi* | [**searchMemories**](doc//MemoriesApi.md#searchmemories) | **GET** /memories |
*MemoriesApi* | [**updateMemory**](doc//MemoriesApi.md#updatememory) | **PUT** /memories/{id} |
*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /notifications/admin/templates/{name} |
*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /notifications/admin/test-email |
*NotificationsApi* | [**deleteNotification**](doc//NotificationsApi.md#deletenotification) | **DELETE** /notifications/{id} |
*NotificationsApi* | [**deleteNotifications**](doc//NotificationsApi.md#deletenotifications) | **DELETE** /notifications |
*NotificationsApi* | [**getNotification**](doc//NotificationsApi.md#getnotification) | **GET** /notifications/{id} |
*NotificationsApi* | [**getNotifications**](doc//NotificationsApi.md#getnotifications) | **GET** /notifications |
*NotificationsApi* | [**updateNotification**](doc//NotificationsApi.md#updatenotification) | **PUT** /notifications/{id} |
*NotificationsApi* | [**updateNotifications**](doc//NotificationsApi.md#updatenotifications) | **PUT** /notifications |
*NotificationsAdminApi* | [**createNotification**](doc//NotificationsAdminApi.md#createnotification) | **POST** /admin/notifications |
*NotificationsAdminApi* | [**getNotificationTemplateAdmin**](doc//NotificationsAdminApi.md#getnotificationtemplateadmin) | **POST** /admin/notifications/templates/{name} |
*NotificationsAdminApi* | [**sendTestEmailAdmin**](doc//NotificationsAdminApi.md#sendtestemailadmin) | **POST** /admin/notifications/test-email |
*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |
*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
*OAuthApi* | [**redirectOAuthToMobile**](doc//OAuthApi.md#redirectoauthtomobile) | **GET** /oauth/mobile-redirect |
@ -300,7 +307,6 @@ Class | Method | HTTP request | Description
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md)
- [AvatarResponse](doc//AvatarResponse.md)
- [AvatarUpdate](doc//AvatarUpdate.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md)
@ -361,6 +367,13 @@ Class | Method | HTTP request | Description
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md)
- [NotificationCreateDto](doc//NotificationCreateDto.md)
- [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md)
- [NotificationDto](doc//NotificationDto.md)
- [NotificationLevel](doc//NotificationLevel.md)
- [NotificationType](doc//NotificationType.md)
- [NotificationUpdateAllDto](doc//NotificationUpdateAllDto.md)
- [NotificationUpdateDto](doc//NotificationUpdateDto.md)
- [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md)
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md)

View File

@ -44,6 +44,7 @@ part 'api/jobs_api.dart';
part 'api/libraries_api.dart';
part 'api/map_api.dart';
part 'api/memories_api.dart';
part 'api/notifications_api.dart';
part 'api/notifications_admin_api.dart';
part 'api/o_auth_api.dart';
part 'api/partners_api.dart';
@ -107,7 +108,6 @@ part 'model/asset_stack_response_dto.dart';
part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart';
part 'model/audio_codec.dart';
part 'model/avatar_response.dart';
part 'model/avatar_update.dart';
part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart';
@ -168,6 +168,13 @@ part 'model/memory_type.dart';
part 'model/memory_update_dto.dart';
part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart';
part 'model/notification_create_dto.dart';
part 'model/notification_delete_all_dto.dart';
part 'model/notification_dto.dart';
part 'model/notification_level.dart';
part 'model/notification_type.dart';
part 'model/notification_update_all_dto.dart';
part 'model/notification_update_dto.dart';
part 'model/o_auth_authorize_response_dto.dart';
part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart';

View File

@ -16,7 +16,54 @@ class NotificationsAdminApi {
final ApiClient apiClient;
/// Performs an HTTP 'POST /notifications/admin/templates/{name}' operation and returns the [Response].
/// Performs an HTTP 'POST /admin/notifications' operation and returns the [Response].
/// Parameters:
///
/// * [NotificationCreateDto] notificationCreateDto (required):
Future<Response> createNotificationWithHttpInfo(NotificationCreateDto notificationCreateDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/notifications';
// ignore: prefer_final_locals
Object? postBody = notificationCreateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [NotificationCreateDto] notificationCreateDto (required):
Future<NotificationDto?> createNotification(NotificationCreateDto notificationCreateDto,) async {
final response = await createNotificationWithHttpInfo(notificationCreateDto,);
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), 'NotificationDto',) as NotificationDto;
}
return null;
}
/// Performs an HTTP 'POST /admin/notifications/templates/{name}' operation and returns the [Response].
/// Parameters:
///
/// * [String] name (required):
@ -24,7 +71,7 @@ class NotificationsAdminApi {
/// * [TemplateDto] templateDto (required):
Future<Response> getNotificationTemplateAdminWithHttpInfo(String name, TemplateDto templateDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications/admin/templates/{name}'
final apiPath = r'/admin/notifications/templates/{name}'
.replaceAll('{name}', name);
// ignore: prefer_final_locals
@ -68,13 +115,13 @@ class NotificationsAdminApi {
return null;
}
/// Performs an HTTP 'POST /notifications/admin/test-email' operation and returns the [Response].
/// Performs an HTTP 'POST /admin/notifications/test-email' operation and returns the [Response].
/// Parameters:
///
/// * [SystemConfigSmtpDto] systemConfigSmtpDto (required):
Future<Response> sendTestEmailAdminWithHttpInfo(SystemConfigSmtpDto systemConfigSmtpDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications/admin/test-email';
final apiPath = r'/admin/notifications/test-email';
// ignore: prefer_final_locals
Object? postBody = systemConfigSmtpDto;

View File

@ -0,0 +1,311 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 NotificationsApi {
NotificationsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'DELETE /notifications/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteNotificationWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<void> deleteNotification(String id,) async {
final response = await deleteNotificationWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'DELETE /notifications' operation and returns the [Response].
/// Parameters:
///
/// * [NotificationDeleteAllDto] notificationDeleteAllDto (required):
Future<Response> deleteNotificationsWithHttpInfo(NotificationDeleteAllDto notificationDeleteAllDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications';
// ignore: prefer_final_locals
Object? postBody = notificationDeleteAllDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [NotificationDeleteAllDto] notificationDeleteAllDto (required):
Future<void> deleteNotifications(NotificationDeleteAllDto notificationDeleteAllDto,) async {
final response = await deleteNotificationsWithHttpInfo(notificationDeleteAllDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'GET /notifications/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> getNotificationWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<NotificationDto?> getNotification(String id,) async {
final response = await getNotificationWithHttpInfo(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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'NotificationDto',) as NotificationDto;
}
return null;
}
/// Performs an HTTP 'GET /notifications' operation and returns the [Response].
/// Parameters:
///
/// * [String] id:
///
/// * [NotificationLevel] level:
///
/// * [NotificationType] type:
///
/// * [bool] unread:
Future<Response> getNotificationsWithHttpInfo({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (level != null) {
queryParams.addAll(_queryParams('', 'level', level));
}
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
if (unread != null) {
queryParams.addAll(_queryParams('', 'unread', unread));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id:
///
/// * [NotificationLevel] level:
///
/// * [NotificationType] type:
///
/// * [bool] unread:
Future<List<NotificationDto>?> getNotifications({ String? id, NotificationLevel? level, NotificationType? type, bool? unread, }) async {
final response = await getNotificationsWithHttpInfo( id: id, level: level, type: type, unread: unread, );
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<NotificationDto>') as List)
.cast<NotificationDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'PUT /notifications/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [NotificationUpdateDto] notificationUpdateDto (required):
Future<Response> updateNotificationWithHttpInfo(String id, NotificationUpdateDto notificationUpdateDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = notificationUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [NotificationUpdateDto] notificationUpdateDto (required):
Future<NotificationDto?> updateNotification(String id, NotificationUpdateDto notificationUpdateDto,) async {
final response = await updateNotificationWithHttpInfo(id, notificationUpdateDto,);
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), 'NotificationDto',) as NotificationDto;
}
return null;
}
/// Performs an HTTP 'PUT /notifications' operation and returns the [Response].
/// Parameters:
///
/// * [NotificationUpdateAllDto] notificationUpdateAllDto (required):
Future<Response> updateNotificationsWithHttpInfo(NotificationUpdateAllDto notificationUpdateAllDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/notifications';
// ignore: prefer_final_locals
Object? postBody = notificationUpdateAllDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [NotificationUpdateAllDto] notificationUpdateAllDto (required):
Future<void> updateNotifications(NotificationUpdateAllDto notificationUpdateAllDto,) async {
final response = await updateNotificationsWithHttpInfo(notificationUpdateAllDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@ -270,8 +270,6 @@ class ApiClient {
return AssetTypeEnumTypeTransformer().decode(value);
case 'AudioCodec':
return AudioCodecTypeTransformer().decode(value);
case 'AvatarResponse':
return AvatarResponse.fromJson(value);
case 'AvatarUpdate':
return AvatarUpdate.fromJson(value);
case 'BulkIdResponseDto':
@ -392,6 +390,20 @@ class ApiClient {
return MergePersonDto.fromJson(value);
case 'MetadataSearchDto':
return MetadataSearchDto.fromJson(value);
case 'NotificationCreateDto':
return NotificationCreateDto.fromJson(value);
case 'NotificationDeleteAllDto':
return NotificationDeleteAllDto.fromJson(value);
case 'NotificationDto':
return NotificationDto.fromJson(value);
case 'NotificationLevel':
return NotificationLevelTypeTransformer().decode(value);
case 'NotificationType':
return NotificationTypeTypeTransformer().decode(value);
case 'NotificationUpdateAllDto':
return NotificationUpdateAllDto.fromJson(value);
case 'NotificationUpdateDto':
return NotificationUpdateDto.fromJson(value);
case 'OAuthAuthorizeResponseDto':
return OAuthAuthorizeResponseDto.fromJson(value);
case 'OAuthCallbackDto':

View File

@ -100,6 +100,12 @@ String parameterToString(dynamic value) {
if (value is MemoryType) {
return MemoryTypeTypeTransformer().encode(value).toString();
}
if (value is NotificationLevel) {
return NotificationLevelTypeTransformer().encode(value).toString();
}
if (value is NotificationType) {
return NotificationTypeTypeTransformer().encode(value).toString();
}
if (value is PartnerDirection) {
return PartnerDirectionTypeTransformer().encode(value).toString();
}

View File

@ -0,0 +1,180 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 NotificationCreateDto {
/// Returns a new [NotificationCreateDto] instance.
NotificationCreateDto({
this.data,
this.description,
this.level,
this.readAt,
required this.title,
this.type,
required this.userId,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Object? data;
String? description;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
NotificationLevel? level;
DateTime? readAt;
String title;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
NotificationType? type;
String userId;
@override
bool operator ==(Object other) => identical(this, other) || other is NotificationCreateDto &&
other.data == data &&
other.description == description &&
other.level == level &&
other.readAt == readAt &&
other.title == title &&
other.type == type &&
other.userId == userId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(data == null ? 0 : data!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(level == null ? 0 : level!.hashCode) +
(readAt == null ? 0 : readAt!.hashCode) +
(title.hashCode) +
(type == null ? 0 : type!.hashCode) +
(userId.hashCode);
@override
String toString() => 'NotificationCreateDto[data=$data, description=$description, level=$level, readAt=$readAt, title=$title, type=$type, userId=$userId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.data != null) {
json[r'data'] = this.data;
} else {
// json[r'data'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
if (this.level != null) {
json[r'level'] = this.level;
} else {
// json[r'level'] = null;
}
if (this.readAt != null) {
json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
} else {
// json[r'readAt'] = null;
}
json[r'title'] = this.title;
if (this.type != null) {
json[r'type'] = this.type;
} else {
// json[r'type'] = null;
}
json[r'userId'] = this.userId;
return json;
}
/// Returns a new [NotificationCreateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static NotificationCreateDto? fromJson(dynamic value) {
upgradeDto(value, "NotificationCreateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return NotificationCreateDto(
data: mapValueOfType<Object>(json, r'data'),
description: mapValueOfType<String>(json, r'description'),
level: NotificationLevel.fromJson(json[r'level']),
readAt: mapDateTime(json, r'readAt', r''),
title: mapValueOfType<String>(json, r'title')!,
type: NotificationType.fromJson(json[r'type']),
userId: mapValueOfType<String>(json, r'userId')!,
);
}
return null;
}
static List<NotificationCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationCreateDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationCreateDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, NotificationCreateDto> mapFromJson(dynamic json) {
final map = <String, NotificationCreateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = NotificationCreateDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of NotificationCreateDto-objects as value to a dart map
static Map<String, List<NotificationCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<NotificationCreateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = NotificationCreateDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'title',
'userId',
};
}

View File

@ -0,0 +1,101 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 NotificationDeleteAllDto {
/// Returns a new [NotificationDeleteAllDto] instance.
NotificationDeleteAllDto({
this.ids = const [],
});
List<String> ids;
@override
bool operator ==(Object other) => identical(this, other) || other is NotificationDeleteAllDto &&
_deepEquality.equals(other.ids, ids);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode);
@override
String toString() => 'NotificationDeleteAllDto[ids=$ids]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'ids'] = this.ids;
return json;
}
/// Returns a new [NotificationDeleteAllDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static NotificationDeleteAllDto? fromJson(dynamic value) {
upgradeDto(value, "NotificationDeleteAllDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return NotificationDeleteAllDto(
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
);
}
return null;
}
static List<NotificationDeleteAllDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationDeleteAllDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationDeleteAllDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, NotificationDeleteAllDto> mapFromJson(dynamic json) {
final map = <String, NotificationDeleteAllDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = NotificationDeleteAllDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of NotificationDeleteAllDto-objects as value to a dart map
static Map<String, List<NotificationDeleteAllDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<NotificationDeleteAllDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = NotificationDeleteAllDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'ids',
};
}

View File

@ -0,0 +1,182 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 NotificationDto {
/// Returns a new [NotificationDto] instance.
NotificationDto({
required this.createdAt,
this.data,
this.description,
required this.id,
required this.level,
this.readAt,
required this.title,
required this.type,
});
DateTime createdAt;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Object? data;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? description;
String id;
NotificationLevel level;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? readAt;
String title;
NotificationType type;
@override
bool operator ==(Object other) => identical(this, other) || other is NotificationDto &&
other.createdAt == createdAt &&
other.data == data &&
other.description == description &&
other.id == id &&
other.level == level &&
other.readAt == readAt &&
other.title == title &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(createdAt.hashCode) +
(data == null ? 0 : data!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(id.hashCode) +
(level.hashCode) +
(readAt == null ? 0 : readAt!.hashCode) +
(title.hashCode) +
(type.hashCode);
@override
String toString() => 'NotificationDto[createdAt=$createdAt, data=$data, description=$description, id=$id, level=$level, readAt=$readAt, title=$title, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
if (this.data != null) {
json[r'data'] = this.data;
} else {
// json[r'data'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
json[r'id'] = this.id;
json[r'level'] = this.level;
if (this.readAt != null) {
json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
} else {
// json[r'readAt'] = null;
}
json[r'title'] = this.title;
json[r'type'] = this.type;
return json;
}
/// Returns a new [NotificationDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static NotificationDto? fromJson(dynamic value) {
upgradeDto(value, "NotificationDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return NotificationDto(
createdAt: mapDateTime(json, r'createdAt', r'')!,
data: mapValueOfType<Object>(json, r'data'),
description: mapValueOfType<String>(json, r'description'),
id: mapValueOfType<String>(json, r'id')!,
level: NotificationLevel.fromJson(json[r'level'])!,
readAt: mapDateTime(json, r'readAt', r''),
title: mapValueOfType<String>(json, r'title')!,
type: NotificationType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<NotificationDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, NotificationDto> mapFromJson(dynamic json) {
final map = <String, NotificationDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = NotificationDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of NotificationDto-objects as value to a dart map
static Map<String, List<NotificationDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<NotificationDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = NotificationDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'createdAt',
'id',
'level',
'title',
'type',
};
}

View File

@ -0,0 +1,91 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 NotificationLevel {
/// Instantiate a new enum with the provided [value].
const NotificationLevel._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const success = NotificationLevel._(r'success');
static const error = NotificationLevel._(r'error');
static const warning = NotificationLevel._(r'warning');
static const info = NotificationLevel._(r'info');
/// List of all possible values in this [enum][NotificationLevel].
static const values = <NotificationLevel>[
success,
error,
warning,
info,
];
static NotificationLevel? fromJson(dynamic value) => NotificationLevelTypeTransformer().decode(value);
static List<NotificationLevel> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationLevel>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationLevel.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [NotificationLevel] to String,
/// and [decode] dynamic data back to [NotificationLevel].
class NotificationLevelTypeTransformer {
factory NotificationLevelTypeTransformer() => _instance ??= const NotificationLevelTypeTransformer._();
const NotificationLevelTypeTransformer._();
String encode(NotificationLevel data) => data.value;
/// Decodes a [dynamic value][data] to a NotificationLevel.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
NotificationLevel? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'success': return NotificationLevel.success;
case r'error': return NotificationLevel.error;
case r'warning': return NotificationLevel.warning;
case r'info': return NotificationLevel.info;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [NotificationLevelTypeTransformer] instance.
static NotificationLevelTypeTransformer? _instance;
}

View File

@ -0,0 +1,91 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 NotificationType {
/// Instantiate a new enum with the provided [value].
const NotificationType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const jobFailed = NotificationType._(r'JobFailed');
static const backupFailed = NotificationType._(r'BackupFailed');
static const systemMessage = NotificationType._(r'SystemMessage');
static const custom = NotificationType._(r'Custom');
/// List of all possible values in this [enum][NotificationType].
static const values = <NotificationType>[
jobFailed,
backupFailed,
systemMessage,
custom,
];
static NotificationType? fromJson(dynamic value) => NotificationTypeTypeTransformer().decode(value);
static List<NotificationType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [NotificationType] to String,
/// and [decode] dynamic data back to [NotificationType].
class NotificationTypeTypeTransformer {
factory NotificationTypeTypeTransformer() => _instance ??= const NotificationTypeTypeTransformer._();
const NotificationTypeTypeTransformer._();
String encode(NotificationType data) => data.value;
/// Decodes a [dynamic value][data] to a NotificationType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
NotificationType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'JobFailed': return NotificationType.jobFailed;
case r'BackupFailed': return NotificationType.backupFailed;
case r'SystemMessage': return NotificationType.systemMessage;
case r'Custom': return NotificationType.custom;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [NotificationTypeTypeTransformer] instance.
static NotificationTypeTypeTransformer? _instance;
}

View File

@ -0,0 +1,112 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 NotificationUpdateAllDto {
/// Returns a new [NotificationUpdateAllDto] instance.
NotificationUpdateAllDto({
this.ids = const [],
this.readAt,
});
List<String> ids;
DateTime? readAt;
@override
bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateAllDto &&
_deepEquality.equals(other.ids, ids) &&
other.readAt == readAt;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(readAt == null ? 0 : readAt!.hashCode);
@override
String toString() => 'NotificationUpdateAllDto[ids=$ids, readAt=$readAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'ids'] = this.ids;
if (this.readAt != null) {
json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
} else {
// json[r'readAt'] = null;
}
return json;
}
/// Returns a new [NotificationUpdateAllDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static NotificationUpdateAllDto? fromJson(dynamic value) {
upgradeDto(value, "NotificationUpdateAllDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return NotificationUpdateAllDto(
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
readAt: mapDateTime(json, r'readAt', r''),
);
}
return null;
}
static List<NotificationUpdateAllDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationUpdateAllDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = NotificationUpdateAllDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, NotificationUpdateAllDto> mapFromJson(dynamic json) {
final map = <String, NotificationUpdateAllDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = NotificationUpdateAllDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of NotificationUpdateAllDto-objects as value to a dart map
static Map<String, List<NotificationUpdateAllDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<NotificationUpdateAllDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = NotificationUpdateAllDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'ids',
};
}

View File

@ -10,52 +10,56 @@
part of openapi.api;
class AvatarResponse {
/// Returns a new [AvatarResponse] instance.
AvatarResponse({
required this.color,
class NotificationUpdateDto {
/// Returns a new [NotificationUpdateDto] instance.
NotificationUpdateDto({
this.readAt,
});
UserAvatarColor color;
DateTime? readAt;
@override
bool operator ==(Object other) => identical(this, other) || other is AvatarResponse &&
other.color == color;
bool operator ==(Object other) => identical(this, other) || other is NotificationUpdateDto &&
other.readAt == readAt;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(color.hashCode);
(readAt == null ? 0 : readAt!.hashCode);
@override
String toString() => 'AvatarResponse[color=$color]';
String toString() => 'NotificationUpdateDto[readAt=$readAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'color'] = this.color;
if (this.readAt != null) {
json[r'readAt'] = this.readAt!.toUtc().toIso8601String();
} else {
// json[r'readAt'] = null;
}
return json;
}
/// Returns a new [AvatarResponse] instance and imports its values from
/// Returns a new [NotificationUpdateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AvatarResponse? fromJson(dynamic value) {
upgradeDto(value, "AvatarResponse");
static NotificationUpdateDto? fromJson(dynamic value) {
upgradeDto(value, "NotificationUpdateDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AvatarResponse(
color: UserAvatarColor.fromJson(json[r'color'])!,
return NotificationUpdateDto(
readAt: mapDateTime(json, r'readAt', r''),
);
}
return null;
}
static List<AvatarResponse> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AvatarResponse>[];
static List<NotificationUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <NotificationUpdateDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AvatarResponse.fromJson(row);
final value = NotificationUpdateDto.fromJson(row);
if (value != null) {
result.add(value);
}
@ -64,12 +68,12 @@ class AvatarResponse {
return result.toList(growable: growable);
}
static Map<String, AvatarResponse> mapFromJson(dynamic json) {
final map = <String, AvatarResponse>{};
static Map<String, NotificationUpdateDto> mapFromJson(dynamic json) {
final map = <String, NotificationUpdateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AvatarResponse.fromJson(entry.value);
final value = NotificationUpdateDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
@ -78,14 +82,14 @@ class AvatarResponse {
return map;
}
// maps a json object with a list of AvatarResponse-objects as value to a dart map
static Map<String, List<AvatarResponse>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AvatarResponse>>{};
// maps a json object with a list of NotificationUpdateDto-objects as value to a dart map
static Map<String, List<NotificationUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<NotificationUpdateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AvatarResponse.listFromJson(entry.value, growable: growable,);
map[entry.key] = NotificationUpdateDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
@ -93,7 +97,6 @@ class AvatarResponse {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'color',
};
}

View File

@ -66,6 +66,10 @@ class Permission {
static const memoryPeriodRead = Permission._(r'memory.read');
static const memoryPeriodUpdate = Permission._(r'memory.update');
static const memoryPeriodDelete = Permission._(r'memory.delete');
static const notificationPeriodCreate = Permission._(r'notification.create');
static const notificationPeriodRead = Permission._(r'notification.read');
static const notificationPeriodUpdate = Permission._(r'notification.update');
static const notificationPeriodDelete = Permission._(r'notification.delete');
static const partnerPeriodCreate = Permission._(r'partner.create');
static const partnerPeriodRead = Permission._(r'partner.read');
static const partnerPeriodUpdate = Permission._(r'partner.update');
@ -147,6 +151,10 @@ class Permission {
memoryPeriodRead,
memoryPeriodUpdate,
memoryPeriodDelete,
notificationPeriodCreate,
notificationPeriodRead,
notificationPeriodUpdate,
notificationPeriodDelete,
partnerPeriodCreate,
partnerPeriodRead,
partnerPeriodUpdate,
@ -263,6 +271,10 @@ class PermissionTypeTransformer {
case r'memory.read': return Permission.memoryPeriodRead;
case r'memory.update': return Permission.memoryPeriodUpdate;
case r'memory.delete': return Permission.memoryPeriodDelete;
case r'notification.create': return Permission.notificationPeriodCreate;
case r'notification.read': return Permission.notificationPeriodRead;
case r'notification.update': return Permission.notificationPeriodUpdate;
case r'notification.delete': return Permission.notificationPeriodDelete;
case r'partner.create': return Permission.partnerPeriodCreate;
case r'partner.read': return Permission.partnerPeriodRead;
case r'partner.update': return Permission.partnerPeriodUpdate;

View File

@ -13,6 +13,7 @@ part of openapi.api;
class UserAdminCreateDto {
/// Returns a new [UserAdminCreateDto] instance.
UserAdminCreateDto({
this.avatarColor,
required this.email,
required this.name,
this.notify,
@ -22,6 +23,8 @@ class UserAdminCreateDto {
this.storageLabel,
});
UserAvatarColor? avatarColor;
String email;
String name;
@ -51,6 +54,7 @@ class UserAdminCreateDto {
@override
bool operator ==(Object other) => identical(this, other) || other is UserAdminCreateDto &&
other.avatarColor == avatarColor &&
other.email == email &&
other.name == name &&
other.notify == notify &&
@ -62,6 +66,7 @@ class UserAdminCreateDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatarColor == null ? 0 : avatarColor!.hashCode) +
(email.hashCode) +
(name.hashCode) +
(notify == null ? 0 : notify!.hashCode) +
@ -71,10 +76,15 @@ class UserAdminCreateDto {
(storageLabel == null ? 0 : storageLabel!.hashCode);
@override
String toString() => 'UserAdminCreateDto[email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
String toString() => 'UserAdminCreateDto[avatarColor=$avatarColor, email=$email, name=$name, notify=$notify, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.avatarColor != null) {
json[r'avatarColor'] = this.avatarColor;
} else {
// json[r'avatarColor'] = null;
}
json[r'email'] = this.email;
json[r'name'] = this.name;
if (this.notify != null) {
@ -110,6 +120,7 @@ class UserAdminCreateDto {
final json = value.cast<String, dynamic>();
return UserAdminCreateDto(
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
email: mapValueOfType<String>(json, r'email')!,
name: mapValueOfType<String>(json, r'name')!,
notify: mapValueOfType<bool>(json, r'notify'),

View File

@ -13,6 +13,7 @@ part of openapi.api;
class UserAdminUpdateDto {
/// Returns a new [UserAdminUpdateDto] instance.
UserAdminUpdateDto({
this.avatarColor,
this.email,
this.name,
this.password,
@ -21,6 +22,8 @@ class UserAdminUpdateDto {
this.storageLabel,
});
UserAvatarColor? avatarColor;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@ -60,6 +63,7 @@ class UserAdminUpdateDto {
@override
bool operator ==(Object other) => identical(this, other) || other is UserAdminUpdateDto &&
other.avatarColor == avatarColor &&
other.email == email &&
other.name == name &&
other.password == password &&
@ -70,6 +74,7 @@ class UserAdminUpdateDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatarColor == null ? 0 : avatarColor!.hashCode) +
(email == null ? 0 : email!.hashCode) +
(name == null ? 0 : name!.hashCode) +
(password == null ? 0 : password!.hashCode) +
@ -78,10 +83,15 @@ class UserAdminUpdateDto {
(storageLabel == null ? 0 : storageLabel!.hashCode);
@override
String toString() => 'UserAdminUpdateDto[email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
String toString() => 'UserAdminUpdateDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password, quotaSizeInBytes=$quotaSizeInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.avatarColor != null) {
json[r'avatarColor'] = this.avatarColor;
} else {
// json[r'avatarColor'] = null;
}
if (this.email != null) {
json[r'email'] = this.email;
} else {
@ -124,6 +134,7 @@ class UserAdminUpdateDto {
final json = value.cast<String, dynamic>();
return UserAdminUpdateDto(
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
email: mapValueOfType<String>(json, r'email'),
name: mapValueOfType<String>(json, r'name'),
password: mapValueOfType<String>(json, r'password'),

View File

@ -13,7 +13,6 @@ part of openapi.api;
class UserPreferencesResponseDto {
/// Returns a new [UserPreferencesResponseDto] instance.
UserPreferencesResponseDto({
required this.avatar,
required this.download,
required this.emailNotifications,
required this.folders,
@ -25,8 +24,6 @@ class UserPreferencesResponseDto {
required this.tags,
});
AvatarResponse avatar;
DownloadResponse download;
EmailNotificationsResponse emailNotifications;
@ -47,7 +44,6 @@ class UserPreferencesResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto &&
other.avatar == avatar &&
other.download == download &&
other.emailNotifications == emailNotifications &&
other.folders == folders &&
@ -61,7 +57,6 @@ class UserPreferencesResponseDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatar.hashCode) +
(download.hashCode) +
(emailNotifications.hashCode) +
(folders.hashCode) +
@ -73,11 +68,10 @@ class UserPreferencesResponseDto {
(tags.hashCode);
@override
String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
String toString() => 'UserPreferencesResponseDto[download=$download, emailNotifications=$emailNotifications, folders=$folders, memories=$memories, people=$people, purchase=$purchase, ratings=$ratings, sharedLinks=$sharedLinks, tags=$tags]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'avatar'] = this.avatar;
json[r'download'] = this.download;
json[r'emailNotifications'] = this.emailNotifications;
json[r'folders'] = this.folders;
@ -99,7 +93,6 @@ class UserPreferencesResponseDto {
final json = value.cast<String, dynamic>();
return UserPreferencesResponseDto(
avatar: AvatarResponse.fromJson(json[r'avatar'])!,
download: DownloadResponse.fromJson(json[r'download'])!,
emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!,
folders: FoldersResponse.fromJson(json[r'folders'])!,
@ -156,7 +149,6 @@ class UserPreferencesResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'avatar',
'download',
'emailNotifications',
'folders',

View File

@ -13,11 +13,14 @@ part of openapi.api;
class UserUpdateMeDto {
/// Returns a new [UserUpdateMeDto] instance.
UserUpdateMeDto({
this.avatarColor,
this.email,
this.name,
this.password,
});
UserAvatarColor? avatarColor;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@ -44,6 +47,7 @@ class UserUpdateMeDto {
@override
bool operator ==(Object other) => identical(this, other) || other is UserUpdateMeDto &&
other.avatarColor == avatarColor &&
other.email == email &&
other.name == name &&
other.password == password;
@ -51,15 +55,21 @@ class UserUpdateMeDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(avatarColor == null ? 0 : avatarColor!.hashCode) +
(email == null ? 0 : email!.hashCode) +
(name == null ? 0 : name!.hashCode) +
(password == null ? 0 : password!.hashCode);
@override
String toString() => 'UserUpdateMeDto[email=$email, name=$name, password=$password]';
String toString() => 'UserUpdateMeDto[avatarColor=$avatarColor, email=$email, name=$name, password=$password]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.avatarColor != null) {
json[r'avatarColor'] = this.avatarColor;
} else {
// json[r'avatarColor'] = null;
}
if (this.email != null) {
json[r'email'] = this.email;
} else {
@ -87,6 +97,7 @@ class UserUpdateMeDto {
final json = value.cast<String, dynamic>();
return UserUpdateMeDto(
avatarColor: UserAvatarColor.fromJson(json[r'avatarColor']),
email: mapValueOfType<String>(json, r'email'),
name: mapValueOfType<String>(json, r'name'),
password: mapValueOfType<String>(json, r'password'),

View File

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.132.1+195
version: 1.132.3+197
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@ -60,6 +60,9 @@ void main() {
final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
final MockAppSettingService appSettingService = MockAppSettingService();
final MockLocalFilesManagerRepository localFilesManagerRepository =
MockLocalFilesManagerRepository();
final MockPartnerApiRepository partnerApiRepository =
MockPartnerApiRepository();
final MockUserApiRepository userApiRepository = MockUserApiRepository();
@ -106,6 +109,8 @@ void main() {
userRepository,
userService,
eTagRepository,
appSettingService,
localFilesManagerRepository,
partnerApiRepository,
userApiRepository,
);

View File

@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:mocktail/mocktail.dart';
@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
class MockAuthRepository extends Mock implements IAuthRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {}
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
class MockPartnerRepository extends Mock implements IPartnerRepository {}
class MockLocalFilesManagerRepository extends Mock
implements ILocalFilesManager {}

View File

@ -1,5 +1,6 @@
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
@ -25,4 +26,6 @@ class MockNetworkService extends Mock implements NetworkService {}
class MockSearchApi extends Mock implements SearchApi {}
class MockAppSettingService extends Mock implements AppSettingsService {}
class MockBackgroundService extends Mock implements BackgroundService {}

View File

@ -206,6 +206,141 @@
]
}
},
"/admin/notifications": {
"post": {
"operationId": "createNotification",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications (Admin)"
]
}
},
"/admin/notifications/templates/{name}": {
"post": {
"operationId": "getNotificationTemplateAdmin",
"parameters": [
{
"name": "name",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications (Admin)"
]
}
},
"/admin/notifications/test-email": {
"post": {
"operationId": "sendTestEmailAdmin",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigSmtpDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestEmailResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications (Admin)"
]
}
},
"/admin/users": {
"get": {
"operationId": "searchUsersAdmin",
@ -3485,15 +3620,224 @@
]
}
},
"/notifications/admin/templates/{name}": {
"post": {
"operationId": "getNotificationTemplateAdmin",
"/notifications": {
"delete": {
"operationId": "deleteNotifications",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationDeleteAllDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
},
"get": {
"operationId": "getNotifications",
"parameters": [
{
"name": "name",
"name": "id",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "level",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/NotificationLevel"
}
},
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/NotificationType"
}
},
{
"name": "unread",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/NotificationDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
},
"put": {
"operationId": "updateNotifications",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationUpdateAllDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
}
},
"/notifications/{id}": {
"delete": {
"operationId": "deleteNotification",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
},
"get": {
"operationId": "getNotification",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications"
]
},
"put": {
"operationId": "updateNotification",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
@ -3502,7 +3846,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateDto"
"$ref": "#/components/schemas/NotificationUpdateDto"
}
}
},
@ -3513,7 +3857,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TemplateResponseDto"
"$ref": "#/components/schemas/NotificationDto"
}
}
},
@ -3532,49 +3876,7 @@
}
],
"tags": [
"Notifications (Admin)"
]
}
},
"/notifications/admin/test-email": {
"post": {
"operationId": "sendTestEmailAdmin",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigSmtpDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestEmailResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Notifications (Admin)"
"Notifications"
]
}
},
@ -7656,7 +7958,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.132.1",
"version": "1.132.3",
"contact": {}
},
"tags": [],
@ -8884,21 +9186,6 @@
],
"type": "string"
},
"AvatarResponse": {
"properties": {
"color": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
]
}
},
"required": [
"color"
],
"type": "object"
},
"AvatarUpdate": {
"properties": {
"color": {
@ -10341,6 +10628,157 @@
},
"type": "object"
},
"NotificationCreateDto": {
"properties": {
"data": {
"type": "object"
},
"description": {
"nullable": true,
"type": "string"
},
"level": {
"allOf": [
{
"$ref": "#/components/schemas/NotificationLevel"
}
]
},
"readAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"title": {
"type": "string"
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/NotificationType"
}
]
},
"userId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"title",
"userId"
],
"type": "object"
},
"NotificationDeleteAllDto": {
"properties": {
"ids": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"ids"
],
"type": "object"
},
"NotificationDto": {
"properties": {
"createdAt": {
"format": "date-time",
"type": "string"
},
"data": {
"type": "object"
},
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"level": {
"allOf": [
{
"$ref": "#/components/schemas/NotificationLevel"
}
]
},
"readAt": {
"format": "date-time",
"type": "string"
},
"title": {
"type": "string"
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/NotificationType"
}
]
}
},
"required": [
"createdAt",
"id",
"level",
"title",
"type"
],
"type": "object"
},
"NotificationLevel": {
"enum": [
"success",
"error",
"warning",
"info"
],
"type": "string"
},
"NotificationType": {
"enum": [
"JobFailed",
"BackupFailed",
"SystemMessage",
"Custom"
],
"type": "string"
},
"NotificationUpdateAllDto": {
"properties": {
"ids": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"readAt": {
"format": "date-time",
"nullable": true,
"type": "string"
}
},
"required": [
"ids"
],
"type": "object"
},
"NotificationUpdateDto": {
"properties": {
"readAt": {
"format": "date-time",
"nullable": true,
"type": "string"
}
},
"type": "object"
},
"OAuthAuthorizeResponseDto": {
"properties": {
"url": {
@ -10615,6 +11053,10 @@
"memory.read",
"memory.update",
"memory.delete",
"notification.create",
"notification.read",
"notification.update",
"notification.delete",
"partner.create",
"partner.read",
"partner.update",
@ -13621,6 +14063,14 @@
},
"UserAdminCreateDto": {
"properties": {
"avatarColor": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
],
"nullable": true
},
"email": {
"format": "email",
"type": "string"
@ -13763,6 +14213,14 @@
},
"UserAdminUpdateDto": {
"properties": {
"avatarColor": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
],
"nullable": true
},
"email": {
"format": "email",
"type": "string"
@ -13826,9 +14284,6 @@
},
"UserPreferencesResponseDto": {
"properties": {
"avatar": {
"$ref": "#/components/schemas/AvatarResponse"
},
"download": {
"$ref": "#/components/schemas/DownloadResponse"
},
@ -13858,7 +14313,6 @@
}
},
"required": [
"avatar",
"download",
"emailNotifications",
"folders",
@ -13952,6 +14406,14 @@
},
"UserUpdateMeDto": {
"properties": {
"avatarColor": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
],
"nullable": true
},
"email": {
"format": "email",
"type": "string"

View File

@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.132.1",
"version": "1.132.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.132.1",
"version": "1.132.3",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.132.1",
"version": "1.132.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@ -1,6 +1,6 @@
/**
* Immich
* 1.132.1
* 1.132.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@ -39,6 +39,48 @@ export type ActivityCreateDto = {
export type ActivityStatisticsResponseDto = {
comments: number;
};
export type NotificationCreateDto = {
data?: object;
description?: string | null;
level?: NotificationLevel;
readAt?: string | null;
title: string;
"type"?: NotificationType;
userId: string;
};
export type NotificationDto = {
createdAt: string;
data?: object;
description?: string;
id: string;
level: NotificationLevel;
readAt?: string;
title: string;
"type": NotificationType;
};
export type TemplateDto = {
template: string;
};
export type TemplateResponseDto = {
html: string;
name: string;
};
export type SystemConfigSmtpTransportDto = {
host: string;
ignoreCert: boolean;
password: string;
port: number;
username: string;
};
export type SystemConfigSmtpDto = {
enabled: boolean;
"from": string;
replyTo: string;
transport: SystemConfigSmtpTransportDto;
};
export type TestEmailResponseDto = {
messageId: string;
};
export type UserLicense = {
activatedAt: string;
activationKey: string;
@ -64,6 +106,7 @@ export type UserAdminResponseDto = {
updatedAt: string;
};
export type UserAdminCreateDto = {
avatarColor?: (UserAvatarColor) | null;
email: string;
name: string;
notify?: boolean;
@ -76,6 +119,7 @@ export type UserAdminDeleteDto = {
force?: boolean;
};
export type UserAdminUpdateDto = {
avatarColor?: (UserAvatarColor) | null;
email?: string;
name?: string;
password?: string;
@ -83,9 +127,6 @@ export type UserAdminUpdateDto = {
shouldChangePassword?: boolean;
storageLabel?: string | null;
};
export type AvatarResponse = {
color: UserAvatarColor;
};
export type DownloadResponse = {
archiveSize: number;
includeEmbeddedVideos: boolean;
@ -122,7 +163,6 @@ export type TagsResponse = {
sidebarWeb: boolean;
};
export type UserPreferencesResponseDto = {
avatar: AvatarResponse;
download: DownloadResponse;
emailNotifications: EmailNotificationsResponse;
folders: FoldersResponse;
@ -663,28 +703,15 @@ export type MemoryUpdateDto = {
memoryAt?: string;
seenAt?: string;
};
export type TemplateDto = {
template: string;
export type NotificationDeleteAllDto = {
ids: string[];
};
export type TemplateResponseDto = {
html: string;
name: string;
export type NotificationUpdateAllDto = {
ids: string[];
readAt?: string | null;
};
export type SystemConfigSmtpTransportDto = {
host: string;
ignoreCert: boolean;
password: string;
port: number;
username: string;
};
export type SystemConfigSmtpDto = {
enabled: boolean;
"from": string;
replyTo: string;
transport: SystemConfigSmtpTransportDto;
};
export type TestEmailResponseDto = {
messageId: string;
export type NotificationUpdateDto = {
readAt?: string | null;
};
export type OAuthConfigDto = {
codeChallenge?: string;
@ -1388,6 +1415,7 @@ export type TrashResponseDto = {
count: number;
};
export type UserUpdateMeDto = {
avatarColor?: (UserAvatarColor) | null;
email?: string;
name?: string;
password?: string;
@ -1454,6 +1482,43 @@ export function deleteActivity({ id }: {
method: "DELETE"
}));
}
export function createNotification({ notificationCreateDto }: {
notificationCreateDto: NotificationCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: NotificationDto;
}>("/admin/notifications", oazapfts.json({
...opts,
method: "POST",
body: notificationCreateDto
})));
}
export function getNotificationTemplateAdmin({ name, templateDto }: {
name: string;
templateDto: TemplateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TemplateResponseDto;
}>(`/admin/notifications/templates/${encodeURIComponent(name)}`, oazapfts.json({
...opts,
method: "POST",
body: templateDto
})));
}
export function sendTestEmailAdmin({ systemConfigSmtpDto }: {
systemConfigSmtpDto: SystemConfigSmtpDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TestEmailResponseDto;
}>("/admin/notifications/test-email", oazapfts.json({
...opts,
method: "POST",
body: systemConfigSmtpDto
})));
}
export function searchUsersAdmin({ withDeleted }: {
withDeleted?: boolean;
}, opts?: Oazapfts.RequestOpts) {
@ -2322,29 +2387,71 @@ export function addMemoryAssets({ id, bulkIdsDto }: {
body: bulkIdsDto
})));
}
export function getNotificationTemplateAdmin({ name, templateDto }: {
name: string;
templateDto: TemplateDto;
export function deleteNotifications({ notificationDeleteAllDto }: {
notificationDeleteAllDto: NotificationDeleteAllDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TemplateResponseDto;
}>(`/notifications/admin/templates/${encodeURIComponent(name)}`, oazapfts.json({
return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({
...opts,
method: "POST",
body: templateDto
method: "DELETE",
body: notificationDeleteAllDto
})));
}
export function sendTestEmailAdmin({ systemConfigSmtpDto }: {
systemConfigSmtpDto: SystemConfigSmtpDto;
export function getNotifications({ id, level, $type, unread }: {
id?: string;
level?: NotificationLevel;
$type?: NotificationType;
unread?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TestEmailResponseDto;
}>("/notifications/admin/test-email", oazapfts.json({
data: NotificationDto[];
}>(`/notifications${QS.query(QS.explode({
id,
level,
"type": $type,
unread
}))}`, {
...opts
}));
}
export function updateNotifications({ notificationUpdateAllDto }: {
notificationUpdateAllDto: NotificationUpdateAllDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/notifications", oazapfts.json({
...opts,
method: "POST",
body: systemConfigSmtpDto
method: "PUT",
body: notificationUpdateAllDto
})));
}
export function deleteNotification({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/notifications/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
export function getNotification({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: NotificationDto;
}>(`/notifications/${encodeURIComponent(id)}`, {
...opts
}));
}
export function updateNotification({ id, notificationUpdateDto }: {
id: string;
notificationUpdateDto: NotificationUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: NotificationDto;
}>(`/notifications/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PUT",
body: notificationUpdateDto
})));
}
export function startOAuth({ oAuthConfigDto }: {
@ -3453,6 +3560,18 @@ export enum UserAvatarColor {
Gray = "gray",
Amber = "amber"
}
export enum NotificationLevel {
Success = "success",
Error = "error",
Warning = "warning",
Info = "info"
}
export enum NotificationType {
JobFailed = "JobFailed",
BackupFailed = "BackupFailed",
SystemMessage = "SystemMessage",
Custom = "Custom"
}
export enum UserStatus {
Active = "active",
Removing = "removing",
@ -3527,6 +3646,10 @@ export enum Permission {
MemoryRead = "memory.read",
MemoryUpdate = "memory.update",
MemoryDelete = "memory.delete",
NotificationCreate = "notification.create",
NotificationRead = "notification.read",
NotificationUpdate = "notification.update",
NotificationDelete = "notification.delete",
PartnerCreate = "partner.create",
PartnerRead = "partner.read",
PartnerUpdate = "partner.update",

View File

@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.132.1",
"version": "1.132.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.132.1",
"version": "1.132.3",
"hasInstallScript": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@ -32,6 +32,7 @@
"chokidar": "^3.5.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"compression": "^1.8.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1",
@ -83,6 +84,7 @@
"@types/archiver": "^6.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^5.0.0",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21",
@ -5009,6 +5011,16 @@
"@types/node": "*"
}
},
"node_modules/@types/compression": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz",
"integrity": "sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@ -7603,6 +7615,60 @@
"node": ">= 14"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz",
"integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.0.2",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.132.1",
"version": "1.132.3",
"description": "",
"author": "",
"private": true,
@ -57,6 +57,7 @@
"chokidar": "^3.5.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"compression": "^1.8.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1",
@ -108,6 +109,7 @@
"@types/archiver": "^6.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^5.0.0",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21",

View File

@ -44,7 +44,7 @@ const imports = [
BullModule.registerQueue(...bull.queues),
ClsModule.forRoot(cls.config),
OpenTelemetryModule.forRoot(otel),
KyselyModule.forRoot(getKyselyConfig(database.config.kysely)),
KyselyModule.forRoot(getKyselyConfig(database.config)),
];
class BaseModule implements OnModuleInit, OnModuleDestroy {

View File

@ -10,7 +10,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
import { getKyselyConfig } from 'src/utils/database';
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
const main = async () => {
const command = process.argv[2];
@ -56,7 +56,7 @@ const main = async () => {
const getDatabaseClient = () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
return new Kysely<any>(getKyselyConfig(database.config.kysely));
return new Kysely<any>(getKyselyConfig(database.config));
};
const runQuery = async (query: string) => {
@ -105,7 +105,7 @@ const create = (path: string, up: string[], down: string[]) => {
const compare = async () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
const db = postgres(database.config.kysely);
const db = postgres(asPostgresConnectionConfig(database.config));
const source = schemaFromCode();
const target = await schemaFromDatabase(db, {});

View File

@ -78,7 +78,7 @@ class SqlGenerator {
const moduleFixture = await Test.createTestingModule({
imports: [
KyselyModule.forRoot({
...getKyselyConfig(database.config.kysely),
...getKyselyConfig(database.config),
log: (event) => {
if (event.level === 'query') {
this.sqlLogger.logQuery(event.query.sql);

View File

@ -14,6 +14,7 @@ import { LibraryController } from 'src/controllers/library.controller';
import { MapController } from 'src/controllers/map.controller';
import { MemoryController } from 'src/controllers/memory.controller';
import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
import { NotificationController } from 'src/controllers/notification.controller';
import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
@ -47,6 +48,7 @@ export const controllers = [
LibraryController,
MapController,
MemoryController,
NotificationController,
NotificationAdminController,
OAuthController,
PartnerController,

View File

@ -1,16 +1,28 @@
import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { TemplateDto, TemplateResponseDto, TestEmailResponseDto } from 'src/dtos/notification.dto';
import {
NotificationCreateDto,
NotificationDto,
TemplateDto,
TemplateResponseDto,
TestEmailResponseDto,
} from 'src/dtos/notification.dto';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { EmailTemplate } from 'src/repositories/email.repository';
import { NotificationService } from 'src/services/notification.service';
import { NotificationAdminService } from 'src/services/notification-admin.service';
@ApiTags('Notifications (Admin)')
@Controller('notifications/admin')
@Controller('admin/notifications')
export class NotificationAdminController {
constructor(private service: NotificationService) {}
constructor(private service: NotificationAdminService) {}
@Post()
@Authenticated({ admin: true })
createNotification(@Auth() auth: AuthDto, @Body() dto: NotificationCreateDto): Promise<NotificationDto> {
return this.service.create(auth, dto);
}
@Post('test-email')
@HttpCode(HttpStatus.OK)

View File

@ -0,0 +1,60 @@
import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import {
NotificationDeleteAllDto,
NotificationDto,
NotificationSearchDto,
NotificationUpdateAllDto,
NotificationUpdateDto,
} from 'src/dtos/notification.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { NotificationService } from 'src/services/notification.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Notifications')
@Controller('notifications')
export class NotificationController {
constructor(private service: NotificationService) {}
@Get()
@Authenticated({ permission: Permission.NOTIFICATION_READ })
getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise<NotificationDto[]> {
return this.service.search(auth, dto);
}
@Put()
@Authenticated({ permission: Permission.NOTIFICATION_UPDATE })
updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@Authenticated({ permission: Permission.NOTIFICATION_DELETE })
deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ permission: Permission.NOTIFICATION_READ })
getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<NotificationDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.NOTIFICATION_UPDATE })
updateNotification(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: NotificationUpdateDto,
): Promise<NotificationDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.NOTIFICATION_DELETE })
deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}

View File

@ -90,7 +90,7 @@ export class StorageCore {
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
}
static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: ImageFormat) {
static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') {
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}-${type}.${format}`);
}

View File

@ -9,6 +9,7 @@ import {
Permission,
SharedLinkType,
SourceType,
UserAvatarColor,
UserStatus,
} from 'src/enum';
import { OnThisDayData, UserMetadataItem } from 'src/types';
@ -122,6 +123,7 @@ export type User = {
id: string;
name: string;
email: string;
avatarColor: UserAvatarColor | null;
profileImagePath: string;
profileChangedAt: Date;
};
@ -264,7 +266,15 @@ export type AssetFace = {
person?: Person | null;
};
const userColumns = ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'] as const;
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
const userWithPrefixColumns = [
'users.id',
'users.name',
'users.email',
'users.avatarColor',
'users.profileImagePath',
'users.profileChangedAt',
] as const;
export const columns = {
asset: [
@ -306,7 +316,7 @@ export const columns = {
'shared_links.password',
],
user: userColumns,
userWithPrefix: ['users.id', 'users.name', 'users.email', 'users.profileImagePath', 'users.profileChangedAt'],
userWithPrefix: userWithPrefixColumns,
userAdmin: [
...userColumns,
'createdAt',
@ -323,6 +333,7 @@ export const columns = {
],
tag: ['tags.id', 'tags.value', 'tags.createdAt', 'tags.updatedAt', 'tags.color', 'tags.parentId'],
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
syncAsset: [
'id',
'ownerId',

18
server/src/db.d.ts vendored
View File

@ -11,6 +11,8 @@ import {
AssetStatus,
AssetType,
MemoryType,
NotificationLevel,
NotificationType,
Permission,
SharedLinkType,
SourceType,
@ -263,6 +265,21 @@ export interface Memories {
updateId: Generated<string>;
}
export interface Notifications {
id: Generated<string>;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
updateId: Generated<string>;
userId: string;
level: Generated<NotificationLevel>;
type: NotificationType;
title: string;
description: string | null;
data: any | null;
readAt: Timestamp | null;
}
export interface MemoriesAssetsAssets {
assetsId: string;
memoriesId: string;
@ -463,6 +480,7 @@ export interface DB {
memories: Memories;
memories_assets_assets: MemoriesAssetsAssets;
migrations: Migrations;
notifications: Notifications;
move_history: MoveHistory;
naturalearth_countries: NaturalearthCountries;
partners_audit: PartnersAudit;

View File

@ -1,4 +1,7 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsString } from 'class-validator';
import { NotificationLevel, NotificationType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
export class TestEmailResponseDto {
messageId!: string;
@ -11,3 +14,106 @@ export class TemplateDto {
@IsString()
template!: string;
}
export class NotificationDto {
id!: string;
@ValidateDate()
createdAt!: Date;
@ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' })
level!: NotificationLevel;
@ApiProperty({ enum: NotificationType, enumName: 'NotificationType' })
type!: NotificationType;
title!: string;
description?: string;
data?: any;
readAt?: Date;
}
export class NotificationSearchDto {
@Optional()
@ValidateUUID({ optional: true })
id?: string;
@IsEnum(NotificationLevel)
@Optional()
@ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' })
level?: NotificationLevel;
@IsEnum(NotificationType)
@Optional()
@ApiProperty({ enum: NotificationType, enumName: 'NotificationType' })
type?: NotificationType;
@ValidateBoolean({ optional: true })
unread?: boolean;
}
export class NotificationCreateDto {
@Optional()
@IsEnum(NotificationLevel)
@ApiProperty({ enum: NotificationLevel, enumName: 'NotificationLevel' })
level?: NotificationLevel;
@IsEnum(NotificationType)
@Optional()
@ApiProperty({ enum: NotificationType, enumName: 'NotificationType' })
type?: NotificationType;
@IsString()
title!: string;
@IsString()
@Optional({ nullable: true })
description?: string | null;
@Optional({ nullable: true })
data?: any;
@ValidateDate({ optional: true, nullable: true })
readAt?: Date | null;
@ValidateUUID()
userId!: string;
}
export class NotificationUpdateDto {
@ValidateDate({ optional: true, nullable: true })
readAt?: Date | null;
}
export class NotificationUpdateAllDto {
@ValidateUUID({ each: true, optional: true })
ids!: string[];
@ValidateDate({ optional: true, nullable: true })
readAt?: Date | null;
}
export class NotificationDeleteAllDto {
@ValidateUUID({ each: true })
ids!: string[];
}
export type MapNotification = {
id: string;
createdAt: Date;
updateId?: string;
level: NotificationLevel;
type: NotificationType;
data: any | null;
title: string;
description: string | null;
readAt: Date | null;
};
export const mapNotification = (notification: MapNotification): NotificationDto => {
return {
id: notification.id,
createdAt: notification.createdAt,
level: notification.level,
type: notification.type,
title: notification.title,
description: notification.description ?? undefined,
data: notification.data ?? undefined,
readAt: notification.readAt ?? undefined,
};
};

View File

@ -137,11 +137,6 @@ export class UserPreferencesUpdateDto {
purchase?: PurchaseUpdate;
}
class AvatarResponse {
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
color!: UserAvatarColor;
}
class RatingsResponse {
enabled: boolean = false;
}
@ -195,7 +190,6 @@ export class UserPreferencesResponseDto implements UserPreferences {
ratings!: RatingsResponse;
sharedLinks!: SharedLinksResponse;
tags!: TagsResponse;
avatar!: AvatarResponse;
emailNotifications!: EmailNotificationsResponse;
download!: DownloadResponse;
purchase!: PurchaseResponse;

View File

@ -1,10 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
import { getPreferences } from 'src/utils/preferences';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
export class UserUpdateMeDto {
@ -23,6 +22,11 @@ export class UserUpdateMeDto {
@IsString()
@IsNotEmpty()
name?: string;
@Optional({ nullable: true })
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor | null;
}
export class UserResponseDto {
@ -41,13 +45,21 @@ export class UserLicense {
activatedAt!: Date;
}
const emailToAvatarColor = (email: string): UserAvatarColor => {
const values = Object.values(UserAvatarColor);
const randomIndex = Math.floor(
[...email].map((letter) => letter.codePointAt(0) ?? 0).reduce((a, b) => a + b, 0) % values.length,
);
return values[randomIndex];
};
export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
return {
id: entity.id,
email: entity.email,
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity.email, (entity as UserAdmin).metadata || []).avatar.color,
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
profileChangedAt: entity.profileChangedAt,
};
};
@ -69,6 +81,11 @@ export class UserAdminCreateDto {
@IsString()
name!: string;
@Optional({ nullable: true })
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor | null;
@Optional({ nullable: true })
@IsString()
@Transform(toSanitized)
@ -104,6 +121,11 @@ export class UserAdminUpdateDto {
@IsNotEmpty()
name?: string;
@Optional({ nullable: true })
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor | null;
@Optional({ nullable: true })
@IsString()
@Transform(toSanitized)

View File

@ -126,6 +126,11 @@ export enum Permission {
MEMORY_UPDATE = 'memory.update',
MEMORY_DELETE = 'memory.delete',
NOTIFICATION_CREATE = 'notification.create',
NOTIFICATION_READ = 'notification.read',
NOTIFICATION_UPDATE = 'notification.update',
NOTIFICATION_DELETE = 'notification.delete',
PARTNER_CREATE = 'partner.create',
PARTNER_READ = 'partner.read',
PARTNER_UPDATE = 'partner.update',
@ -332,6 +337,11 @@ export enum ImageFormat {
WEBP = 'webp',
}
export enum RawExtractedFormat {
JPEG = 'jpeg',
JXL = 'jxl',
}
export enum LogLevel {
VERBOSE = 'verbose',
DEBUG = 'debug',
@ -515,6 +525,7 @@ export enum JobName {
NOTIFY_SIGNUP = 'notify-signup',
NOTIFY_ALBUM_INVITE = 'notify-album-invite',
NOTIFY_ALBUM_UPDATE = 'notify-album-update',
NOTIFICATIONS_CLEANUP = 'notifications-cleanup',
SEND_EMAIL = 'notification-send-email',
// Version check
@ -580,3 +591,17 @@ export enum SyncEntityType {
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
PartnerAssetExifV1 = 'PartnerAssetExifV1',
}
export enum NotificationLevel {
Success = 'success',
Error = 'error',
Warning = 'warning',
Info = 'info',
}
export enum NotificationType {
JobFailed = 'JobFailed',
BackupFailed = 'BackupFailed',
SystemMessage = 'SystemMessage',
Custom = 'Custom',
}

View File

@ -157,6 +157,15 @@ where
and "memories"."ownerId" = $2
and "memories"."deletedAt" is null
-- AccessRepository.notification.checkOwnerAccess
select
"notifications"."id"
from
"notifications"
where
"notifications"."id" in ($1)
and "notifications"."userId" = $2
-- AccessRepository.person.checkOwnerAccess
select
"person"."id"

View File

@ -13,6 +13,7 @@ from
"users"."id",
"users"."name",
"users"."email",
"users"."avatarColor",
"users"."profileImagePath",
"users"."profileChangedAt"
from
@ -44,6 +45,7 @@ returning
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from

View File

@ -12,6 +12,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -36,6 +37,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -100,6 +102,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -124,6 +127,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -191,6 +195,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -215,6 +220,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -269,6 +275,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -292,6 +299,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -353,6 +361,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from

View File

@ -259,6 +259,130 @@ from
where
"assets"."id" = $2
-- AssetJobRepository.getForSyncAssets
select
"assets"."id",
"assets"."isOffline",
"assets"."libraryId",
"assets"."originalPath",
"assets"."status",
"assets"."fileModifiedAt"
from
"assets"
where
"assets"."id" = any ($1::uuid[])
-- AssetJobRepository.getForAssetDeletion
select
"assets"."id",
"assets"."isVisible",
"assets"."libraryId",
"assets"."ownerId",
"assets"."livePhotoVideoId",
"assets"."sidecarPath",
"assets"."encodedVideoPath",
"assets"."originalPath",
to_json("exif") as "exifInfo",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_faces".*,
"person" as "person"
from
"asset_faces"
left join lateral (
select
"person".*
from
"person"
where
"asset_faces"."personId" = "person"."id"
) as "person" on true
where
"asset_faces"."assetId" = "assets"."id"
and "asset_faces"."deletedAt" is null
) as agg
) as "faces",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset_files"."id",
"asset_files"."path",
"asset_files"."type"
from
"asset_files"
where
"asset_files"."assetId" = "assets"."id"
) as agg
) as "files",
to_json("stacked_assets") as "stack"
from
"assets"
left join "exif" on "assets"."id" = "exif"."assetId"
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
left join lateral (
select
"asset_stack"."id",
"asset_stack"."primaryAssetId",
array_agg("stacked") as "assets"
from
"assets" as "stacked"
where
"stacked"."deletedAt" is not null
and "stacked"."isArchived" = $1
and "stacked"."stackId" = "asset_stack"."id"
group by
"asset_stack"."id"
) as "stacked_assets" on "asset_stack"."id" is not null
where
"assets"."id" = $2
-- AssetJobRepository.streamForVideoConversion
select
"assets"."id"
from
"assets"
where
"assets"."type" = $1
and (
"assets"."encodedVideoPath" is null
or "assets"."encodedVideoPath" = $2
)
and "assets"."isVisible" = $3
and "assets"."deletedAt" is null
-- AssetJobRepository.getForVideoConversion
select
"assets"."id",
"assets"."ownerId",
"assets"."originalPath",
"assets"."encodedVideoPath"
from
"assets"
where
"assets"."id" = $1
and "assets"."type" = $2
-- AssetJobRepository.streamForMetadataExtraction
select
"assets"."id"
from
"assets"
left join "asset_job_status" on "asset_job_status"."assetId" = "assets"."id"
where
(
"asset_job_status"."metadataExtractedAt" is null
or "asset_job_status"."assetId" is null
)
and "assets"."isVisible" = $1
and "assets"."deletedAt" is null
-- AssetJobRepository.getForStorageTemplateJob
select
"assets"."id",

View File

@ -0,0 +1,58 @@
-- NOTE: This file is auto generated by ./sql-generator
-- NotificationRepository.cleanup
delete from "notifications"
where
(
(
"deletedAt" is not null
and "deletedAt" < $1
)
or (
"readAt" > $2
and "createdAt" < $3
)
or (
"readAt" = $4
and "createdAt" < $5
)
)
-- NotificationRepository.search
select
"id",
"createdAt",
"level",
"type",
"title",
"description",
"data",
"readAt"
from
"notifications"
where
"userId" = $1
and "deletedAt" is null
order by
"createdAt" desc
-- NotificationRepository.search (unread)
select
"id",
"createdAt",
"level",
"type",
"title",
"description",
"data",
"readAt"
from
"notifications"
where
(
"userId" = $1
and "readAt" is null
)
and "deletedAt" is null
order by
"createdAt" desc

View File

@ -12,6 +12,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -29,6 +30,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -61,6 +63,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -78,6 +81,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -112,6 +116,7 @@ returning
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -129,6 +134,7 @@ returning
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -156,6 +162,7 @@ returning
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
@ -173,6 +180,7 @@ returning
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from

View File

@ -5,6 +5,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"createdAt",
@ -43,6 +44,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"createdAt",
@ -90,6 +92,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"createdAt",
@ -128,6 +131,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"createdAt",
@ -152,6 +156,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"createdAt",
@ -198,6 +203,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"createdAt",
@ -235,6 +241,7 @@ select
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt",
"createdAt",

View File

@ -279,6 +279,26 @@ class AuthDeviceAccess {
}
}
class NotificationAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, notificationIds: Set<string>) {
if (notificationIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('notifications')
.select('notifications.id')
.where('notifications.id', 'in', [...notificationIds])
.where('notifications.userId', '=', userId)
.execute()
.then((stacks) => new Set(stacks.map((stack) => stack.id)));
}
}
class StackAccess {
constructor(private db: Kysely<DB>) {}
@ -426,6 +446,7 @@ export class AccessRepository {
asset: AssetAccess;
authDevice: AuthDeviceAccess;
memory: MemoryAccess;
notification: NotificationAccess;
person: PersonAccess;
partner: PartnerAccess;
stack: StackAccess;
@ -438,6 +459,7 @@ export class AccessRepository {
this.asset = new AssetAccess(db);
this.authDevice = new AuthDeviceAccess(db);
this.memory = new MemoryAccess(db);
this.notification = new NotificationAccess(db);
this.person = new PersonAccess(db);
this.partner = new PartnerAccess(db);
this.stack = new StackAccess(db);

View File

@ -2,12 +2,21 @@ import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { Asset, columns } from 'src/database';
import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType } from 'src/enum';
import { AssetFileType, AssetType } from 'src/enum';
import { StorageAsset } from 'src/types';
import { anyUuid, asUuid, withExifInner, withFaces, withFiles } from 'src/utils/database';
import {
anyUuid,
asUuid,
toJson,
withExif,
withExifInner,
withFaces,
withFacesAndPeople,
withFiles,
} from 'src/utils/database';
@Injectable()
export class AssetJobRepository {
@ -148,6 +157,7 @@ export class AssetJobRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
getForSyncAssets(ids: string[]) {
return this.db
.selectFrom('assets')
@ -163,6 +173,84 @@ export class AssetJobRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForAssetDeletion(id: string) {
return this.db
.selectFrom('assets')
.select([
'assets.id',
'assets.isVisible',
'assets.libraryId',
'assets.ownerId',
'assets.livePhotoVideoId',
'assets.sidecarPath',
'assets.encodedVideoPath',
'assets.originalPath',
])
.$call(withExif)
.select(withFacesAndPeople)
.select(withFiles)
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('assets as stacked')
.select(['asset_stack.id', 'asset_stack.primaryAssetId'])
.select((eb) => eb.fn<Asset[]>('array_agg', [eb.table('stacked')]).as('assets'))
.where('stacked.deletedAt', 'is not', null)
.where('stacked.isArchived', '=', false)
.whereRef('stacked.stackId', '=', 'asset_stack.id')
.groupBy('asset_stack.id')
.as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null),
)
.select((eb) => toJson(eb, 'stacked_assets').as('stack'))
.where('assets.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForVideoConversion(force?: boolean) {
return this.db
.selectFrom('assets')
.select(['assets.id'])
.where('assets.type', '=', AssetType.VIDEO)
.$if(!force, (qb) =>
qb
.where((eb) => eb.or([eb('assets.encodedVideoPath', 'is', null), eb('assets.encodedVideoPath', '=', '')]))
.where('assets.isVisible', '=', true),
)
.where('assets.deletedAt', 'is', null)
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForVideoConversion(id: string) {
return this.db
.selectFrom('assets')
.select(['assets.id', 'assets.ownerId', 'assets.originalPath', 'assets.encodedVideoPath'])
.where('assets.id', '=', id)
.where('assets.type', '=', AssetType.VIDEO)
.executeTakeFirst();
}
@GenerateSql({ params: [], stream: true })
streamForMetadataExtraction(force?: boolean) {
return this.db
.selectFrom('assets')
.select(['assets.id'])
.$if(!force, (qb) =>
qb
.leftJoin('asset_job_status', 'asset_job_status.assetId', 'assets.id')
.where((eb) =>
eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]),
)
.where('assets.isVisible', '=', true),
)
.where('assets.deletedAt', 'is', null)
.stream();
}
private storageTemplateAssetQuery() {
return this.db
.selectFrom('assets')

View File

@ -80,21 +80,12 @@ describe('getEnv', () => {
const { database } = getEnv();
expect(database).toEqual({
config: {
kysely: expect.objectContaining({
host: 'database',
port: 5432,
database: 'immich',
username: 'postgres',
password: 'postgres',
}),
typeorm: expect.objectContaining({
type: 'postgres',
host: 'database',
port: 5432,
database: 'immich',
username: 'postgres',
password: 'postgres',
}),
connectionType: 'parts',
host: 'database',
port: 5432,
database: 'immich',
username: 'postgres',
password: 'postgres',
},
skipMigrations: false,
vectorExtension: 'vectors',
@ -110,88 +101,9 @@ describe('getEnv', () => {
it('should use DB_URL', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: 'database1',
password: 'postgres2',
user: 'postgres1',
port: 54_320,
database: 'immich',
});
});
it('should handle sslmode=require', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=prefer', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-ca', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-full', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=no-verify', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } });
});
it('should handle ssl=true', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: true });
});
it('should reject invalid ssl', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid';
expect(() => getEnv()).toThrowError('Invalid ssl option: invalid');
});
it('should handle socket: URLs', () => {
process.env.DB_URL = 'socket:/run/postgresql?db=database1';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/run/postgresql',
database: 'database1',
});
});
it('should handle sockets in postgres: URLs', () => {
process.env.DB_URL = 'postgres:///database2?host=/path/to/socket';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/path/to/socket',
database: 'database2',
expect(database.config).toMatchObject({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich',
});
});
});

View File

@ -7,8 +7,7 @@ import { Request, Response } from 'express';
import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { join, resolve } from 'node:path';
import { parse } from 'pg-connection-string';
import { join } from 'node:path';
import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators';
import { EnvDto } from 'src/dtos/env.dto';
@ -22,9 +21,7 @@ import {
QueueName,
} from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
import { isValidSsl, PostgresConnectionConfig } from 'src/utils/database';
import { setDifference } from 'src/utils/set';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
export interface EnvData {
host?: string;
@ -59,7 +56,7 @@ export interface EnvData {
};
database: {
config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig };
config: DatabaseConnectionParams;
skipMigrations: boolean;
vectorExtension: VectorExtension;
};
@ -152,14 +149,10 @@ const getEnv = (): EnvData => {
const isProd = environment === ImmichEnvironment.PRODUCTION;
const buildFolder = dto.IMMICH_BUILD_DATA || '/build';
const folders = {
// eslint-disable-next-line unicorn/prefer-module
dist: resolve(`${__dirname}/..`),
geodata: join(buildFolder, 'geodata'),
web: join(buildFolder, 'www'),
};
const databaseUrl = dto.DB_URL;
let redisConfig = {
host: dto.REDIS_HOSTNAME || 'redis',
port: dto.REDIS_PORT || 6379,
@ -191,30 +184,16 @@ const getEnv = (): EnvData => {
}
}
const parts = {
connectionType: 'parts',
host: dto.DB_HOSTNAME || 'database',
port: dto.DB_PORT || 5432,
username: dto.DB_USERNAME || 'postgres',
password: dto.DB_PASSWORD || 'postgres',
database: dto.DB_DATABASE_NAME || 'immich',
} as const;
let parsedOptions: PostgresConnectionConfig = parts;
if (dto.DB_URL) {
const parsed = parse(dto.DB_URL);
if (!isValidSsl(parsed.ssl)) {
throw new Error(`Invalid ssl option: ${parsed.ssl}`);
}
parsedOptions = {
...parsed,
ssl: parsed.ssl,
host: parsed.host ?? undefined,
port: parsed.port ? Number(parsed.port) : undefined,
database: parsed.database ?? undefined,
};
}
const databaseConnection: DatabaseConnectionParams = dto.DB_URL
? { connectionType: 'url', url: dto.DB_URL }
: {
connectionType: 'parts',
host: dto.DB_HOSTNAME || 'database',
port: dto.DB_PORT || 5432,
username: dto.DB_USERNAME || 'postgres',
password: dto.DB_PASSWORD || 'postgres',
database: dto.DB_DATABASE_NAME || 'immich',
};
return {
host: dto.IMMICH_HOST,
@ -269,21 +248,7 @@ const getEnv = (): EnvData => {
},
database: {
config: {
typeorm: {
type: 'postgres',
entities: [],
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
subscribers: [],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts),
},
kysely: parsedOptions,
},
config: databaseConnection,
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
},

View File

@ -3,7 +3,7 @@ import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import semver from 'semver';
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
import { DB } from 'src/db';
@ -205,8 +205,29 @@ export class DatabaseRepository {
const { rows } = await tableExists.execute(this.db);
const hasTypeOrmMigrations = !!rows[0]?.result;
if (hasTypeOrmMigrations) {
// eslint-disable-next-line unicorn/prefer-module
const dist = resolve(`${__dirname}/..`);
this.logger.debug('Running typeorm migrations');
const dataSource = new DataSource(database.config.typeorm);
const dataSource = new DataSource({
type: 'postgres',
entities: [],
subscribers: [],
migrations: [`${dist}/migrations` + '/*.{js,ts}'],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...(database.config.connectionType === 'url'
? { url: database.config.url }
: {
host: database.config.host,
port: database.config.port,
username: database.config.username,
password: database.config.password,
database: database.config.database,
}),
});
await dataSource.initialize();
await dataSource.runMigrations(options);
await dataSource.destroy();

View File

@ -14,6 +14,7 @@ import { SystemConfig } from 'src/config';
import { EventConfig } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ImmichWorker, MetadataKey, QueueName } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
@ -64,6 +65,7 @@ type EventMap = {
'assets.restore': [{ assetIds: string[]; userId: string }];
'job.start': [QueueName, JobItem];
'job.failed': [{ job: JobItem; error: Error | any }];
// session events
'session.delete': [{ sessionId: string }];
@ -104,6 +106,7 @@ export interface ClientEventMap {
on_server_version: [ServerVersionResponseDto];
on_config_update: [];
on_new_release: [ReleaseNotification];
on_notification: [NotificationDto];
on_session_delete: [string];
}

View File

@ -22,6 +22,7 @@ import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
@ -55,6 +56,7 @@ export const repositories = [
CryptoRepository,
DatabaseRepository,
DownloadRepository,
EmailRepository,
EventRepository,
JobRepository,
LibraryRepository,
@ -65,7 +67,7 @@ export const repositories = [
MemoryRepository,
MetadataRepository,
MoveRepository,
EmailRepository,
NotificationRepository,
OAuthRepository,
PartnerRepository,
PersonRepository,

View File

@ -7,7 +7,7 @@ import { Writable } from 'node:stream';
import sharp from 'sharp';
import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants';
import { Exif } from 'src/database';
import { Colorspace, LogLevel } from 'src/enum';
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import {
DecodeToBufferOptions,
@ -36,34 +36,51 @@ type ProgressEvent = {
percent?: number;
};
export type ExtractResult = {
buffer: Buffer;
format: RawExtractedFormat;
};
@Injectable()
export class MediaRepository {
constructor(private logger: LoggingRepository) {
this.logger.setContext(MediaRepository.name);
}
async extract(input: string, output: string): Promise<boolean> {
/**
*
* @param input file path to the input image
* @returns ExtractResult if succeeded, or null if failed
*/
async extract(input: string): Promise<ExtractResult | null> {
try {
// remove existing output file if it exists
// as exiftool-vendored does not support overwriting via "-w!" flag
// and throws "1 files could not be read" error when the output file exists
await fs.unlink(output).catch(() => null);
await exiftool.extractBinaryTag('JpgFromRaw2', input, output);
} catch {
try {
this.logger.debug('Extracting JPEG from RAW image:', input);
await exiftool.extractJpgFromRaw(input, output);
} catch (error: any) {
this.logger.debug('Could not extract JPEG from image, trying preview', error.message);
try {
await exiftool.extractPreview(input, output);
} catch (error: any) {
this.logger.debug('Could not extract preview from image', error.message);
return false;
}
}
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
return { buffer, format: RawExtractedFormat.JPEG };
} catch (error: any) {
this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
return { buffer, format: RawExtractedFormat.JPEG };
} catch (error: any) {
this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
return { buffer, format: RawExtractedFormat.JXL };
} catch (error: any) {
this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message);
}
try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
return { buffer, format: RawExtractedFormat.JPEG };
} catch (error: any) {
this.logger.debug('Could not extract preview buffer from image', error.message);
return null;
}
return true;
}
async writeExif(tags: Partial<Exif>, output: string): Promise<boolean> {
@ -104,7 +121,7 @@ export class MediaRepository {
}
}
decodeImage(input: string, options: DecodeToBufferOptions) {
decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
}
@ -235,7 +252,7 @@ export class MediaRepository {
});
}
async getImageDimensions(input: string): Promise<ImageDimensions> {
async getImageDimensions(input: string | Buffer): Promise<ImageDimensions> {
const { width = 0, height = 0 } = await sharp(input).metadata();
return { width, height };
}

View File

@ -0,0 +1,103 @@
import { Insertable, Kysely, Updateable } from 'kysely';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DB, Notifications } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { NotificationSearchDto } from 'src/dtos/notification.dto';
export class NotificationRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
cleanup() {
return this.db
.deleteFrom('notifications')
.where((eb) =>
eb.or([
// remove soft-deleted notifications
eb.and([eb('deletedAt', 'is not', null), eb('deletedAt', '<', DateTime.now().minus({ days: 3 }).toJSDate())]),
// remove old, read notifications
eb.and([
// keep recently read messages around for a few days
eb('readAt', '>', DateTime.now().minus({ days: 2 }).toJSDate()),
eb('createdAt', '<', DateTime.now().minus({ days: 15 }).toJSDate()),
]),
eb.and([
// remove super old, unread notifications
eb('readAt', '=', null),
eb('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate()),
]),
]),
)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, {}] }, { name: 'unread', params: [DummyValue.UUID, { unread: true }] })
search(userId: string, dto: NotificationSearchDto) {
return this.db
.selectFrom('notifications')
.select(columns.notification)
.where((qb) =>
qb.and({
userId,
id: dto.id,
level: dto.level,
type: dto.type,
readAt: dto.unread ? null : undefined,
}),
)
.where('deletedAt', 'is', null)
.orderBy('createdAt', 'desc')
.execute();
}
create(notification: Insertable<Notifications>) {
return this.db
.insertInto('notifications')
.values(notification)
.returning(columns.notification)
.executeTakeFirstOrThrow();
}
get(id: string) {
return this.db
.selectFrom('notifications')
.select(columns.notification)
.where('id', '=', id)
.where('deletedAt', 'is not', null)
.executeTakeFirst();
}
update(id: string, notification: Updateable<Notifications>) {
return this.db
.updateTable('notifications')
.set(notification)
.where('deletedAt', 'is', null)
.where('id', '=', id)
.returning(columns.notification)
.executeTakeFirstOrThrow();
}
async updateAll(ids: string[], notification: Updateable<Notifications>) {
await this.db.updateTable('notifications').set(notification).where('id', 'in', ids).execute();
}
async delete(id: string) {
await this.db
.updateTable('notifications')
.set({ deletedAt: DateTime.now().toJSDate() })
.where('id', '=', id)
.execute();
}
async deleteAll(ids: string[]) {
await this.db
.updateTable('notifications')
.set({ deletedAt: DateTime.now().toJSDate() })
.where('id', 'in', ids)
.execute();
}
}

View File

@ -28,6 +28,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table';
import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table';
import { MoveTable } from 'src/schema/tables/move.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
import { NotificationTable } from 'src/schema/tables/notification.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonTable } from 'src/schema/tables/person.table';
@ -76,6 +77,7 @@ export class ImmichDatabase {
MemoryTable,
MoveTable,
NaturalEarthCountriesTable,
NotificationTable,
PartnerAuditTable,
PartnerTable,
PersonTable,

View File

@ -0,0 +1,22 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), "userId" uuid, "level" character varying NOT NULL DEFAULT 'info', "type" character varying NOT NULL DEFAULT 'info', "data" jsonb, "title" character varying NOT NULL, "description" text, "readAt" timestamp with time zone);`.execute(db);
await sql`ALTER TABLE "notifications" ADD CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id");`.execute(db);
await sql`ALTER TABLE "notifications" ADD CONSTRAINT "FK_692a909ee0fa9383e7859f9b406" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "IDX_notifications_update_id" ON "notifications" ("updateId")`.execute(db);
await sql`CREATE INDEX "IDX_692a909ee0fa9383e7859f9b40" ON "notifications" ("userId")`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "notifications_updated_at"
BEFORE UPDATE ON "notifications"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "notifications_updated_at" ON "notifications";`.execute(db);
await sql`DROP INDEX "IDX_notifications_update_id";`.execute(db);
await sql`DROP INDEX "IDX_692a909ee0fa9383e7859f9b40";`.execute(db);
await sql`ALTER TABLE "notifications" DROP CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a";`.execute(db);
await sql`ALTER TABLE "notifications" DROP CONSTRAINT "FK_692a909ee0fa9383e7859f9b406";`.execute(db);
await sql`DROP TABLE "notifications";`.execute(db);
}

View File

@ -0,0 +1,14 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" ADD "avatarColor" character varying;`.execute(db);
await sql`
UPDATE "users"
SET "avatarColor" = "user_metadata"."value"->'avatar'->>'color'
FROM "user_metadata"
WHERE "users"."id" = "user_metadata"."userId" AND "user_metadata"."key" = 'preferences';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" DROP COLUMN "avatarColor";`.execute(db);
}

View File

@ -0,0 +1,52 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { NotificationLevel, NotificationType } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
} from 'src/sql-tools';
@Table('notifications')
@UpdatedAtTrigger('notifications_updated_at')
export class NotificationTable {
@PrimaryGeneratedColumn()
id!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@DeleteDateColumn()
deletedAt?: Date;
@UpdateIdColumn({ indexName: 'IDX_notifications_update_id' })
updateId?: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
userId!: string;
@Column({ default: NotificationLevel.Info })
level!: NotificationLevel;
@Column({ default: NotificationLevel.Info })
type!: NotificationType;
@Column({ type: 'jsonb', nullable: true })
data!: any | null;
@Column()
title!: string;
@Column({ type: 'text', nullable: true })
description!: string;
@Column({ type: 'timestamp with time zone', nullable: true })
readAt?: Date | null;
}

View File

@ -1,6 +1,6 @@
import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { UserStatus } from 'src/enum';
import { UserAvatarColor, UserStatus } from 'src/enum';
import { users_delete_audit } from 'src/schema/functions';
import {
AfterDeleteTrigger,
@ -49,6 +49,9 @@ export class UserTable {
@Column({ type: 'boolean', default: true })
shouldChangePassword!: Generated<boolean>;
@Column({ default: null })
avatarColor!: UserAvatarColor | null;
@DeleteDateColumn()
deletedAt!: Timestamp | null;

View File

@ -565,7 +565,7 @@ describe(AssetService.name, () => {
it('should remove faces', async () => {
const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] };
mocks.asset.getById.mockResolvedValue(assetWithFace);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetWithFace);
await sut.handleAssetDeletion({ id: assetWithFace.id, deleteOnDisk: true });
@ -592,7 +592,7 @@ describe(AssetService.name, () => {
it('should update stack primary asset if deleted asset was primary asset in a stack', async () => {
mocks.stack.update.mockResolvedValue(factory.stack() as any);
mocks.asset.getById.mockResolvedValue(assetStub.primaryImage);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.primaryImage);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
@ -604,7 +604,7 @@ describe(AssetService.name, () => {
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
mocks.stack.delete.mockResolvedValue();
mocks.asset.getById.mockResolvedValue({
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
...assetStub.primaryImage,
stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) },
});
@ -615,7 +615,7 @@ describe(AssetService.name, () => {
});
it('should delete a live photo', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any);
mocks.asset.getLivePhotoCount.mockResolvedValue(0);
await sut.handleAssetDeletion({
@ -653,7 +653,7 @@ describe(AssetService.name, () => {
it('should not delete a live motion part if it is being used by another asset', async () => {
mocks.asset.getLivePhotoCount.mockResolvedValue(2);
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any);
await sut.handleAssetDeletion({
id: assetStub.livePhotoStillAsset.id,
@ -680,12 +680,13 @@ describe(AssetService.name, () => {
});
it('should update usage', async () => {
mocks.asset.getById.mockResolvedValue(assetStub.image);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.image);
await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
});
it('should fail if asset could not be found', async () => {
mocks.assetJob.getForAssetDeletion.mockResolvedValue(void 0);
await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe(
JobStatus.FAILED,
);

View File

@ -189,13 +189,7 @@ export class AssetService extends BaseService {
async handleAssetDeletion(job: JobOf<JobName.ASSET_DELETION>): Promise<JobStatus> {
const { id, deleteOnDisk } = job;
const asset = await this.assetRepository.getById(id, {
faces: { person: true },
library: true,
stack: { assets: true },
exifInfo: true,
files: true,
});
const asset = await this.assetJobRepository.getForAssetDeletion(id);
if (!asset) {
return JobStatus.FAILED;

View File

@ -142,52 +142,55 @@ describe(BackupService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
mocks.storage.createWriteStream.mockReturnValue(new PassThrough());
});
it('should run a database backup successfully', async () => {
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.storage.createWriteStream).toHaveBeenCalled();
});
it('should rename file on success', async () => {
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.SUCCESS);
expect(mocks.storage.rename).toHaveBeenCalled();
});
it('should fail if pg_dumpall fails', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
});
it('should not rename file if pgdump fails and gzip succeeds', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
expect(mocks.storage.rename).not.toHaveBeenCalled();
});
it('should fail if gzip fails', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', ''));
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1');
});
it('should fail if write stream fails', async () => {
mocks.storage.createWriteStream.mockImplementation(() => {
throw new Error('error');
});
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('error');
});
it('should fail if rename fails', async () => {
mocks.storage.rename.mockRejectedValue(new Error('error'));
const result = await sut.handleBackupDatabase();
expect(result).toBe(JobStatus.FAILED);
await expect(sut.handleBackupDatabase()).rejects.toThrow('error');
});
it('should ignore unlink failing and still return failed job status', async () => {
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
mocks.storage.unlink.mockRejectedValue(new Error('error'));
const result = await sut.handleBackupDatabase();
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
expect(mocks.storage.unlink).toHaveBeenCalled();
expect(result).toBe(JobStatus.FAILED);
});
it.each`
postgresVersion | expectedVersion
${'14.10'} | ${14}

Some files were not shown because too many files have changed in this diff Show More